《Spring 实战》笔记3:高级装配1

环境与 profile

在开发软件的时候,有一个很大的挑战就是将应用程序从一个环境迁移到另外一个环境。开发阶段中,某些环境相关做法可能并不适合迁移到生产环境中,甚至即便迁移过去也无法正常工作。数据库配置、加密算法以及与外部系统的集成是跨环境部署时会发生变化的几个典型例子。

看起来简单的 DataSource 实际上并不是那么简单。它表现了在不同环境中某个 bean 会有所不同。我们必须有一种方式来配置 DataSource ,使其在每种环境下都会选择最为合适的配置。

其中一种方式就是在单独的配置类(或 XML )中配置每个 bean,然后在构造阶段确定要使用哪一个配置编译到可部署的环境中。这种方式的问题在于要为每种环境重新构建应用,当从开发阶段迁移到 QA 阶段时,重新构造也算不上什么大问题。但是,从 QA 阶段迁移到生产环境阶段时,重新构建可能引入 BUG 并且会在 QA 团队的成员中带来不安的情绪。

配置 profile bean

Spring 为环境相关的 bean 所提供的解决方案其实与构建时的方案没有太大的差别。当然,在这个过程中需要根据环境决定该创建哪个 bean 和不创建哪个 bean。不过 Spring 并不是在构建的时候做出这样的决策,而是等到运行时再来确定。这样的结果就是同一个部署单元(可能会是 WAR 文件)能够适用于所有的环境,没有必要进行重新构建

在 3.1 版本中,Spring 引入了 bean profile 的功能。要使用 profile,你首先要将所有不同的 bean 定义整理到一个或多个 profile 之中,在将应用部署到每个环境时,要确保对应的 profile 处于激活的状态

在 Java 配置中,可以使用 @Profile 注解指定某个 bean 属于哪一个 profile。例如,在配置类中,嵌入式数据库的 DataSource 可能会配置成如下所示:

@Configuration
@Profile("dev")
public class DataSourceConfig {

    @Bean(destroyMethod="shutdown")
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .addScript("classpath:schema.sql")
                .addScript("classpath:test-data.sql")
                .build();
    }
}

需要注意的是 @Profile 注解应用到了类级别啥概念,它会告诉 Spring 这个配置来中的 bean 只有在 dev profile 激活时才创建。如果 dev profile 没有激活的话,那么带有 @Bean 注解的方法都会被忽略。

同时,你可能还需要有一个适用于生产环境的配置,如下所示:

@Configuration
@Profile("prod")  // 只有 prod profile 激活的时候,才会创建对应的 bean。
public class DataSourceConfig {

    @Bean
    public DataSource jndiDataSource() {
        JndiObjectFactoryBean jndiObjectFactoryBean = new JndiObjectFactoryBean();
        jndiObjectFactoryBean.setJndiName("jdbc/myDS");
        jndiObjectFactoryBean.setResourceRef(true);
        jndiObjectFactoryBean.setProxyInterface(javax.sql.DataSource.class);
        return (DataSource) jndiObjectFactoryBean.getObject();
    }
}

在 Spring 3.1 中,只能在类级别上使用 @Profile 注解。不过,从 Spring 3.2 开始,你也可以在方法级别上使用 @Profile 注解,与 @Bean 注解一同使用。这样的话,就能将这两个 bean 的声明放到同一个配置类之中,如下所示:

@Configuration
public class DataSourceConfig {

    @Bean(destroyMethod="shutdown")
    @Profile("dev")
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .addScript("classpath:schema.sql")
                .addScript("classpath:test-data.sql")
                .build();
    }

    @Bean
    @Profile("prod")
    public DataSource jndiDataSource() {
        JndiObjectFactoryBean jndiObjectFactoryBean = new JndiObjectFactoryBean();
        jndiObjectFactoryBean.setJndiName("jdbc/myDS");
        jndiObjectFactoryBean.setResourceRef(true);
        jndiObjectFactoryBean.setProxyInterface(javax.sql.DataSource.class);
        return (DataSource) jndiObjectFactoryBean.getObject();
    }
}

尽管每个 DataSource bean 都被声明在一个 profile 中,并且只能当规定的 profile 激活时,相应的 bean 才会被创建,但是可能会有其他的 bean 并没有声明到一个给定的 profile 范围内。没有指定的 profile 的 bean 都会创建,与激活那个 profile 没有关系

在 XML 中配置 profile

我们也可以通过 <beans> 元素的 profil 属性,在 XML 中配置 profile bean。

