什么是Spring?
Spring是一个支持快速开发Java EE应用程序的框架。它提供了一系列底层容器和基础设施,并可以和大量常用的开源框架无缝集成,可以说是开发Java EE应用程序的必备。
Spring Framework主要包括几个模块:

  • 支持IoC和AOP的容器;
  • 支持JDBC和ORM的数据访问模块;
  • 支持声明式事务的模块;
  • 支持基于Servlet的MVC开发;
  • 支持基于Reactive的Web开发;
  • 以及集成JMS、JavaMail、JMX、缓存等其他模块。

    1.IoC容器

    什么是容器?容器是一种为某种特定组件的运行提供必要支持的一个软件环境。例如,Tomcat就是一个Servlet容器,它可以为Servlet的运行提供运行环境。类似Docker这样的软件也是一个容器,它提供了必要的Linux环境以便运行一个特定的Linux进程。
    通常来说,使用容器运行组件,除了提供一个组件运行环境之外,容器还提供了许多底层服务。例如,Servlet容器底层实现了TCP连接,解析HTTP协议等非常复杂的服务,如果没有容器来提供这些服务,我们就无法编写像Servlet这样代码简单,功能强大的组件。早期的JavaEE服务器提供的EJB容器最重要的功能就是通过声明式事务服务,使得EJB组件的开发人员不必自己编写冗长的事务处理代码,所以极大地简化了事务处理。
    Spring的核心就是提供了一个IoC容器,它可以管理所有轻量级的JavaBean组件,提供的底层服务包括组件的生命周期管理、配置和组装服务、AOP支持,以及建立在AOP基础上的声明式事务服务等。

    1.IoC原理

    Spring提供的容器又称为IoC容器,什么是IoC?
    IoC全称Inversion of Control,直译为控制反转。
    IoC模式下,控制权发生了反转,即从应用程序转移到了IoC容器,所有组件不再由应用程序自己创建和配置,而是由IoC容器负责,这样,应用程序只需要直接使用已经创建好并且配置好的组件。为了能让组件在IoC容器中被“装配”出来,需要某种“注入”机制,例如,BookService自己并不会创建DataSource,而是等待外部通过setDataSource()方法来注入一个DataSource:

    1. public class BookService {
    2. private DataSource dataSource;
    3. public void setDataSource(DataSource dataSource) {
    4. this.dataSource = dataSource;
    5. }
    6. }

    不直接new一个DataSource,而是注入一个DataSource,这个小小的改动虽然简单,却带来了一系列好处:

  1. BookService不再关心如何创建DataSource,因此,不必编写读取数据库配置之类的代码;
  2. DataSource实例被注入到BookService,同样也可以注入到UserService,因此,共享一个组件非常简单;
  3. 测试BookService更容易,因为注入的是DataSource,可以使用内存数据库,而不是真实的MySQL配置。

因此,IoC又称为依赖注入(DI:Dependency Injection),它解决了一个最主要的问题:将组件的创建+配置与组件的使用相分离,并且,由IoC容器负责管理组件的生命周期。
因为IoC容器要负责实例化所有的组件,因此,有必要告诉容器如何创建组件,以及各组件的依赖关系。一种最简单的配置是通过XML文件来实现,例如:

  1. <beans>
  2. <bean id="dataSource" class="HikariDataSource" />
  3. <bean id="bookService" class="BookService">
  4. <property name="dataSource" ref="dataSource" />
  5. </bean>
  6. <bean id="userService" class="UserService">
  7. <property name="dataSource" ref="dataSource" />
  8. </bean>
  9. </beans>

上述XML配置文件指示IoC容器创建3个JavaBean组件,并把id为dataSource的组件通过属性dataSource(即调用setDataSource()方法)注入到另外两个组件中。
在Spring的IoC容器中,我们把所有组件统称为JavaBean,即配置一个组件就是配置一个Bean。

1.依赖注入方式

我们从上面的代码可以看到,依赖注入可以通过set()方法实现。但依赖注入也可以通过构造方法实现。
很多Java类都具有带参数的构造方法,如果我们把BookService改造为通过构造方法注入,那么实现代码如下:

  1. public class BookService {
  2. private DataSource dataSource;
  3. public BookService(DataSource dataSource) {
  4. this.dataSource = dataSource;
  5. }
  6. }

Spring的IoC容器同时支持属性注入和构造方法注入,并允许混合使用。

2.无侵入容器

在设计上,Spring的IoC容器是一个高度可扩展的无侵入容器。所谓无侵入,是指应用程序的组件无需实现Spring的特定接口,或者说,组件根本不知道自己在Spring的容器中运行。这种无侵入的设计有以下好处:

  1. 应用程序组件既可以在Spring的IoC容器中运行,也可以自己编写代码自行组装配置;
  2. 测试的时候并不依赖Spring容器,可单独进行测试,大大提高了开发效率。

    2.装配Bean

    需要编写一个特定的application.xml配置文件,告诉Spring的IoC容器应该如何创建并组装Bean:

    1. <?xml version="1.0" encoding="UTF-8"?>
    2. <beans xmlns="http://www.springframework.org/schema/beans"
    3. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    4. xsi:schemaLocation="http://www.springframework.org/schema/beans
    5. https://www.springframework.org/schema/beans/spring-beans.xsd">
    6. <bean id="userService" class="com.itranswarp.learnjava.service.UserService">
    7. <property name="mailService" ref="mailService" />
    8. </bean>
    9. <bean id="mailService" class="com.itranswarp.learnjava.service.MailService" />
    10. </beans>

    注意观察上述配置文件,其中与XML Schema相关的部分格式是固定的,我们只关注两个的配置:

  • 每个都有一个id标识,相当于Bean的唯一ID;
  • 在userServiceBean中,通过注入了另一个Bean;
  • Bean的顺序不重要,Spring根据依赖关系会自动正确初始化。

把上述XML配置文件用Java代码写出来,就像这样:

  1. UserService userService = new UserService();
  2. MailService mailService = new MailService();
  3. userService.setMailService(mailService);

只不过Spring容器是通过读取XML文件后使用反射完成的。
如果注入的不是Bean,而是boolean、int、String这样的数据类型,则通过value注入,例如,创建一个HikariDataSource:

  1. <bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource">
  2. <property name="jdbcUrl" value="jdbc:mysql://localhost:3306/test" />
  3. <property name="username" value="root" />
  4. <property name="password" value="password" />
  5. <property name="maximumPoolSize" value="10" />
  6. <property name="autoCommit" value="true" />
  7. </bean>

最后一步,我们需要创建一个Spring的IoC容器实例,然后加载配置文件,让Spring容器为我们创建并装配好配置文件中指定的所有Bean,这只需要一行代码:

  1. ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");

接下来,我们就可以从Spring容器中“取出”装配好的Bean然后使用它:

  1. // 获取Bean:
  2. UserService userService = context.getBean(UserService.class);
  3. // 正常调用:
  4. User user = userService.login("bob@example.com", "password");

完整的main()方法如下:

  1. public class Main {
  2. public static void main(String[] args) {
  3. ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
  4. UserService userService = context.getBean(UserService.class);
  5. User user = userService.login("bob@example.com", "password");
  6. System.out.println(user.getName());
  7. }
  8. }

1.ApplicationContext

我们从创建Spring容器的代码:

  1. ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");

可以看到,Spring容器就是ApplicationContext,它是一个接口,有很多实现类,这里我们选择ClassPathXmlApplicationContext,表示它会自动从classpath中查找指定的XML配置文件。
获得了ApplicationContext的实例,就获得了IoC容器的引用。从ApplicationContext中我们可以根据Bean的ID获取Bean,但更多的时候我们根据Bean的类型获取Bean的引用:

  1. UserService userService = context.getBean(UserService.class);

Spring还提供另一种IoC容器叫BeanFactory,使用方式和ApplicationContext类似:

  1. BeanFactory factory = new XmlBeanFactory(new ClassPathResource("application.xml"));
  2. MailService mailService = factory.getBean(MailService.class);

BeanFactory和ApplicationContext的区别在于,BeanFactory的实现是按需创建,即第一次获取Bean时才创建这个Bean,而ApplicationContext会一次性创建所有的Bean。实际上,ApplicationContext接口是从BeanFactory接口继承而来的,并且,ApplicationContext提供了一些额外的功能,包括国际化支持、事件和通知机制等。通常情况下,我们总是使用ApplicationContext,很少会考虑使用BeanFactory。
Spring的IoC容器接口是ApplicationContext,并提供了多种实现类;
通过XML配置文件创建IoC容器时,使用ClassPathXmlApplicationContext;
持有IoC容器后,通过getBean()方法获取Bean的引用。

3.使用Annotation配置

添加一个@Component注解:

  1. @Component
  2. public class MailService {
  3. ...
  4. }

这个@Component注解就相当于定义了一个Bean,它有一个可选的名称,默认是mailService,即小写开头的类名。
然后,我们给UserService添加一个@Component注解和一个@Autowired注解:

  1. @Component
  2. public class UserService {
  3. @Autowired
  4. MailService mailService;
  5. ...
  6. }

使用@Autowired就相当于把指定类型的Bean注入到指定的字段中。和XML配置相比,@Autowired大幅简化了注入,因为它不但可以写在set()方法上,还可以直接写在字段上,甚至可以写在构造方法中:

  1. @Component
  2. public class UserService {
  3. MailService mailService;
  4. public UserService(@Autowired MailService mailService) {
  5. this.mailService = mailService;
  6. }
  7. ...
  8. }

我们一般把@Autowired写在字段上,通常使用package权限的字段,便于测试。
最后,编写一个AppConfig类启动容器:

  1. @Configuration
  2. @ComponentScan
  3. public class AppConfig {
  4. public static void main(String[] args) {
  5. ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
  6. UserService userService = context.getBean(UserService.class);
  7. User user = userService.login("bob@example.com", "password");
  8. System.out.println(user.getName());
  9. }
  10. }

除了main()方法外,AppConfig标注了@Configuration,表示它是一个配置类,因为我们创建ApplicationContext时:

  1. ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);

使用的实现类是AnnotationConfigApplicationContext,必须传入一个标注了@Configuration的类名。
此外,AppConfig还标注了@ComponentScan,它告诉容器,自动搜索当前类所在的包以及子包,把所有标注为@Component的Bean自动创建出来,并根据@Autowired进行装配。
使用Annotation配合自动扫描能大幅简化Spring的配置,我们只需要保证:

  • 每个Bean被标注为@Component并正确使用@Autowired注入;
  • 配置类被标注为@Configuration和@ComponentScan;
  • 所有Bean均在指定包以及子包内。

使用@ComponentScan非常方便,但是,我们也要特别注意包的层次结构。通常来说,启动配置AppConfig位于自定义的顶层包(例如com.itranswarp.learnjava),其他Bean按类别放入子包。
使用Annotation可以大幅简化配置,每个Bean通过@Component和@Autowired注入;
必须合理设计包的层次结构,才能发挥@ComponentScan的威力。

4.定制Bean

1.Scope

对于Spring容器来说,当我们把一个Bean标记为@Component后,它就会自动为我们创建一个单例(Singleton),即容器初始化时创建Bean,容器关闭前销毁Bean。在容器运行期间,我们调用getBean(Class)获取到的Bean总是同一个实例。
还有一种Bean,我们每次调用getBean(Class),容器都返回一个新的实例,这种Bean称为Prototype(原型),它的生命周期显然和Singleton不同。声明一个Prototype的Bean时,需要添加一个额外的@Scope注解:

  1. @Component
  2. @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // @Scope("prototype")
  3. public class MailSession {
  4. ...
  5. }

2.注入List

有些时候,我们会有一系列接口相同,不同实现类的Bean。例如,注册用户时,我们要对email、password和name这3个变量进行验证。为了便于扩展,我们先定义验证接口:

  1. public interface Validator {
  2. void validate(String email, String password, String name);
  3. }

然后,分别使用3个Validator对用户参数进行验证:

  1. @Component
  2. public class EmailValidator implements Validator {
  3. public void validate(String email, String password, String name) {
  4. if (!email.matches("^[a-z0-9]+\\@[a-z0-9]+\\.[a-z]{2,10}$")) {
  5. throw new IllegalArgumentException("invalid email: " + email);
  6. }
  7. }
  8. }
  9. @Component
  10. public class PasswordValidator implements Validator {
  11. public void validate(String email, String password, String name) {
  12. if (!password.matches("^.{6,20}$")) {
  13. throw new IllegalArgumentException("invalid password");
  14. }
  15. }
  16. }
  17. @Component
  18. public class NameValidator implements Validator {
  19. public void validate(String email, String password, String name) {
  20. if (name == null || name.isBlank() || name.length() > 20) {
  21. throw new IllegalArgumentException("invalid name: " + name);
  22. }
  23. }
  24. }

最后,我们通过一个Validators作为入口进行验证:

  1. @Component
  2. public class Validators {
  3. @Autowired
  4. List<Validator> validators;
  5. public void validate(String email, String password, String name) {
  6. for (var validator : this.validators) {
  7. validator.validate(email, password, name);
  8. }
  9. }
  10. }