<?xml version="1.0" encoding="UTF-8"?>
<beans
    ...
    profile="dev">

    <jdbc:embedded-database id="dataSource">
        <jdbc:script location="classpath:schema.sql" />
        <jdbc:script location="classpath:test-data.sql" />
    </jdbc:embedded-database>
</beans>

还可以在根 <beans> 元素中嵌套定义 <beans> 元素,而不是为每个环境都创建一个 profile XML 文件。这能够将所有的 profile bean 定义放到同一个 XML 文件中,如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<beans
    ...>

    <beans profile="dev">
        <jdbc:embedded-database id="dataSource">
            <jdbc:script location="classpath:schema.sql" />
            <jdbc:script location="classpath:test-data.sql" />
        </jdbc:embedded-database>
    </beans>

    <beans profile="qa">
        <bean id="dataSource"
              class="org.apache.commons.dbcp.BasicDataSource"
              destroy-method="close"
              p:url="jdbc:h2:tcp://dbserver/~/test"
              p:driverClassName="org.h2.Driver"
              p:username="test"
              p:password="123456"
              p:initialSize="20"
              p:maxActive="39"/>
    </beans>

    <beans profile="prod">
        <jee:jndi-lookup id="dataSource"
                         lazy-init="true"
                         jndi-name="jdbc/myDatabase"
                         resource-ref="true"
                         proxy-interface="javax.sql.DataSource" />
    </beans>
</beans>

除了所有的 bean 定义到同一个 XML 文件中,这种配置方式与定义单独的 XML 文件中实际效果是一样的。在运行时,只会创建一个 bean,这取决于处于激活状态的是哪一个 profile

激活 profile

Spring 在确定哪个 profile 处于激活状态时,需要依赖两个独立的属性:sring.profiles.activespring.profiles.default 。如果设置了 spring.profiles.active 属性的话,那么它的值就会用来确定哪个 profile 是激活的。但如果没有设置 spring.profiles.active 的话,那么 Spring 将会查找 spring.profiles.default 的值。如果两者都没有的话,那就没有激活的 profile。

有多种方式来设置这两个属性

  • 作为 DispatcherServlet 的初始化参数 <init-param>
  • 作为 Web 的应用上下文参数 <context-param>
  • 作为 JNDI 条目
  • 作为环境变量
  • 作为 JVM 的系统属性
  • 在集成测试类上,使用 @ActiveProfiles 注解设置。

作者喜欢的一种方式是使用 DisPatcherServlet 的参数将 spring.profiles.default 设置为开发环境,会在 Servlet 上下文中进行设置。

<!-- 在 Web 应用的 web.xml 文件中设置默认的 profile -->

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://java.sun.com/xml/ns/javaee"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">

    <!-- 加载 spring 容器 -->
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath:spring/applicationContext*.xml</param-value>
    </context-param>

    <!-- 为上下文设置默认的 profile -->
    <context-param>
        <param-name>spring.profiles.default</param-name>
        <param-value>dev</param-value>
    </context-param>

    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
    </listener>

    <!-- springmvc 的前端控制器 -->
    <servlet>
        <servlet-name>appServlet</servlet-name>
        <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
        <!-- contextConfigLocation 不是必须的, 如果不配置 contextConfigLocation, springmvc 的配置文件默认在:WEB-INF/servlet 的 name+"-servlet.xml" -->
        <init-param>
            <param-name>contextConfigLocation</param-name>
            <param-value>classpath:spring/springmvc.xml</param-value>
        </init-param>
        <!-- 为 Servlet 设置默认的 profile -->
        <init-param>
            <param-name>spring.profiles.default</param-name>
            <param-value>dev</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>appServlet</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>
</web-app>

按照这种方式设置 spring.profiles.default ,所有开发人员能从版本控制软件中获得应用的程序源码,并使用开发环境的设置(如切入式数据库)运行代码而不需要任何额外的设置。

当应用程序部署到 QA、生产、或其他环境中时,负责部署的人根据情况使用系统属性、环境变量、或 JNDI 设置 spring.profiles.active 即可。当设置 spring.profiles.avtive 后,至于 spring.profiles.default 设置成什么已经无所谓了:系统会优先使用 spring.profiles.active 中设置的 profile

在 spring.profiles.active 和 spring.profiles.default 中,profile 使用的都是复数形式。这意味着你可以同时激活多个 profile,这可以通过列出多个 profile 名称,并以逗号分隔来实现。当然,同时启用 dev 和 prod profile 可能也没有太大的意义,不过你可以同时设置多个彼此不相关的 profile。

使用profile进行测试

当运行集成测试时,通常会希望采用与生产环境(或者是生产环境的部分子集)相同的配置进行测试。但是,如果配置中的 bean 定义在了 profile 中,那么在运行测试时,我们就需要有一种方式来启用合适的 profile。