注意到Validators被注入了一个List,Spring会自动把所有类型为Validator的Bean装配为一个List注入进来,这样一来,我们每新增一个Validator类型,就自动被Spring装配到Validators中了,非常方便。
因为Spring是通过扫描classpath获取到所有的Bean,而List是有序的,要指定List中Bean的顺序,可以加上@Order注解:

  1. @Component
  2. @Order(1)
  3. public class EmailValidator implements Validator {
  4. ...
  5. }
  6. @Component
  7. @Order(2)
  8. public class PasswordValidator implements Validator {
  9. ...
  10. }
  11. @Component
  12. @Order(3)
  13. public class NameValidator implements Validator {
  14. ...
  15. }

3.可选注入

默认情况下,当我们标记了一个@Autowired后,Spring如果没有找到对应类型的Bean,它会抛出NoSuchBeanDefinitionException异常。
可以给@Autowired增加一个required = false的参数:

  1. @Component
  2. public class MailService {
  3. @Autowired(required = false)
  4. ZoneId zoneId = ZoneId.systemDefault();
  5. ...
  6. }

这个参数告诉Spring容器,如果找到一个类型为ZoneId的Bean,就注入,如果找不到,就忽略。
这种方式非常适合有定义就使用定义,没有就使用默认值的情况。

4.创建第三方Bean

如果一个Bean不在我们自己的package管理之内,例如ZoneId,如何创建它?
答案是我们自己在@Configuration类中编写一个Java方法创建并返回它,注意给方法标记一个@Bean注解:

  1. @Configuration
  2. @ComponentScan
  3. public class AppConfig {
  4. // 创建一个Bean:
  5. @Bean
  6. ZoneId createZoneId() {
  7. return ZoneId.of("Z");
  8. }
  9. }

Spring对标记为@Bean的方法只调用一次,因此返回的Bean仍然是单例。

5.初始化和销毁

有些时候,一个Bean在注入必要的依赖后,需要进行初始化(监听消息等)。在容器关闭时,有时候还需要清理资源(关闭连接池等)。我们通常会定义一个init()方法进行初始化,定义一个shutdown()方法进行清理,然后,引入JSR-250定义的Annotation:

  1. <dependency>
  2. <groupId>javax.annotation</groupId>
  3. <artifactId>javax.annotation-api</artifactId>
  4. <version>1.3.2</version>
  5. </dependency>

在Bean的初始化和清理方法上标记@PostConstruct和@PreDestroy:

  1. @Component
  2. public class MailService {
  3. @Autowired(required = false)
  4. ZoneId zoneId = ZoneId.systemDefault();
  5. @PostConstruct
  6. public void init() {
  7. System.out.println("Init mail service with zoneId = " + this.zoneId);
  8. }
  9. @PreDestroy
  10. public void shutdown() {
  11. System.out.println("Shutdown mail service");
  12. }
  13. }

Spring容器会对上述Bean做如下初始化流程:

  • 调用构造方法创建MailService实例;
  • 根据@Autowired进行注入;
  • 调用标记有@PostConstruct的init()方法进行初始化。

而销毁时,容器会首先调用标记有@PreDestroy的shutdown()方法。
Spring只根据Annotation查找无参数方法,对方法名不作要求。

6.使用别名

默认情况下,对一种类型的Bean,容器只创建一个实例。但有些时候,我们需要对一种类型的Bean创建多个实例。例如,同时连接多个数据库,就必须创建多个DataSource实例。
如果我们在@Configuration类中创建了多个同类型的Bean:

  1. @Configuration
  2. @ComponentScan
  3. public class AppConfig {
  4. @Bean
  5. ZoneId createZoneOfZ() {
  6. return ZoneId.of("Z");
  7. }
  8. @Bean
  9. ZoneId createZoneOfUTC8() {
  10. return ZoneId.of("UTC+08:00");
  11. }
  12. }

Spring会报NoUniqueBeanDefinitionException异常,意思是出现了重复的Bean定义。
这个时候,需要给每个Bean添加不同的名字:

  1. @Configuration
  2. @ComponentScan
  3. public class AppConfig {
  4. @Bean("z")
  5. ZoneId createZoneOfZ() {
  6. return ZoneId.of("Z");
  7. }
  8. @Bean
  9. @Qualifier("utc8")
  10. ZoneId createZoneOfUTC8() {
  11. return ZoneId.of("UTC+08:00");
  12. }
  13. }

可以用@Bean(“name”)指定别名,也可以用@Bean+@Qualifier(“name”)指定别名。
存在多个同类型的Bean时,注入ZoneId又会报错:

  1. NoUniqueBeanDefinitionException: No qualifying bean of type 'java.time.ZoneId' available: expected single matching bean but found 2

意思是期待找到唯一的ZoneId类型Bean,但是找到两。因此,注入时,要指定Bean的名称:

  1. @Component
  2. public class MailService {
  3. @Autowired(required = false)
  4. @Qualifier("z") // 指定注入名称为"z"的ZoneId
  5. ZoneId zoneId = ZoneId.systemDefault();
  6. ...
  7. }

还有一种方法是把其中某个Bean指定为@Primary:

  1. @Configuration
  2. @ComponentScan
  3. public class AppConfig {
  4. @Bean
  5. @Primary // 指定为主要Bean
  6. @Qualifier("z")
  7. ZoneId createZoneOfZ() {
  8. return ZoneId.of("Z");
  9. }
  10. @Bean
  11. @Qualifier("utc8")
  12. ZoneId createZoneOfUTC8() {
  13. return ZoneId.of("UTC+08:00");
  14. }
  15. }

这样,在注入时,如果没有指出Bean的名字,Spring会注入标记有@Primary的Bean。这种方式也很常用。例如,对于主从两个数据源,通常将主数据源定义为@Primary:

  1. @Configuration
  2. @ComponentScan
  3. public class AppConfig {
  4. @Bean
  5. @Primary
  6. DataSource createMasterDataSource() {
  7. ...
  8. }
  9. @Bean
  10. @Qualifier("slave")
  11. DataSource createSlaveDataSource() {
  12. ...
  13. }
  14. }

其他Bean默认注入的就是主数据源。如果要注入从数据源,那么只需要指定名称即可。

7.使用FactoryBean

我们在设计模式的工厂方法中讲到,很多时候,可以通过工厂模式创建对象。Spring也提供了工厂模式,允许定义一个工厂,然后由工厂创建真正的Bean。
用工厂模式创建Bean需要实现FactoryBean接口。我们观察下面的代码:

  1. @Component
  2. public class ZoneIdFactoryBean implements FactoryBean<ZoneId> {
  3. String zone = "Z";
  4. @Override
  5. public ZoneId getObject() throws Exception {
  6. return ZoneId.of(zone);
  7. }
  8. @Override
  9. public Class<?> getObjectType() {
  10. return ZoneId.class;
  11. }
  12. }

当一个Bean实现了FactoryBean接口后,Spring会先实例化这个工厂,然后调用getObject()创建真正的Bean。getObjectType()可以指定创建的Bean的类型,因为指定类型不一定与实际类型一致,可以是接口或抽象类。
因此,如果定义了一个FactoryBean,要注意Spring创建的Bean实际上是这个FactoryBean的getObject()方法返回的Bean。为了和普通Bean区分,我们通常都以XxxFactoryBean命名。
Spring默认使用Singleton创建Bean,也可指定Scope为Prototype;
可将相同类型的Bean注入List;
可用@Autowired(required=false)允许可选注入;
可用带@Bean标注的方法创建Bean;
可使用@PostConstruct和@PreDestroy对Bean进行初始化和清理;
相同类型的Bean只能有一个指定为@Primary,其他必须用@Quanlifier(“beanName”)指定别名;
注入时,可通过别名@Quanlifier(“beanName”)指定某个Bean;
可以定义FactoryBean来使用工厂模式创建Bean。

5.使用Resource

在Java程序中,我们经常会读取配置文件、资源文件等。使用Spring容器时,我们也可以把“文件”注入进来,方便程序读取。
例如,AppService需要读取logo.txt这个文件,通常情况下,我们需要写很多繁琐的代码,主要是为了定位文件,打开InputStream。
Spring提供了一个org.springframework.core.io.Resource(注意不是javax.annotation.Resource),它可以像String、int一样使用@Value注入:

  1. @Component
  2. public class AppService {
  3. @Value("classpath:/logo.txt")
  4. private Resource resource;
  5. private String logo;
  6. @PostConstruct
  7. public void init() throws IOException {
  8. try (var reader = new BufferedReader(
  9. new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
  10. this.logo = reader.lines().collect(Collectors.joining("\n"));
  11. }
  12. }
  13. }

注入Resource最常用的方式是通过classpath,即类似classpath:/logo.txt表示在classpath中搜索logo.txt文件,然后,我们直接调用Resource.getInputStream()就可以获取到输入流,避免了自己搜索文件的代码。
也可以直接指定文件的路径,例如:

  1. @Value("file:/path/to/logo.txt")
  2. private Resource resource;

使用Maven的标准目录结构,所有资源文件放入src/main/resources即可。
Spring提供了Resource类便于注入资源文件。
最常用的注入是通过classpath以classpath:/path/to/file的形式注入。

6.注入配置

在开发应用程序时,经常需要读取配置文件。最常用的配置方法是以key=value的形式写在.properties文件中。
例如,MailService根据配置的app.zone=Asia/Shanghai来决定使用哪个时区。要读取配置文件,我们可以使用讲到的Resource来读取位于classpath下的一个app.properties文件。但是,这样仍然比较繁琐。
Spring容器还提供了一个更简单的@PropertySource来自动读取配置文件。我们只需要在@Configuration配置类上再添加一个注解:

  1. @Configuration
  2. @ComponentScan
  3. @PropertySource("app.properties") // 表示读取classpath的app.properties
  4. public class AppConfig {
  5. @Value("${app.zone:Z}")
  6. String zoneId;
  7. @Bean
  8. ZoneId createZoneId() {
  9. return ZoneId.of(zoneId);
  10. }
  11. }

Spring容器看到@PropertySource(“app.properties”)注解后,自动读取这个配置文件,然后,我们使用@Value正常注入:

  1. @Value("${app.zone:Z}")
  2. String zoneId;

注意注入的字符串语法,它的格式如下:

  • “${app.zone}”表示读取key为app.zone的value,如果key不存在,启动将报错;
  • “${app.zone:Z}”表示读取key为app.zone的value,但如果key不存在,就使用默认值Z。

这样一来,我们就可以根据app.zone的配置来创建ZoneId。
还可以把注入的注解写到方法参数中:

  1. @Bean
  2. ZoneId createZoneId(@Value("${app.zone:Z}") String zoneId) {
  3. return ZoneId.of(zoneId);
  4. }

可见,先使用@PropertySource读取配置文件,然后通过@Value以${key:defaultValue}的形式注入,可以极大地简化读取配置的麻烦。
另一种注入配置的方式是先通过一个简单的JavaBean持有所有的配置,例如,一个SmtpConfig:

  1. @Component
  2. public class SmtpConfig {
  3. @Value("${smtp.host}")
  4. private String host;
  5. @Value("${smtp.port:25}")
  6. private int port;
  7. public String getHost() {
  8. return host;
  9. }
  10. public int getPort() {
  11. return port;
  12. }
  13. }

然后,在需要读取的地方,使用#{smtpConfig.host}注入:

  1. @Component
  2. public class MailService {
  3. @Value("#{smtpConfig.host}")
  4. private String smtpHost;
  5. @Value("#{smtpConfig.port}")
  6. private int smtpPort;
  7. }

使用一个独立的JavaBean持有所有属性,然后在其他Bean中以#{bean.property}注入的好处是,多个Bean都可以引用同一个Bean的某个属性。例如,如果SmtpConfig决定从数据库中读取相关配置项,那么MailService注入的@Value(“#{smtpConfig.host}”)仍然可以不修改正常运行。
Spring容器可以通过@PropertySource自动读取配置,并以@Value(“${key}”)的形式注入;
可以通过${key:defaultValue}指定默认值;
以#{bean.property}形式注入时,Spring容器自动把指定Bean的指定属性值注入。

7.使用条件装配

开发应用程序时,我们会使用开发环境,例如,使用内存数据库以便快速启动。而运行在生产环境时,我们会使用生产环境,例如,使用MySQL数据库。如果应用程序可以根据自身的环境做一些适配,无疑会更加灵活。
Spring为应用程序准备了Profile这一概念,用来表示不同的环境。例如,我们分别定义开发、测试和生产这3个环境:

  • native
  • test
  • production

创建某个Bean时,Spring容器可以根据注解@Profile来决定是否创建。例如,以下配置:

  1. @Configuration
  2. @ComponentScan
  3. public class AppConfig {
  4. @Bean
  5. @Profile("!test")
  6. ZoneId createZoneId() {
  7. return ZoneId.systemDefault();
  8. }
  9. @Bean
  10. @Profile("test")
  11. ZoneId createZoneIdForTest() {
  12. return ZoneId.of("America/New_York");
  13. }
  14. }

如果当前的Profile设置为test,则Spring容器会调用createZoneIdForTest()创建ZoneId,否则,调用createZoneId()创建ZoneId。注意到@Profile(“!test”)表示非test环境。
在运行程序时,加上JVM参数-Dspring.profiles.active=test就可以指定以test环境启动。
实际上,Spring允许指定多个Profile,例如:

  1. -Dspring.profiles.active=test,master

可以表示test环境,并使用master分支代码。
要满足多个Profile条件,可以这样写:

  1. @Bean
  2. @Profile({ "test", "master" }) // 同时满足test和master
  3. ZoneId createZoneId() {
  4. ...
  5. }

1.使用Conditional

除了根据@Profile条件来决定是否创建某个Bean外,Spring还可以根据@Conditional决定是否创建某个Bean。
例如,我们对SmtpMailService添加如下注解:

  1. @Component
  2. @Conditional(OnSmtpEnvCondition.class)
  3. public class SmtpMailService implements MailService {
  4. ...
  5. }

它的意思是,如果满足OnSmtpEnvCondition的条件,才会创建SmtpMailService这个Bean。OnSmtpEnvCondition的条件是什么呢?我们看一下代码:

  1. public class OnSmtpEnvCondition implements Condition {
  2. public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
  3. return "true".equalsIgnoreCase(System.getenv("smtp"));
  4. }
  5. }