Spring 提供了 @ActiveProfiles 注解,我们可以使用它来指定运行测试时要激活哪个 profile。在集成测试时,通常想要激活的是开发环境的 profile。例如,下面的测试类片段展现了使用 @ActiveProfiles 激活 dev profile:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = DataSourceConfig.class)
@ActiveProfiles("dev")
public static class PersistenceTest{
    ...
}

在条件化创建 bean,Spring 的 profil 机制是一种很好的方法,这里的条件要基于哪个 profile 处于激活状态来判断。Spring 4.0 中提供了一种更为通用的机制来实现条件化的 bean 定义,这这种机制之中,条件化完全由你来确定,Spring 4 和 @Conditional 注解定义条件化的 bean。

条件化的 bean

假设你希望一个或所个 bean 只有在类路径下包含特定的库时才创建。或者我们希望某个 bean 只有当另外某个特定的 bean 也声明了之后才创建,我们还可能要求只有某个特定的环境变量设置之后,才会创建某个 bean。

在 Spring 4 之前,很难实现这种级别的条件化配置,但是 Spring 4.0 引入了一个新的 @Conditional 注解,它可以用到带有 @Bean 注解的方法上。如果给定的条件计算结果为 true,就会创建这个 bean,否则的话,这个 bean 会被忽略。

假设有一个名为 MagicBean 的类,我们希望只有设置了 magic 环境属性的时候,Spring 才会实例化这个类。如果环境中没有这个属性,则忽略。

@Bean
@Conditional(MagicExistsCondition.class)
public MagicBean magicBean{
    return new MagicBean();
}

Condition

可以看到,@Conditional 中给定了一个 Class,它指明了条件,在本例中,也就是 MagicExistsCondition@Conditional 将会通过 Condition 接口进行条件对比:

public interface Condition {
    boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata);
}

设置给 @Conditional 的类可以是任意实现了 Condition 接口的类型。可以看出来,这个接口实现起来很简单直接,只需提供 matches() 方法的实现即可。如果 matches() 方法返回 true,那么就会创建带有 @Conditional 注解的 bean。如果 matches() 方法返回 false,将不会创建这些 bean。

在本例中,我们需要创建 Condition 的实现并根据环境中是否存在 magic 属性来做出决策。

public class MagicExistsCondition implements Condition {
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        Environment env = context.getEnvironment();
        return env.containsProperty("magic");  // 检查 magic 属性
    }
}

matches() 方法很简单但功能强大。它通过给定的 ConditionContext 对象进而得到 Environment 对象,并使用这个对象检查环境中是否存在名为 magic 的环境属性。如果满足这个条件的话,matches() 方法就会返回 true。所带来的结果就是条件能够得到满足,所有 @Conditional 注解上引用 MagicExistsCondition 的 bean 都会被创建。

话说回来,如果这个属性不存在的话,就无法满足条件,matches() 方法会返回 false,这些 bean 都不会被创建。

MagicExistsCondition 中只是使用了 ConditionContext 得到的 Environment,但 Condition 实现的考量因素可能会比这更多。matches() 方法会得到 ConditionContextAnnotatedTypeMetadata 对象用来做出决策。

ConditionContext

public interface ConditionContext {
    BeanDefinitionRegistry getRegistry();
    ConfigurableListableBeanFactory getBeanFactory();
    Environment getEnvironment();
    ResourceLoader getResourceLoader();
    ClassLoader getClassLoader();
}

通过 ConditionContext ,我们可以做到如下几点:

  • 借助 getRegistry() 返回的 BeanDefinitionRegistry 检查 bean 定义;
  • 借助 getBeanFactory() 返回的 ConfigurableListableBeanFactory 检查 bean 是否存在,甚至探查 bean 的属性;
  • 借助 getEnvironment() 返回的 Environment 检查环境变量是否存在以及它的值是什么;
  • 读取并探查 getResourceLoader() 返回的 ResourceLoader 所加载的资源;
  • 借助 getClassLoader() 返回的 ClassLoader 加载并检查类是否存在。

AnnotatedTypeMetadata

AnnotatedTypeMetadata 则能够让我们检查带有 @Bean 注解的方法上还有什么其他的注解。AnnotatedTypeMetadata 也是一个接口。它如下所示:

public interface AnnotatedTypeMetadata {
    boolean isAnnotated(String annotationName);
    Map<String, Object> getAnnotationAttributes(String annotationName);
    Map<String, Object> getAnnotationAttributes(String annotationName, boolean classValuesAsString);
    MultiValueMap<String, Object> getAllAnnotationAttributes(String annotationName);
    MultiValueMap<String, Object> getAllAnnotationAttributes(String annotationName, boolean classValuesAsString);
}