因此,OnSmtpEnvCondition的条件是存在环境变量smtp,值为true。这样,我们就可以通过环境变量来控制是否创建SmtpMailService。
Spring只提供了@Conditional注解,具体判断逻辑还需要我们自己实现。Spring Boot提供了更多使用起来更简单的条件注解,例如,如果配置文件中存在app.smtp=true,则创建MailService:

  1. @Component
  2. @ConditionalOnProperty(name="app.smtp", havingValue="true")
  3. public class MailService {
  4. ...
  5. }

如果当前classpath中存在类javax.mail.Transport,则创建MailService:

  1. @Component
  2. @ConditionalOnClass(name = "javax.mail.Transport")
  3. public class MailService {
  4. ...
  5. }

后续我们会介绍Spring Boot的条件装配。我们以文件存储为例,假设我们需要保存用户上传的头像,并返回存储路径,在本地开发运行时,我们总是存储到文件:

  1. @Component
  2. @ConditionalOnProperty(name = "app.storage", havingValue = "file", matchIfMissing = true)
  3. public class FileUploader implements Uploader {
  4. ...
  5. }

在生产环境运行时,我们会把文件存储到类似AWS S3上:

  1. @Component
  2. @ConditionalOnProperty(name = "app.storage", havingValue = "s3")
  3. public class S3Uploader implements Uploader {
  4. ...
  5. }

其他需要存储的服务则注入Uploader:

  1. @Component
  2. public class UserImageService {
  3. @Autowired
  4. Uploader uploader;
  5. }

当应用程序检测到配置文件存在app.storage=s3时,自动使用S3Uploader,如果存在配置app.storage=file,或者配置app.storage不存在,则使用FileUploader。
可见,使用条件注解,能更灵活地装配Bean。
Spring允许通过@Profile配置不同的Bean;
Spring还提供了@Conditional来进行条件装配,Spring Boot在此基础上进一步提供了基于配置、Class、Bean等条件进行装配。

2.使用AOP

AOP是Aspect Oriented Programming,即面向切面编程。
那什么是AOP?
我们先回顾一下OOP:Object Oriented Programming,OOP作为面向对象编程的模式,获得了巨大的成功,OOP的主要功能是数据封装、继承和多态。
而AOP是一种新的编程方式,它和OOP不同,OOP把系统看作多个对象的交互,AOP把系统分解为不同的关注点,或者称之为切面(Aspect)。
要理解AOP的概念,我们先用OOP举例,比如一个业务组件BookService,它有几个业务方法:

  • createBook:添加新的Book;
  • updateBook:修改Book;
  • deleteBook:删除Book。

对每个业务方法,例如,createBook(),除了业务逻辑,还需要安全检查、日志记录和事务处理,它的代码像这样:

  1. public class BookService {
  2. public void createBook(Book book) {
  3. securityCheck();
  4. Transaction tx = startTransaction();
  5. try {
  6. // 核心业务逻辑
  7. tx.commit();
  8. } catch (RuntimeException e) {
  9. tx.rollback();
  10. throw e;
  11. }
  12. log("created book: " + book);
  13. }
  14. }

继续编写updateBook(),代码如下:

  1. public class BookService {
  2. public void updateBook(Book book) {
  3. securityCheck();
  4. Transaction tx = startTransaction();
  5. try {
  6. // 核心业务逻辑
  7. tx.commit();
  8. } catch (RuntimeException e) {
  9. tx.rollback();
  10. throw e;
  11. }
  12. log("updated book: " + book);
  13. }
  14. }

对于安全检查、日志、事务等代码,它们会重复出现在每个业务方法中。使用OOP,我们很难将这些四处分散的代码模块化。
考察业务模型可以发现,BookService关心的是自身的核心逻辑,但整个系统还要求关注安全检查、日志、事务等功能,这些功能实际上“横跨”多个业务方法,为了实现这些功能,不得不在每个业务方法上重复编写代码。
一种可行的方式是使用Proxy模式,将某个功能,例如,权限检查,放入Proxy中:

  1. public class SecurityCheckBookService implements BookService {
  2. private final BookService target;
  3. public SecurityCheckBookService(BookService target) {
  4. this.target = target;
  5. }
  6. public void createBook(Book book) {
  7. securityCheck();
  8. target.createBook(book);
  9. }
  10. public void updateBook(Book book) {
  11. securityCheck();
  12. target.updateBook(book);
  13. }
  14. public void deleteBook(Book book) {
  15. securityCheck();
  16. target.deleteBook(book);
  17. }
  18. private void securityCheck() {
  19. ...
  20. }
  21. }

这种方式的缺点是比较麻烦,必须先抽取接口,然后,针对每个方法实现Proxy。
另一种方法是,既然SecurityCheckBookService的代码都是标准的Proxy样板代码,不如把权限检查视作一种切面(Aspect),把日志、事务也视为切面,然后,以某种自动化的方式,把切面织入到核心逻辑中,实现Proxy模式。
如果我们以AOP的视角来编写上述业务,可以依次实现:

  1. 核心逻辑,即BookService;
  2. 切面逻辑,即:
  3. 权限检查的Aspect;
  4. 日志的Aspect;
  5. 事务的Aspect。

然后,以某种方式,让框架来把上述3个Aspect以Proxy的方式“织入”到BookService中,这样一来,就不必编写复杂而冗长的Proxy模式。

1.AOP原理

如何把切面织入到核心逻辑中?这正是AOP需要解决的问题。换句话说,如果客户端获得了BookService的引用,当调用bookService.createBook()时,如何对调用方法进行拦截,并在拦截前后进行安全检查、日志、事务等处理,就相当于完成了所有业务功能。
在Java平台上,对于AOP的织入,有3种方式:

  1. 编译期:在编译时,由编译器把切面调用编译进字节码,这种方式需要定义新的关键字并扩展编译器,AspectJ就扩展了Java编译器,使用关键字aspect来实现织入;
  2. 类加载器:在目标类被装载到JVM时,通过一个特殊的类加载器,对目标类的字节码重新“增强”;
  3. 运行期:目标对象和切面都是普通Java类,通过JVM的动态代理功能或者第三方库实现运行期动态织入。

最简单的方式是第三种,Spring的AOP实现就是基于JVM的动态代理。由于JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者Javassist这些第三方库实现。
AOP技术看上去比较神秘,但实际上,它本质就是一个动态代理,让我们把一些常用功能如权限检查、日志、事务等,从每个业务方法中剥离出来。
需要特别指出的是,AOP对于解决特定问题,例如事务管理非常有用,这是因为分散在各处的事务代码几乎是完全相同的,并且它们需要的参数(JDBC的Connection)也是固定的。另一些特定问题,如日志,就不那么容易实现,因为日志虽然简单,但打印日志的时候,经常需要捕获局部变量,如果使用AOP实现日志,我们只能输出固定格式的日志,因此,使用AOP时,必须适合特定的场景。

2.装配AOP

在AOP编程中,我们经常会遇到下面的概念:

  • Aspect:切面,即一个横跨多个核心逻辑的功能,或者称之为系统关注点;
  • Joinpoint:连接点,即定义在应用程序流程的何处插入切面的执行;
  • Pointcut:切入点,即一组连接点的集合;
  • Advice:增强,指特定连接点上执行的动作;
  • Introduction:引介,指为一个已有的Java对象动态地增加新的接口;
  • Weaving:织入,指将切面整合到程序的执行流程中;
  • Interceptor:拦截器,是一种实现增强的方式;
  • Target Object:目标对象,即真正执行业务的核心逻辑对象;
  • AOP Proxy:AOP代理,是客户端持有的增强后的对象引用。

看完上述术语,是不是感觉对AOP有了进一步的困惑?其实,我们不用关心AOP创造的“术语”,只需要理解AOP本质上只是一种代理模式的实现方式,在Spring的容器中实现AOP特别方便。
我们以UserService和MailService为例,这两个属于核心业务逻辑,现在,我们准备给UserService的每个业务方法执行前添加日志,给MailService的每个业务方法执行前后添加日志,在Spring中,需要以下步骤:
首先,我们通过Maven引入Spring对AOP的支持:

  1. <dependency>
  2. <groupId>org.springframework</groupId>
  3. <artifactId>spring-aspects</artifactId>
  4. <version>${spring.version}</version>
  5. </dependency>

上述依赖会自动引入AspectJ,使用AspectJ实现AOP比较方便,因为它的定义比较简单。
然后,我们定义一个LoggingAspect:

  1. @Aspect
  2. @Component
  3. public class LoggingAspect {
  4. // 在执行UserService的每个方法前执行:
  5. @Before("execution(public * com.itranswarp.learnjava.service.UserService.*(..))")
  6. public void doAccessCheck() {
  7. System.err.println("[Before] do access check...");
  8. }
  9. // 在执行MailService的每个方法前后执行:
  10. @Around("execution(public * com.itranswarp.learnjava.service.MailService.*(..))")
  11. public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
  12. System.err.println("[Around] start " + pjp.getSignature());
  13. Object retVal = pjp.proceed();
  14. System.err.println("[Around] done " + pjp.getSignature());
  15. return retVal;
  16. }
  17. }

观察doAccessCheck()方法,我们定义了一个@Before注解,后面的字符串是告诉AspectJ应该在何处执行该方法,这里写的意思是:执行UserService的每个public方法前执行doAccessCheck()代码。
再观察doLogging()方法,我们定义了一个@Around注解,它和@Before不同,@Around可以决定是否执行目标方法,因此,我们在doLogging()内部先打印日志,再调用方法,最后打印日志后返回结果。
在LoggingAspect类的声明处,除了用@Component表示它本身也是一个Bean外,我们再加上@Aspect注解,表示它的@Before标注的方法需要注入到UserService的每个public方法执行前,@Around标注的方法需要注入到MailService的每个public方法执行前后。
紧接着,我们需要给@Configuration类加上一个@EnableAspectJAutoProxy注解:

  1. @Configuration
  2. @ComponentScan
  3. @EnableAspectJAutoProxy
  4. public class AppConfig {
  5. ...
  6. }

Spring的IoC容器看到这个注解,就会自动查找带有@Aspect的Bean,然后根据每个方法的@Before、@Around等注解把AOP注入到特定的Bean中。执行代码,我们可以看到以下输出:

  1. [Before] do access check...
  2. [Around] start void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
  3. Welcome, test!
  4. [Around] done void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
  5. [Before] do access check...
  6. [Around] start void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)
  7. Hi, Bob! You are logged in at 2020-02-14T23:13:52.167996+08:00[Asia/Shanghai]
  8. [Around] done void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)

这说明执行业务逻辑前后,确实执行了我们定义的Aspect(即LoggingAspect的方法)。
LoggingAspect定义的方法,是如何注入到其他Bean的呢?
其实AOP的原理非常简单。我们以LoggingAspect.doAccessCheck()为例,要把它注入到UserService的每个public方法中,最简单的方法是编写一个子类,并持有原始实例的引用:

  1. public UserServiceAopProxy extends UserService {
  2. private UserService target;
  3. private LoggingAspect aspect;
  4. public UserServiceAopProxy(UserService target, LoggingAspect aspect) {
  5. this.target = target;
  6. this.aspect = aspect;
  7. }
  8. public User login(String email, String password) {
  9. // 先执行Aspect的代码:
  10. aspect.doAccessCheck();
  11. // 再执行UserService的逻辑:
  12. return target.login(email, password);
  13. }
  14. public User register(String email, String password, String name) {
  15. aspect.doAccessCheck();
  16. return target.register(email, password, name);
  17. }
  18. ...
  19. }