借助 isAnnotated() 方法,我们能够判断带有 @Bean 注解的方法是不是还有其他特定的注解。借助其他的那些方法,我们能够检查 @Bean 注解的方法上其他注解的属性。

新版 Profile 注解重构

从 Spring 4 开始,@Profile 注解进行了重构,使其基于 @ConditionalCondition 实现。作为如何使用 @ConditionalCondition 的例子,我们来看一下在 Spring 4 中,@Profile 是如何实现的。

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(ProfileCondition.class)
public @interface Profile {
    String[] value();
}

@Profile 本身也使用了 @Conditional 注解,并且引用 ProfileCondition 作为 Condition 实现。如下所示, ProfileCondition 实现了 Condition 接口,并且在做出决策的过程中,考虑到了 ConditionContextAnnotatedTypeMetadata 中的多个因素。

class ProfileCondition implements Condition {

    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        if (context.getEnvironment() != null) {
            MultiValueMap<String, Object> attrs = metadata.getAllAnnotationAttributes(Profile.class.getName());
            if (attrs != null) {
                for (Object value : attrs.get("value")) {
                    if (context.getEnvironment().acceptsProfiles(((String[]) value))) {
                        return true;
                    }
                }
                return false;
            }
        }
        return true;
    }
}

可以看到, ProfileCondition 通过 AnnotatedTypeMetadata 得到了用于 @Profile 注解的所有属性。借助该信息,它会明确地检查 value 属性,该属性包含了 bean 的 profile 名称。然后,它根据通过 ConditionContext 得到的 Environment 来检查[借助 acceptsProfiles() 方法]该 profile 是否处于激活状态。

处理自动装配的歧义性

我们已经看到如何使用自动装配让 Spring 完全负责将 bean 引用注入到构造参数和属性中。自动装配能够提供很大的帮助,因为它会减少装配应用程序组件时所需要的显式配置的数量。

不过,仅有一个 bean 匹配所需的结果时,自动装配才是有效的。如果不仅有一个 bean 能够匹配结果的话,这种歧义性会阻碍 Spring 自动装配属性、构造器参数或方法参数。

为了阐述自动装配的歧义性,假设我们提供 @Autowired 注解标注了 setDessert 方法

@Autowired
public void setDessert(Dessert dessert) {
  this.dessert = dessert;
}

Dessert 是一个接口,并且有三个类实现了这个接口

@Component
public class Cake implements Dessert {...}

@Component
public class Cookies implements Dessert { ...}

@Component
public class IceCream implements Dessert {...}

因为这三个实现均使用 @Component 注解,在组件进行扫描的时候,能够发现他们并将其创建为 Spring 应用上下文里面的 bean。然后,当 Spring 试图自动装配的 setDessert() 中的 Dessert 参数时,它们并没有唯一、无歧义的可选值
。Spring 只好宣告失败并抛出异常:NoUniqueBeanDefinitionException

当确实发生歧义性的时候,Spring 提供了多种可选方案来解决这样的问题。你可以将可选 bean 中的某一个设为首选(primary)的bean,或者使用限定符(qualifier)来帮助 Spring 将可选的 bean 的范围缩小到只有一个 bean。

标示首选的 bean (@Primary)

在 Spring 中,可以通过 @Primary 来表达首选方案。

// 将 @Component 注解的 IceCream bean 声明为首选的 bean
@Component
@Primary
public class IceCream implements Dessert {...}
// 或者通过 JavaConfig 显示配置地声明 IceCream
@Bean
@Primary
public Dessert IceCream() {
  return new IceCream();
}
<!-- 使用 XML 配置 bean -->
<bean id="ceCream"
    class="com.desserteater.IceCream"
    primary="true"/>

当然如果标注了两个或者多个首选 bean,那么就无法工作了。

限定自动装配的 bean (@Qualifier)

简单例子

设置首选 bean 的局限性在于 @Primary 无法将可选方案的范围限定到唯一一个无歧义性的选项中。它只能标示一个优先的可选方案。当首选 bean 的数量超过一个时,我们并没有其他的方法进一步缩小可选范围。

就解决歧义性问题而言,限定符是一种更为强大的机制,Spring 的限定符能够在所有可选的 bean 上进行缩小范围的操作,最终能够达到只有一个 bean 满足所规定的限制条件。如果将所有的限定符都用上后依然存在歧义性,那么你可以继续使用更多的限定符来缩小选择范围。