这些都是Spring容器启动时为我们自动创建的注入了Aspect的子类,它取代了原始的UserService(原始的UserService实例作为内部变量隐藏在UserServiceAopProxy中)。如果我们打印从Spring容器获取的UserService实例类型,它类似UserServiceEnhancerBySpringCGLIB1f44e01c,实际上是Spring使用CGLIB动态创建的子类,但对于调用方来说,感觉不到任何区别。
Spring对接口类型使用JDK动态代理,对普通类使用CGLIB创建子类。如果一个Bean的class是final,Spring将无法为其创建子类。
可见,虽然Spring容器内部实现AOP的逻辑比较复杂(需要使用AspectJ解析注解,并通过CGLIB实现代理类),但我们使用AOP非常简单,一共需要三步:

  1. 定义执行方法,并在方法上通过AspectJ的注解告诉Spring应该在何处调用此方法;
  2. 标记@Component和@Aspect;
  3. 在@Configuration类上标注@EnableAspectJAutoProxy。

至于AspectJ的注入语法则比较复杂,请参考Spring文档
Spring也提供其他方法来装配AOP,但都没有使用AspectJ注解的方式来得简洁明了。

3.拦截器类型

顾名思义,拦截器有以下类型:

  • @Before:这种拦截器先执行拦截代码,再执行目标代码。如果拦截器抛异常,那么目标代码就不执行了;
  • @After:这种拦截器先执行目标代码,再执行拦截器代码。无论目标代码是否抛异常,拦截器代码都会执行;
  • @AfterReturning:和@After不同的是,只有当目标代码正常返回时,才执行拦截器代码;
  • @AfterThrowing:和@After不同的是,只有当目标代码抛出了异常时,才执行拦截器代码;
  • @Around:能完全控制目标代码是否执行,并可以在执行前后、抛异常后执行任意拦截代码,可以说是包含了上面所有功能。

在Spring容器中使用AOP非常简单,只需要定义执行方法,并用AspectJ的注解标注应该在何处触发并执行。
Spring通过CGLIB动态创建子类等方式来实现AOP代理模式,大大简化了代码。

4.使用注解装配AOP

以一个实际例子演示如何使用注解实现AOP装配。为了监控应用程序的性能,我们定义一个性能监控的注解:

  1. @Target(METHOD)
  2. @Retention(RUNTIME)
  3. public @interface MetricTime {
  4. String value();
  5. }

在需要被监控的关键方法上标注该注解:

  1. @Component
  2. public class UserService {
  3. // 监控register()方法性能:
  4. @MetricTime("register")
  5. public User register(String email, String password, String name) {
  6. ...
  7. }
  8. ...
  9. }

然后,我们定义MetricAspect:

  1. @Aspect
  2. @Component
  3. public class MetricAspect {
  4. @Around("@annotation(metricTime)")
  5. public Object metric(ProceedingJoinPoint joinPoint, MetricTime metricTime) throws Throwable {
  6. String name = metricTime.value();
  7. long start = System.currentTimeMillis();
  8. try {
  9. return joinPoint.proceed();
  10. } finally {
  11. long t = System.currentTimeMillis() - start;
  12. // 写入日志或发送至JMX:
  13. System.err.println("[Metrics] " + name + ": " + t + "ms");
  14. }
  15. }
  16. }

注意metric()方法标注了@Around(“@annotation(metricTime)”),它的意思是,符合条件的目标方法是带有@MetricTime注解的方法,因为metric()方法参数类型是MetricTime(注意参数名是metricTime不是MetricTime),我们通过它获取性能监控的名称。
有了@MetricTime注解,再配合MetricAspect,任何Bean,只要方法标注了@MetricTime注解,就可以自动实现性能监控。运行代码,输出结果如下:

  1. Welcome, Bob!
  2. [Metrics] register: 16ms

使用注解实现AOP需要先定义注解,然后使用@Around(“@annotation(name)”)实现装配;
使用注解既简单,又能明确标识AOP装配,是使用AOP推荐的方式。

5.AOP避坑指南

无论是使用AspectJ语法,还是配合Annotation,使用AOP,实际上就是让Spring自动为我们创建一个Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此,AOP本质上就是一个代理模式。
因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果我们没能深入理解其运行原理和实现机制,就极有可能遇到各种诡异的问题。
我们来看一个实际的例子。
假设我们定义了一个UserService的Bean:

  1. @Component
  2. public class UserService {
  3. // 成员变量:
  4. public final ZoneId zoneId = ZoneId.systemDefault();
  5. // 构造方法:
  6. public UserService() {
  7. System.out.println("UserService(): init...");
  8. System.out.println("UserService(): zoneId = " + this.zoneId);
  9. }
  10. // public方法:
  11. public ZoneId getZoneId() {
  12. return zoneId;
  13. }
  14. // public final方法:
  15. public final ZoneId getFinalZoneId() {
  16. return zoneId;
  17. }
  18. }

再写个MailService,并注入UserService:

  1. @Component
  2. public class MailService {
  3. @Autowired
  4. UserService userService;
  5. public String sendMail() {
  6. ZoneId zoneId = userService.zoneId;
  7. String dt = ZonedDateTime.now(zoneId).toString();
  8. return "Hello, it is " + dt;
  9. }
  10. }

最后用main()方法测试一下:

  1. @Configuration
  2. @ComponentScan
  3. public class AppConfig {
  4. public static void main(String[] args) {
  5. ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
  6. MailService mailService = context.getBean(MailService.class);
  7. System.out.println(mailService.sendMail());
  8. }
  9. }

查看输出,一切正常:

  1. UserService(): init...
  2. UserService(): zoneId = Asia/Shanghai
  3. Hello, it is 2020-04-12T10:23:22.917721+08:00[Asia/Shanghai]

下一步,我们给UserService加上AOP支持,就添加一个最简单的LoggingAspect:

  1. @Aspect
  2. @Component
  3. public class LoggingAspect {
  4. @Before("execution(public * com..*.UserService.*(..))")
  5. public void doAccessCheck() {
  6. System.err.println("[Before] do access check...");
  7. }
  8. }

别忘了在AppConfig上加上@EnableAspectJAutoProxy。再次运行,不出意外的话,会得到一个NullPointerException:

  1. Exception in thread "main" java.lang.NullPointerException: zone
  2. at java.base/java.util.Objects.requireNonNull(Objects.java:246)
  3. at java.base/java.time.Clock.system(Clock.java:203)
  4. at java.base/java.time.ZonedDateTime.now(ZonedDateTime.java:216)
  5. at com.itranswarp.learnjava.service.MailService.sendMail(MailService.java:19)
  6. at com.itranswarp.learnjava.AppConfig.main(AppConfig.java:21)

仔细跟踪代码,会发现null值出现在MailService.sendMail()内部的这一行代码:

  1. @Component
  2. public class MailService {
  3. @Autowired
  4. UserService userService;
  5. public String sendMail() {
  6. ZoneId zoneId = userService.zoneId;
  7. System.out.println(zoneId); // null
  8. ...
  9. }
  10. }