@Qualifier 注解是使用限定符的主要方式。它可以与 @Autowired@Inject 协同使用,在注入的时候指定想要注入进去的是哪个 bean。例如,我们确保要将 IceCream 注入到 setDessert() 之中。

@Autowired
@Qualifier("iceCream")
public void setDessert(Dessert dessert) {
  this.dessert = dessert;
}

这是使用限定符的最简单的例子。为 @Qualifier 注解所设置的参数就是想要注入的 bean 的 ID。所有使用 @Component 注解声明的类都会创建为 bean,并且 bean 的 ID 为首字母变为小写的类名

@Qualifier("iceCream") 所引用的 bean 要具有 String 类型的“iceCream”作为限定符。如果没有指定其他的限定符的话,所有的 bean 都会给定一个默认的限定符,这个限定符与 bean 的 ID 相同。

如果你重构了 IceCrean 类,将其重名为 Gelato 的话,bean 的默认 ID 和默认的限定符会变为 gelato,这就无法匹配 setDessert() 方法中的限定符,自动装配会失败。

创建自定义的限定符

可以为 bean 设置自己的限定符,而不是依赖于将 ID 作为限定符。在这里所需要做的就是在 bean 声明上加 @Qualifier 注解。

@Component
@Qualifier("cold")
public class IceCream implements Dessert {...}

在这种情况下,cold 限定符分配了 IceCream bean。因为它没耦合类名,因此你可以随意重构 IceCream 的类名,而不必担心会破坏自动装配。在注入的地方,只要引用cold限定符就可以了:

@Autowired
@Qualifier("cold")
public void setDessert(Dessert dessert) {
  this.dessert = dessert;
}
// 通过 Java 配置显式定义 bean 的时候,@Qualifier 也可以与 @Bean 注解一起。
@Bean
@Qualifier
public Dessert dessert () {
  return new IceCream();
}

使用自定义的限定符注解

面向特性的限定符要比基于 bean ID 的限定符更好一些。但是,如果多个 bean 都具备相同特性的话,这种做法也会出现问题。例如,如果引入了这个新的 Dessert bean,限定符也是 cold,这样再次产生了歧义。

可能想到的解决方案就是在注入点和 bean 定义的地方同时再添加另外一个 @Qualifier 注解。但 Java 不允许在同一个条目上重复出现相同类型的多个注解。(Java 8 允许出现重复的注解,只要这个注解本身在定义的时候带有 @Repeatable 注解就可以。不过,Spring 的 @Qualifier 注解并没有在定义时添加 @Repeatable 注解。)

但是,我们可以创建自定义的限定符注解,借助这样的注解来表达 bean 所希望限定的特性。这个注解,它本身要使用 @Qualifier 注解来标注。这样我们将不再使用 @Qualifier("cold"),而是使用自定义的 @Cold 注解,该注解的定义如下所示:

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Qualifier
public @interface cold {}

同样,你可以创建一个新的 @Creamy 注解来代替 @Qualifier("creamy")

现在我们重新看一下 IceCream,并为其添加 @Cold@Creamy 注解

@Component
@Clod
@Creamy
public class IceCream implements Dessert {...}

最终,在注入点,我们使用必要的限定符注解进行任意组合,从而将可选的范围缩小到只有一个 bean 满足需求。为了得到 IceCream bean,setDessert() 方法可以这样使用注解:

@Autowired
@Cold
@Creamy
public void setDessert(Dessert dessert) {
  this.dessert = dessert;
}

通过声明自定义的限定符注解,我们可以同时使用多个限定符,不会再有 Java 编译器的限制或错误,与此同时,相对于原始的 @Qualifier 并借助于 String 类型来指定限定符,自定义的注解也更为类型安全

在本节和前节中,我们讨论了几种通过自定义注解扩展 Spring 的方式,为了创建自定义的条件化注解,我们建议一个新的注解并在这个注解上添加了 @Conditional,为了创建自定义的限定符注解,我们创建一个新的注解并在这个注解上添加了 @Qualifer。这种技术可以用到很多 Spring 注解中,从而能够将他们组合在一起形成特定目标的自定义注解。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 bin07280@qq.com

文章标题:《Spring 实战》笔记3:高级装配1

文章字数:5.4k

本文作者:Bin

发布时间:2019-04-24, 21:14:22

最后更新:2019-12-08, 14:00:19

原始链接:http://coolview.github.io/2019/04/24/Spring/%E3%80%8ASpring%20%E5%AE%9E%E6%88%98%E3%80%8B%E7%AC%94%E8%AE%B03%EF%BC%9A%E9%AB%98%E7%BA%A7%E8%A3%85%E9%85%8D1/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录