我们还故意在UserService中特意用final修饰了一下成员变量:

  1. @Component
  2. public class UserService {
  3. public final ZoneId zoneId = ZoneId.systemDefault();
  4. ...
  5. }

为什么加了AOP就报NPE,去了AOP就一切正常?final字段不执行,难道JVM有问题?为了解答这个诡异的问题,我们需要深入理解Spring使用CGLIB生成Proxy的原理:
第一步,正常创建一个UserService的原始实例,这是通过反射调用构造方法实现的,它的行为和我们预期的完全一致;
第二步,通过CGLIB创建一个UserService的子类,并引用了原始实例和LoggingAspect:

  1. public UserService$$EnhancerBySpringCGLIB extends UserService {
  2. UserService target;
  3. LoggingAspect aspect;
  4. public UserService$$EnhancerBySpringCGLIB() {
  5. }
  6. public ZoneId getZoneId() {
  7. aspect.doAccessCheck();
  8. return target.getZoneId();
  9. }
  10. }

如果我们观察Spring创建的AOP代理,它的类名总是类似UserServiceEnhancerBySpringCGLIB1c76af9d(你没看错,Java的类名实际上允许$字符)。为了让调用方获得UserService的引用,它必须继承自UserService。然后,该代理类会覆写所有public和protected方法,并在内部将调用委托给原始的UserService实例。
这里出现了两个UserService实例:
一个是我们代码中定义的原始实例,它的成员变量已经按照我们预期的方式被初始化完成:

  1. UserService original = new UserService();

第二个UserService实例实际上类型是UserService$$EnhancerBySpringCGLIB,它引用了原始的UserService实例:

  1. UserService$$EnhancerBySpringCGLIB proxy = new UserService$$EnhancerBySpringCGLIB();
  2. proxy.target = original;
  3. proxy.aspect = ...

注意到这种情况仅出现在启用了AOP的情况,此刻,从ApplicationContext中获取的UserService实例是proxy,注入到MailService中的UserService实例也是proxy。
那么最终的问题来了:proxy实例的成员变量,也就是从UserService继承的zoneId,它的值是null。
原因在于,UserService成员变量的初始化:

  1. public class UserService {
  2. public final ZoneId zoneId = ZoneId.systemDefault();
  3. ...
  4. }

在UserService$$EnhancerBySpringCGLIB中,并未执行。原因是,没必要初始化proxy的成员变量,因为proxy的目的是代理方法。
实际上,成员变量的初始化是在构造方法中完成的。这是我们看到的代码:

  1. public class UserService {
  2. public final ZoneId zoneId = ZoneId.systemDefault();
  3. public UserService() {
  4. }
  5. }

这是编译器实际编译的代码:

  1. public class UserService {
  2. public final ZoneId zoneId;
  3. public UserService() {
  4. super(); // 构造方法的第一行代码总是调用super()
  5. zoneId = ZoneId.systemDefault(); // 继续初始化成员变量
  6. }
  7. }

然而,对于Spring通过CGLIB动态创建的UserService$$EnhancerBySpringCGLIB代理类,它的构造方法中,并未调用super(),因此,从父类继承的成员变量,包括final类型的成员变量,统统都没有初始化。
有的会问:Java语言规定,任何类的构造方法,第一行必须调用super(),如果没有,编译器会自动加上,怎么Spring的CGLIB就可以搞特殊?
这是因为自动加super()的功能是Java编译器实现的,它发现你没加,就自动给加上,发现你加错了,就报编译错误。但实际上,如果直接构造字节码,一个类的构造方法中,不一定非要调用super()。Spring使用CGLIB构造的Proxy类,是直接生成字节码,并没有源码-编译-字节码这个步骤,因此:
Spring通过CGLIB创建的代理类,不会初始化代理类自身继承的任何成员变量,包括final类型的成员变量!
再考察MailService的代码:

  1. @Component
  2. public class MailService {
  3. @Autowired
  4. UserService userService;
  5. public String sendMail() {
  6. ZoneId zoneId = userService.zoneId;
  7. System.out.println(zoneId); // null
  8. ...
  9. }
  10. }

如果没有启用AOP,注入的是原始的UserService实例,那么一切正常,因为UserService实例的zoneId字段已经被正确初始化了。
如果启动了AOP,注入的是代理后的UserServiceEnhancerBySpringCGLIB实例,那么问题大了:获取的UserServiceEnhancerBySpringCGLIB实例的zoneId字段,永远为null。
那么问题来了:启用了AOP,如何修复?
修复很简单,只需要把直接访问字段的代码,改为通过方法访问:

  1. @Component
  2. public class MailService {
  3. @Autowired
  4. UserService userService;
  5. public String sendMail() {
  6. // 不要直接访问UserService的字段:
  7. ZoneId zoneId = userService.getZoneId();
  8. ...
  9. }
  10. }

无论注入的UserService是原始实例还是代理实例,getZoneId()都能正常工作,因为代理类会覆写getZoneId()方法,并将其委托给原始实例:

  1. public UserService$$EnhancerBySpringCGLIB extends UserService {
  2. UserService target = ...
  3. ...
  4. public ZoneId getZoneId() {
  5. return target.getZoneId();
  6. }
  7. }

注意到我们还给UserService添加了一个public+final的方法:

  1. @Component
  2. public class UserService {
  3. ...
  4. public final ZoneId getFinalZoneId() {
  5. return zoneId;
  6. }
  7. }

如果在MailService中,调用的不是getZoneId(),而是getFinalZoneId(),又会出现NullPointerException,这是因为,代理类无法覆写final方法(这一点绕不过JVM的ClassLoader检查),该方法返回的是代理类的zoneId字段,即null。
实际上,如果我们加上日志,Spring在启动时会打印一个警告:

  1. 10:43:09.929 [main] DEBUG org.springframework.aop.framework.CglibAopProxy - Final method [public final java.time.ZoneId xxx.UserService.getFinalZoneId()] cannot get proxied via CGLIB: Calls to this method will NOT be routed to the target instance and might lead to NPEs against uninitialized fields in the proxy instance.

上面的日志大意就是,因为被代理的UserService有一个final方法getFinalZoneId(),这会导致其他Bean如果调用此方法,无法将其代理到真正的原始实例,从而可能发生NPE异常。
因此,正确使用AOP,我们需要一个避坑指南:

  1. 访问被注入的Bean时,总是调用方法而非直接访问字段;
  2. 编写Bean时,如果可能会被代理,就不要编写public final方法。

这样才能保证有没有AOP,代码都能正常工作。
由于Spring通过CGLIB实现代理类,我们要避免直接访问Bean的字段,以及由final方法带来的“未代理”问题。
遇到CglibAopProxy的相关日志,务必要仔细检查,防止因为AOP出现NPE异常。