1.什么是spring?
Spring是一个支持快速开发Java EE应用程序的框架。它提供了一系列底层容器和基础设施,并可以和大量常用的开源框架无缝集成,可以说是开发Java EE应用程序的必备。
随着Spring越来越受欢迎,在Spring Framework基础上,又诞生了Spring Boot、Spring Cloud、Spring Data、Spring Security等一系列基于Spring Framework的项目。本章我们只介绍Spring Framework,即最核心的Spring框架。
Spring Framework
Spring Framework主要包括几个模块:
- 支持IoC和AOP的容器;
- 支持JDBC和ORM的数据访问模块;
- 支持声明式事务的模块;
- 支持基于Servlet的MVC开发;
- 支持基于Reactive的Web开发;
- 以及集成JMS、JavaMail、JMX、缓存等其他模块。
Spring官网是spring.io,要注意官网有许多项目,我们这里说的Spring是指Spring Framework,可以直接从这里访问最新版以及文档,建议添加到浏览器收藏夹。
2.IoC容器
在学习Spring框架时,我们遇到的第一个也是最核心的概念就是容器。
什么是容器?容器是一种为某种特定组件的运行提供必要支持的一个软件环境。例如,Tomcat就是一个Servlet容器,它可以为Servlet的运行提供运行环境。类似Docker这样的软件也是一个容器,它提供了必要的Linux环境以便运行一个特定的Linux进程。
通常来说,使用容器运行组件,除了提供一个组件运行环境之外,容器还提供了许多底层服务。
例如,Servlet容器底层实现了TCP连接,解析HTTP协议等非常复杂的服务,如果没有容器来提供这些服务,我们就无法编写像Servlet这样代码简单,功能强大的组件。
早期的JavaEE服务器提供的EJB容器最重要的功能就是通过声明式事务服务,使得EJB组件的开发人员不必自己编写冗长的事务处理代码,所以极大地简化了事务处理。
Spring的核心就是提供了一个IoC容器,它可以管理所有轻量级的JavaBean组件,提供的底层服务包括组件的生命周期管理、配置和组装服务、AOP支持,以及建立在AOP基础上的声明式事务服务等。
本章我们讨论的IoC容器,主要介绍Spring容器如何对组件进行生命周期管理和配置组装服务。
2.1IoC原理
Spring提供的容器又称为IoC容器,什么是IoC?
IoC全称Inversion of Control,直译为控制反转。那么何谓IoC?在理解IoC之前,我们先看看通常的Java组件是如何协作的。
我们假定一个在线书店,通过BookService
获取书籍:
public class BookService {
private HikariConfig config = new HikariConfig();
private DataSource dataSource = new HikariDataSource(config);
public Book getBook(long bookId) {
try (Connection conn = dataSource.getConnection()) {
...
return book;
}
}
}
为了从数据库查询书籍,BookService
持有一个DataSource
。为了实例化一个HikariDataSource
,又不得不实例化一个HikariConfig
。
现在,我们继续编写UserService
获取用户:
public class UserService {
private HikariConfig config = new HikariConfig();
private DataSource dataSource = new HikariDataSource(config);
public User getUser(long userId) {
try (Connection conn = dataSource.getConnection()) {
...
return user;
}
}
}
因为UserService
也需要访问数据库,因此,我们不得不也实例化一个HikariDataSource
。
在处理用户购买的CartServlet
中,我们需要实例化UserService
和BookService
:
public class CartServlet extends HttpServlet {
private BookService bookService = new BookService();
private UserService userService = new UserService();
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
long currentUserId = getFromCookie(req);
User currentUser = userService.getUser(currentUserId);
Book book = bookService.getBook(req.getParameter("bookId"));
cartService.addToCart(currentUser, book);
...
}
}
类似的,在购买历史HistoryServlet
中,也需要实例化UserService
和BookService
:
public class HistoryServlet extends HttpServlet {
private BookService bookService = new BookService();
private UserService userService = new UserService();
}
上述每个组件都采用了一种简单的通过new
创建实例并持有的方式。仔细观察,会发现以下缺点:
-
实例化一个组件其实很难,例如,
BookService
和UserService
要创建HikariDataSource
,实际上需要读取配置,才能先实例化HikariConfig
,再实例化HikariDataSource
。 -
没有必要让
BookService
和UserService
分别创建DataSource
实例,完全可以共享同一个DataSource
,但谁负责创建DataSource
,谁负责获取其他组件已经创建的DataSource
,不好处理。类似的,CartServlet
和HistoryServlet
也应当共享BookService
实例和UserService
实例,但也不好处理。 -
很多组件需要销毁以便释放资源,例如
DataSource
,但如果该组件被多个组件共享,如何确保它的使用方都已经全部被销毁? -
随着更多的组件被引入,例如,书籍评论,需要共享的组件写起来会更困难,这些组件的依赖关系会越来越复杂。
-
测试某个组件,例如
BookService
,是复杂的,因为必须要在真实的数据库环境下执行。
从上面的例子可以看出,如果一个系统有大量的组件,其生命周期和相互之间的依赖关系如果由组件自身来维护,不但大大增加了系统的复杂度,而且会导致组件之间极为紧密的耦合,继而给测试和维护带来了极大的困难。
因此,核心问题是:
- 谁负责创建组件?================把new对象的权力交出去
- 谁负责根据依赖关系组装组件?======把对象之间的管理权力交出去
- 销毁时,如何按依赖顺序正确销毁?
解决这一问题的核心方案就是IoC。
传统的应用程序中,控制权在程序本身,程序的控制流程完全由开发者控制,例如:
CartServlet
创建了BookService
,在创建BookService
的过程中,又创建了DataSource
组件。这种模式的缺点是,一个组件如果要使用另一个组件,必须先知道如何正确地创建它。
在IoC模式下,控制权发生了反转,即从应用程序转移到了IoC容器,所有组件不再由应用程序自己创建和配置,而是由IoC容器负责,这样,应用程序只需要直接使用已经创建好并且配置好的组件。为了能让组件在IoC容器中被“装配”出来,需要某种“注入”机制,例如,BookService
自己并不会创建DataSource
,而是等待外部通过setDataSource()
方法来注入一个DataSource
:
public class BookService {
private DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
}
不直接new
一个DataSource
,而是注入一个DataSource
,这个小小的改动虽然简单,却带来了一系列好处:
BookService
不再关心如何创建DataSource
,因此,不必编写读取数据库配置之类的代码;DataSource
实例被注入到BookService
,同样也可以注入到UserService
,因此,共享一个组件非常简单;- 测试
BookService
更容易,因为注入的是DataSource
,可以使用内存数据库,而不是真实的MySQL配置。
因此,IoC又称为依赖注入(DI:Dependency Injection),它解决了一个最主要的问题:将组件的创建+配置 与 组件的使用 相分离,并且,由IoC容器负责管理组件的生命周期。
因为IoC容器要负责实例化所有的组件,因此,有必要告诉容器如何创建组件,以及各组件的依赖关系。一种最简单的配置是通过XML文件来实现,例如:
<beans>
<bean id="dataSource" class="HikariDataSource" />
<bean id="bookService" class="BookService">
<property name="dataSource" ref="dataSource" />
</bean>
<bean id="userService" class="UserService">
<property name="dataSource" ref="dataSource" />
</bean>
</beans>
如何new对象和管理对象之间的关系以及对象的属性??====可通过设置xml配置文件,当加载配置文件时,就new出对象了。
上述XML配置文件指示IoC容器创建3个JavaBean组件,并把id为dataSource
的组件通过属性dataSource
(即调用setDataSource()
方法)注入到另外两个组件中。
在Spring的IoC容器中,我们把所有组件统称为JavaBean,即配置一个组件就是配置一个Bean。
依赖注入方式
我们从上面的代码可以看到,依赖注入可以通过set()
方法实现。但依赖注入也可以通过构造方法实现。
很多Java类都具有带参数的构造方法,如果我们把BookService
改造为通过构造方法注入,那么实现代码如下:
public class BookService {
private DataSource dataSource;
public BookService(DataSource dataSource) {
this.dataSource = dataSource;
}
}
Spring的IoC容器同时支持属性注入和构造方法注入,并允许混合使用。
无侵入容器
在设计上,Spring的IoC容器是一个高度可扩展的无侵入容器。所谓无侵入,是指应用程序的组件无需实现Spring的特定接口,或者说,组件根本不知道自己在Spring的容器中运行。这种无侵入的设计有以下好处:
- 应用程序组件既可以在Spring的IoC容器中运行,也可以自己编写代码自行组装配置;
- 测试的时候并不依赖Spring容器,可单独进行测试,大大提高了开发效率。
2.2装配Bean
我们前面讨论了为什么要使用Spring的IoC容器,因为让容器来为我们创建并装配Bean能获得很大的好处,那么到底如何使用IoC容器?装配好的Bean又如何使用?
我们来看一个具体的用户注册登录的例子。整个工程的结构如下:
spring-ioc-appcontext
├── pom.xml
└── src
└── main
├── java
│ └── com
│ └── itranswarp
│ └── learnjava
│ ├── Main.java
│ └── service
│ ├── MailService.java
│ ├── User.java
│ └── UserService.java
└── resources
└── application.xml
首先,我们用Maven创建工程并引入spring-context
依赖:
- org.springframework:spring-context:6.0.0
我们先编写一个MailService
,用于在用户登录和注册成功后发送邮件通知:
public class MailService {
private ZoneId zoneId = ZoneId.systemDefault();
public void setZoneId(ZoneId zoneId) {
this.zoneId = zoneId;
}
public String getTime() {
return ZonedDateTime.now(this.zoneId).format(DateTimeFormatter.ISO_ZONED_DATE_TIME);
}
public void sendLoginMail(User user) {
System.err.println(String.format("Hi, %s! You are logged in at %s", user.getName(), getTime()));
}
public void sendRegistrationMail(User user) {
System.err.println(String.format("Welcome, %s!", user.getName()));
}
}
再编写一个UserService
,实现用户注册和登录:
public class UserService {
private MailService mailService;
public void setMailService(MailService mailService) {
this.mailService = mailService;
}
private List<User> users = new ArrayList<>(List.of( // users:
new User(1, "bob@example.com", "password", "Bob"), // bob
new User(2, "alice@example.com", "password", "Alice"), // alice
new User(3, "tom@example.com", "password", "Tom"))); // tom
public User login(String email, String password) {
for (User user : users) {
if (user.getEmail().equalsIgnoreCase(email) && user.getPassword().equals(password)) {
mailService.sendLoginMail(user);
return user;
}
}
throw new RuntimeException("login failed.");
}
public User getUser(long id) {
return this.users.stream().filter(user -> user.getId() == id).findFirst().orElseThrow();
}
public User register(String email, String password, String name) {
users.forEach((user) -> {
if (user.getEmail().equalsIgnoreCase(email)) {
throw new RuntimeException("email exist.");
}
});
User user = new User(users.stream().mapToLong(u -> u.getId()).max().getAsLong() + 1, email, password, name);
users.add(user);
mailService.sendRegistrationMail(user);
return user;
}
}
注意到UserService
通过setMailService()
注入了一个MailService
。
然后,我们需要编写一个特定的application.xml
配置文件,告诉Spring的IoC容器应该如何创建并组装Bean:
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
https://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="userService" class="com.itranswarp.learnjava.service.UserService">
<property name="mailService" ref="mailService" />
</bean>
<bean id="mailService" class="com.itranswarp.learnjava.service.MailService" />
</beans>
注意观察上述配置文件,其中与XML Schema相关的部分格式是固定的,我们只关注两个<bean ...>
的配置:
- 每个
<bean ...>
都有一个id
标识,相当于Bean的唯一ID; - 在
userService
Bean中,通过<property name="..." ref="..." />
注入了另一个Bean; - Bean的顺序不重要,Spring根据依赖关系会自动正确初始化。
把上述XML配置文件用Java代码写出来,就像这样:
UserService userService = new UserService();
MailService mailService = new MailService();
userService.setMailService(mailService);
只不过Spring容器是通过读取XML文件后使用反射完成的。
如果注入的不是Bean,而是boolean
、int
、String
这样的数据类型,则通过value
注入,例如,创建一个HikariDataSource
:
<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource">
<property name="jdbcUrl" value="jdbc:mysql://localhost:3306/test" />
<property name="username" value="root" />
<property name="password" value="password" />
<property name="maximumPoolSize" value="10" />
<property name="autoCommit" value="true" />
</bean>
最后一步,我们需要创建一个Spring的IoC容器实例,然后加载配置文件,让Spring容器为我们创建并装配好配置文件中指定的所有Bean,这只需要一行代码:
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
接下来,我们就可以从Spring容器中“取出”装配好的Bean然后使用它:
// 获取Bean:
UserService userService = context.getBean(UserService.class);
// 正常调用:
User user = userService.login("bob@example.com", "password");
完整的main()
方法如下:
public class Main {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
UserService userService = context.getBean(UserService.class);
User user = userService.login("bob@example.com", "password");
System.out.println(user.getName());
}
}
ApplicationContext
我们从创建Spring容器的代码:
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
可以看到,Spring容器就是ApplicationContext
,它是一个接口,有很多实现类,这里我们选择ClassPathXmlApplicationContext
,表示它会自动从classpath中查找指定的XML配置文件。
获得了ApplicationContext
的实例,就获得了IoC容器的引用。从ApplicationContext
中我们可以根据Bean的ID获取Bean,但更多的时候我们根据Bean的类型获取Bean的引用:
UserService userService = context.getBean(UserService.class);
Spring还提供另一种IoC容器叫BeanFactory
,使用方式和ApplicationContext
类似:
BeanFactory factory = new XmlBeanFactory(new ClassPathResource("application.xml"));
MailService mailService = factory.getBean(MailService.class);
BeanFactory
和ApplicationContext
的区别在于,BeanFactory
的实现是按需创建,即第一次获取Bean时才创建这个Bean,而ApplicationContext
会一次性创建所有的Bean。实际上,ApplicationContext
接口是从BeanFactory
接口继承而来的,并且,ApplicationContext
提供了一些额外的功能,包括国际化支持、事件和通知机制等。通常情况下,我们总是使用ApplicationContext
,很少会考虑使用BeanFactory
。
2.3使用Annotation配置
使用Spring的IoC容器,实际上就是通过类似XML这样的配置文件,把我们自己的Bean的依赖关系描述出来,然后让容器来创建并装配Bean。一旦容器初始化完毕,我们就直接从容器中获取Bean使用它们。
使用XML配置的优点是所有的Bean都能一目了然地列出来,并通过配置注入能直观地看到每个Bean的依赖。它的缺点是写起来非常繁琐,每增加一个组件,就必须把新的Bean配置到XML中。
有没有其他更简单的配置方式呢?
有!我们可以使用Annotation配置,可以完全不需要XML,让Spring自动扫描Bean并组装它们。
我们把上一节的示例改造一下,先删除XML配置文件,然后,给UserService
和MailService
添加几个注解。
首先,我们给MailService
添加一个@Component
注解:
@Component
public class MailService {
...
}
这个@Component
注解就相当于定义了一个Bean,它有一个可选的名称,默认是mailService
,即小写开头的类名。
然后,我们给UserService
添加一个@Component
注解和一个@Autowired
注解:
@Component
public class UserService {
@Autowired
MailService mailService;
...
}
使用@Autowired
就相当于把指定类型的Bean注入到指定的字段中。和XML配置相比,@Autowired
大幅简化了注入,因为它不但可以写在set()
方法上,还可以直接写在字段上,甚至可以写在构造方法中:
@Component
public class UserService {
MailService mailService;
public UserService(@Autowired MailService mailService) {
this.mailService = mailService;
}
...
}
我们一般把@Autowired
写在字段上,通常使用package权限的字段,便于测试。
最后,编写一个AppConfig
类启动容器:
@Configuration
@ComponentScan
public class AppConfig {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
UserService userService = context.getBean(UserService.class);
User user = userService.login("bob@example.com", "password");
System.out.println(user.getName());
}
}
除了main()
方法外,AppConfig
标注了@Configuration
,表示它是一个配置类,因为我们创建ApplicationContext
时:
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
使用的实现类是AnnotationConfigApplicationContext
,必须传入一个标注了@Configuration
的类名。
此外,AppConfig
还标注了@ComponentScan
,它告诉容器,自动搜索当前类所在的包以及子包,把所有标注为@Component
的Bean自动创建出来,并根据@Autowired
进行装配。
整个工程结构如下:
spring-ioc-annoconfig
├── pom.xml
└── src
└── main
└── java
└── com
└── itranswarp
└── learnjava
├── AppConfig.java
└── service
├── MailService.java
├── User.java
└── UserService.java
使用Annotation配合自动扫描能大幅简化Spring的配置,我们只需要保证:
- 每个Bean被标注为
@Component
并正确使用@Autowired
注入; - 配置类被标注为
@Configuration
和@ComponentScan
; - 所有Bean均在指定包以及子包内。
使用@ComponentScan
非常方便,但是,我们也要特别注意包的层次结构。通常来说,启动配置AppConfig
位于自定义的顶层包(例如com.itranswarp.learnjava
),其他Bean按类别放入子包。
思考
如果我们想给UserService
注入HikariDataSource
,但是这个类位于com.zaxxer.hikari
包中,并且HikariDataSource
也不可能有@Component
注解,如何告诉IoC容器创建并配置HikariDataSource
?或者换个说法,如何创建并配置一个第三方Bean?
2.3定制Bean
Scope
对于Spring容器来说,当我们把一个Bean标记为@Component
后,它就会自动为我们创建一个单例(Singleton),即容器初始化时创建Bean,容器关闭前销毁Bean。在容器运行期间,我们调用getBean(Class)
获取到的Bean总是同一个实例。
还有一种Bean,我们每次调用getBean(Class)
,容器都返回一个新的实例,这种Bean称为Prototype(原型),它的生命周期显然和Singleton不同。声明一个Prototype的Bean时,需要添加一个额外的@Scope
注解:
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // @Scope("prototype")
public class MailSession {
...
}
注入List
有些时候,我们会有一系列接口相同,不同实现类的Bean。例如,注册用户时,我们要对email、password和name这3个变量进行验证。为了便于扩展,我们先定义验证接口:
public interface Validator {
void validate(String email, String password, String name);
}
然后,分别使用3个Validator
对用户参数进行验证:
@Component
public class EmailValidator implements Validator {
public void validate(String email, String password, String name) {
if (!email.matches("^[a-z0-9]+\\@[a-z0-9]+\\.[a-z]{2,10}$")) {
throw new IllegalArgumentException("invalid email: " + email);
}
}
}
@Component
public class PasswordValidator implements Validator {
public void validate(String email, String password, String name) {
if (!password.matches("^.{6,20}$")) {
throw new IllegalArgumentException("invalid password");
}
}
}
@Component
public class NameValidator implements Validator {
public void validate(String email, String password, String name) {
if (name == null || name.isBlank() || name.length() > 20) {
throw new IllegalArgumentException("invalid name: " + name);
}
}
}
最后,我们通过一个Validators
作为入口进行验证:
@Component
public class Validators {
@Autowired
List<Validator> validators;
public void validate(String email, String password, String name) {
for (var validator : this.validators) {
validator.validate(email, password, name);
}
}
}
注意到Validators
被注入了一个List<Validator>
,Spring会自动把所有类型为Validator
的Bean装配为一个List
注入进来,这样一来,我们每新增一个Validator
类型,就自动被Spring装配到Validators
中了,非常方便。
因为Spring是通过扫描classpath获取到所有的Bean,而List
是有序的,要指定List
中Bean的顺序,可以加上@Order
注解:
@Component
@Order(1)
public class EmailValidator implements Validator {
...
}
@Component
@Order(2)
public class PasswordValidator implements Validator {
...
}
@Component
@Order(3)
public class NameValidator implements Validator {
...
}
可选注入
默认情况下,当我们标记了一个@Autowired
后,Spring如果没有找到对应类型的Bean,它会抛出NoSuchBeanDefinitionException
异常。
可以给@Autowired
增加一个required = false
的参数:
@Component
public class MailService {
@Autowired(required = false)
ZoneId zoneId = ZoneId.systemDefault();
...
}
这个参数告诉Spring容器,如果找到一个类型为ZoneId
的Bean,就注入,如果找不到,就忽略。
这种方式非常适合有定义就使用定义,没有就使用默认值的情况。
创建第三方Bean
如果一个Bean不在我们自己的package管理之内,例如ZoneId
,如何创建它?
答案是我们自己在@Configuration
类中编写一个Java方法创建并返回它,注意给方法标记一个@Bean
注解:
@Configuration
@ComponentScan
public class AppConfig {
// 创建一个Bean:
@Bean
ZoneId createZoneId() {
return ZoneId.of("Z");
}
}
Spring对标记为@Bean
的方法只调用一次,因此返回的Bean仍然是单例。
初始化和销毁
有些时候,一个Bean在注入必要的依赖后,需要进行初始化(监听消息等)。在容器关闭时,有时候还需要清理资源(关闭连接池等)。我们通常会定义一个init()
方法进行初始化,定义一个shutdown()
方法进行清理,然后,引入JSR-250定义的Annotation:
- jakarta.annotation:jakarta.annotation-api:2.1.1
在Bean的初始化和清理方法上标记@PostConstruct
和@PreDestroy
:
@Component
public class MailService {
@Autowired(required = false)
ZoneId zoneId = ZoneId.systemDefault();
@PostConstruct
public void init() {
System.out.println("Init mail service with zoneId = " + this.zoneId);
}
@PreDestroy
public void shutdown() {
System.out.println("Shutdown mail service");
}
}
Spring容器会对上述Bean做如下初始化流程:
- 调用构造方法创建
MailService
实例; - 根据
@Autowired
进行注入; - 调用标记有
@PostConstruct
的init()
方法进行初始化。
而销毁时,容器会首先调用标记有@PreDestroy
的shutdown()
方法。
Spring只根据Annotation查找无参数方法,对方法名不作要求。
使用别名
默认情况下,对一种类型的Bean,容器只创建一个实例。但有些时候,我们需要对一种类型的Bean创建多个实例。例如,同时连接多个数据库,就必须创建多个DataSource
实例。
如果我们在@Configuration
类中创建了多个同类型的Bean:
@Configuration
@ComponentScan
public class AppConfig {
@Bean
ZoneId createZoneOfZ() {
return ZoneId.of("Z");
}
@Bean
ZoneId createZoneOfUTC8() {
return ZoneId.of("UTC+08:00");
}
}
Spring会报NoUniqueBeanDefinitionException
异常,意思是出现了重复的Bean定义。
这个时候,需要给每个Bean添加不同的名字:
@Configuration
@ComponentScan
public class AppConfig {
@Bean("z")
ZoneId createZoneOfZ() {
return ZoneId.of("Z");
}
@Bean
@Qualifier("utc8")
ZoneId createZoneOfUTC8() {
return ZoneId.of("UTC+08:00");
}
}
可以用@Bean("name")
指定别名,也可以用@Bean
+@Qualifier("name")
指定别名。
存在多个同类型的Bean时,注入ZoneId
又会报错:
NoUniqueBeanDefinitionException: No qualifying bean of type 'java.time.ZoneId' available: expected single matching bean but found 2
意思是期待找到唯一的ZoneId
类型Bean,但是找到两。因此,注入时,要指定Bean的名称:
@Component
public class MailService {
@Autowired(required = false)
@Qualifier("z") // 指定注入名称为"z"的ZoneId
ZoneId zoneId = ZoneId.systemDefault();
...
}
还有一种方法是把其中某个Bean指定为@Primary
:
@Configuration
@ComponentScan
public class AppConfig {
@Bean
@Primary // 指定为主要Bean
@Qualifier("z")
ZoneId createZoneOfZ() {
return ZoneId.of("Z");
}
@Bean
@Qualifier("utc8")
ZoneId createZoneOfUTC8() {
return ZoneId.of("UTC+08:00");
}
}
这样,在注入时,如果没有指出Bean的名字,Spring会注入标记有@Primary
的Bean。这种方式也很常用。例如,对于主从两个数据源,通常将主数据源定义为@Primary
:
@Configuration
@ComponentScan
public class AppConfig {
@Bean
@Primary
DataSource createMasterDataSource() {
...
}
@Bean
@Qualifier("slave")
DataSource createSlaveDataSource() {
...
}
}
其他Bean默认注入的就是主数据源。如果要注入从数据源,那么只需要指定名称即可。
使用FactoryBean
我们在设计模式的工厂方法中讲到,很多时候,可以通过工厂模式创建对象。Spring也提供了工厂模式,允许定义一个工厂,然后由工厂创建真正的Bean。
用工厂模式创建Bean需要实现FactoryBean
接口。我们观察下面的代码:
@Component
public class ZoneIdFactoryBean implements FactoryBean<ZoneId> {
String zone = "Z";
@Override
public ZoneId getObject() throws Exception {
return ZoneId.of(zone);
}
@Override
public Class<?> getObjectType() {
return ZoneId.class;
}
}
当一个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。
3.4使用Resource
在Java程序中,我们经常会读取配置文件、资源文件等。使用Spring容器时,我们也可以把“文件”注入进来,方便程序读取。
例如,AppService需要读取logo.txt
这个文件,通常情况下,我们需要写很多繁琐的代码,主要是为了定位文件,打开InputStream。
Spring提供了一个org.springframework.core.io.Resource
(注意不是jarkata.annotation.Resource
或javax.annotation.Resource
),它可以像String
、int
一样使用@Value
注入:
@Component
public class AppService {
@Value("classpath:/logo.txt")
private Resource resource;
private String logo;
@PostConstruct
public void init() throws IOException {
try (var reader = new BufferedReader(
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
this.logo = reader.lines().collect(Collectors.joining("\n"));
}
}
}
注入Resource
最常用的方式是通过classpath,即类似classpath:/logo.txt
表示在classpath中搜索logo.txt
文件,然后,我们直接调用Resource.getInputStream()
就可以获取到输入流,避免了自己搜索文件的代码。
也可以直接指定文件的路径,例如:
@Value("file:/path/to/logo.txt")
private Resource resource;
但使用classpath是最简单的方式。上述工程结构如下:
spring-ioc-resource
├── pom.xml
└── src
└── main
├── java
│ └── com
│ └── itranswarp
│ └── learnjava
│ ├── AppConfig.java
│ └── AppService.java
└── resources
└── logo.txt
使用Maven的标准目录结构,所有资源文件放入src/main/resources
即可。
小结
Spring提供了Resource类便于注入资源文件。
最常用的注入是通过classpath以classpath:/path/to/file
的形式注入
3.5注入配置
在开发应用程序时,经常需要读取配置文件。最常用的配置方法是以key=value
的形式写在.properties
文件中。
例如,MailService
根据配置的app.zone=Asia/Shanghai
来决定使用哪个时区。要读取配置文件,我们可以使用上一节讲到的Resource
来读取位于classpath下的一个app.properties
文件。但是,这样仍然比较繁琐。
Spring容器还提供了一个更简单的@PropertySource
来自动读取配置文件。我们只需要在@Configuration
配置类上再添加一个注解:
@Configuration
@ComponentScan
@PropertySource("app.properties") // 表示读取classpath的app.properties
public class AppConfig {
@Value("${app.zone:Z}")
String zoneId;
@Bean
ZoneId createZoneId() {
return ZoneId.of(zoneId);
}
}
Spring容器看到@PropertySource("app.properties")
注解后,自动读取这个配置文件,然后,我们使用@Value
正常注入:
@Value("${app.zone:Z}")
String zoneId;
注意注入的字符串语法,它的格式如下:
"${app.zone}"
表示读取key为app.zone
的value,如果key不存在,启动将报错;"${app.zone:Z}"
表示读取key为app.zone
的value,但如果key不存在,就使用默认值Z
。
这样一来,我们就可以根据app.zone
的配置来创建ZoneId
。
还可以把注入的注解写到方法参数中:
@Bean
ZoneId createZoneId(@Value("${app.zone:Z}") String zoneId) {
return ZoneId.of(zoneId);
}
可见,先使用@PropertySource
读取配置文件,然后通过@Value
以${key:defaultValue}
的形式注入,可以极大地简化读取配置的麻烦。
另一种注入配置的方式是先通过一个简单的JavaBean持有所有的配置,例如,一个SmtpConfig
:
@Component
public class SmtpConfig {
@Value("${smtp.host}")
private String host;
@Value("${smtp.port:25}")
private int port;
public String getHost() {
return host;
}
public int getPort() {
return port;
}
}
然后,在需要读取的地方,使用#{smtpConfig.host}
注入:
@Component
public class MailService {
@Value("#{smtpConfig.host}")
private String smtpHost;
@Value("#{smtpConfig.port}")
private int smtpPort;
}
注意观察#{}
这种注入语法,它和${key}
不同的是,#{}
表示从JavaBean读取属性。"#{smtpConfig.host}"
的意思是,从名称为smtpConfig
的Bean读取host
属性,即调用getHost()
方法。一个Class名为SmtpConfig
的Bean,它在Spring容器中的默认名称就是smtpConfig
,除非用@Qualifier
指定了名称。
使用一个独立的JavaBean持有所有属性,然后在其他Bean中以#{bean.property}
注入的好处是,多个Bean都可以引用同一个Bean的某个属性。例如,如果SmtpConfig
决定从数据库中读取相关配置项,那么MailService
注入的@Value("#{smtpConfig.host}")
仍然可以不修改正常运行。
小结
Spring容器可以通过@PropertySource
自动读取配置,并以@Value("${key}")
的形式注入;
可以通过${key:defaultValue}
指定默认值;
以#{bean.property}
形式注入时,Spring容器自动把指定Bean的指定属性值注入。
3.使用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()
,除了业务逻辑,还需要安全检查、日志记录和事务处理,它的代码像这样:
public class BookService {
public void createBook(Book book) {
securityCheck();
Transaction tx = startTransaction();
try {
// 核心业务逻辑
tx.commit();
} catch (RuntimeException e) {
tx.rollback();
throw e;
}
log("created book: " + book);
}
}
继续编写updateBook()
,代码如下:
public class BookService {
public void updateBook(Book book) {
securityCheck();
Transaction tx = startTransaction();
try {
// 核心业务逻辑
tx.commit();
} catch (RuntimeException e) {
tx.rollback();
throw e;
}
log("updated book: " + book);
}
}
对于安全检查、日志、事务等代码,它们会重复出现在每个业务方法中。使用OOP,我们很难将这些四处分散的代码模块化。
考察业务模型可以发现,BookService
关心的是自身的核心逻辑,但整个系统还要求关注安全检查、日志、事务等功能,这些功能实际上“横跨”多个业务方法,为了实现这些功能,不得不在每个业务方法上重复编写代码。
一种可行的方式是使用Proxy模式,将某个功能,例如,权限检查,放入Proxy中:
public class SecurityCheckBookService implements BookService {
private final BookService target;
public SecurityCheckBookService(BookService target) {
this.target = target;
}
public void createBook(Book book) {
securityCheck();
target.createBook(book);
}
public void updateBook(Book book) {
securityCheck();
target.updateBook(book);
}
public void deleteBook(Book book) {
securityCheck();
target.deleteBook(book);
}
private void securityCheck() {
...
}
}
这种方式的缺点是比较麻烦,必须先抽取接口,然后,针对每个方法实现Proxy。
另一种方法是,既然SecurityCheckBookService
的代码都是标准的Proxy样板代码,不如把权限检查视作一种切面(Aspect),把日志、事务也视为切面,然后,以某种自动化的方式,把切面织入到核心逻辑中,实现Proxy模式。
如果我们以AOP的视角来编写上述业务,可以依次实现:
- 核心逻辑,即BookService;
- 切面逻辑,即:
- 权限检查的Aspect;
- 日志的Aspect;
- 事务的Aspect。
然后,以某种方式,让框架来把上述3个Aspect以Proxy的方式“织入”到BookService
中,这样一来,就不必编写复杂而冗长的Proxy模式。
AOP原理
如何把切面织入到核心逻辑中?这正是AOP需要解决的问题。换句话说,如果客户端获得了BookService
的引用,当调用bookService.createBook()
时,如何对调用方法进行拦截,并在拦截前后进行安全检查、日志、事务等处理,就相当于完成了所有业务功能。
在Java平台上,对于AOP的织入,有3种方式:
- 编译期:在编译时,由编译器把切面调用编译进字节码,这种方式需要定义新的关键字并扩展编译器,AspectJ就扩展了Java编译器,使用关键字aspect来实现织入;
- 类加载器:在目标类被装载到JVM时,通过一个特殊的类加载器,对目标类的字节码重新“增强”;
- 运行期:目标对象和切面都是普通Java类,通过JVM的动态代理功能或者第三方库实现运行期动态织入。
最简单的方式是第三种,Spring的AOP实现就是基于JVM的动态代理。由于JVM的动态代理要求必须实现接口,如果一个普通类没有业务接口,就需要通过CGLIB或者Javassist这些第三方库实现。
AOP技术看上去比较神秘,但实际上,它本质就是一个动态代理,让我们把一些常用功能如权限检查、日志、事务等,从每个业务方法中剥离出来。
需要特别指出的是,AOP对于解决特定问题,例如事务管理非常有用,这是因为分散在各处的事务代码几乎是完全相同的,并且它们需要的参数(JDBC的Connection)也是固定的。另一些特定问题,如日志,就不那么容易实现,因为日志虽然简单,但打印日志的时候,经常需要捕获局部变量,如果使用AOP实现日志,我们只能输出固定格式的日志,因此,使用AOP时,必须适合特定的场景。
3.1装配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的支持:
- org.springframework:spring-aspects:6.0.0
上述依赖会自动引入AspectJ,使用AspectJ实现AOP比较方便,因为它的定义比较简单。
然后,我们定义一个LoggingAspect
:
@Aspect
@Component
public class LoggingAspect {
// 在执行UserService的每个方法前执行:
@Before("execution(public * com.itranswarp.learnjava.service.UserService.*(..))")
public void doAccessCheck() {
System.err.println("[Before] do access check...");
}
// 在执行MailService的每个方法前后执行:
@Around("execution(public * com.itranswarp.learnjava.service.MailService.*(..))")
public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
System.err.println("[Around] start " + pjp.getSignature());
Object retVal = pjp.proceed();
System.err.println("[Around] done " + pjp.getSignature());
return retVal;
}
}
观察doAccessCheck()
方法,我们定义了一个@Before
注解,后面的字符串是告诉AspectJ应该在何处执行该方法,这里写的意思是:执行UserService
的每个public
方法前执行doAccessCheck()
代码。
再观察doLogging()
方法,我们定义了一个@Around
注解,它和@Before
不同,@Around
可以决定是否执行目标方法,因此,我们在doLogging()
内部先打印日志,再调用方法,最后打印日志后返回结果。
在LoggingAspect
类的声明处,除了用@Component
表示它本身也是一个Bean外,我们再加上@Aspect
注解,表示它的@Before
标注的方法需要注入到UserService
的每个public
方法执行前,@Around
标注的方法需要注入到MailService
的每个public
方法执行前后。
紧接着,我们需要给@Configuration
类加上一个@EnableAspectJAutoProxy
注解:
@Configuration
@ComponentScan
@EnableAspectJAutoProxy
public class AppConfig {
...
}
Spring的IoC容器看到这个注解,就会自动查找带有@Aspect
的Bean,然后根据每个方法的@Before
、@Around
等注解把AOP注入到特定的Bean中。执行代码,我们可以看到以下输出:
[Before] do access check...
[Around] start void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
Welcome, test!
[Around] done void com.itranswarp.learnjava.service.MailService.sendRegistrationMail(User)
[Before] do access check...
[Around] start void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)
Hi, Bob! You are logged in at 2020-02-14T23:13:52.167996+08:00[Asia/Shanghai]
[Around] done void com.itranswarp.learnjava.service.MailService.sendLoginMail(User)
这说明执行业务逻辑前后,确实执行了我们定义的Aspect(即LoggingAspect
的方法)。
有些童鞋会问,LoggingAspect
定义的方法,是如何注入到其他Bean的呢?
其实AOP的原理非常简单。我们以LoggingAspect.doAccessCheck()
为例,要把它注入到UserService
的每个public
方法中,最简单的方法是编写一个子类,并持有原始实例的引用:
public UserServiceAopProxy extends UserService {
private UserService target;
private LoggingAspect aspect;
public UserServiceAopProxy(UserService target, LoggingAspect aspect) {
this.target = target;
this.aspect = aspect;
}
public User login(String email, String password) {
// 先执行Aspect的代码:
aspect.doAccessCheck();
// 再执行UserService的逻辑:
return target.login(email, password);
}
public User register(String email, String password, String name) {
aspect.doAccessCheck();
return target.register(email, password, name);
}
...
}
这些都是Spring容器启动时为我们自动创建的注入了Aspect的子类,它取代了原始的UserService
(原始的UserService
实例作为内部变量隐藏在UserServiceAopProxy
中)。如果我们打印从Spring容器获取的UserService
实例类型,它类似UserService$$EnhancerBySpringCGLIB$$1f44e01c
,实际上是Spring使用CGLIB动态创建的子类,但对于调用方来说,感觉不到任何区别。
Spring对接口类型使用JDK动态代理,对普通类使用CGLIB创建子类。如果一个Bean的class是final,Spring将无法为其创建子类。
可见,虽然Spring容器内部实现AOP的逻辑比较复杂(需要使用AspectJ解析注解,并通过CGLIB实现代理类),但我们使用AOP非常简单,一共需要三步:
- 定义执行方法,并在方法上通过AspectJ的注解告诉Spring应该在何处调用此方法;
- 标记
@Component
和@Aspect
; - 在
@Configuration
类上标注@EnableAspectJAutoProxy
。
至于AspectJ的注入语法则比较复杂,请参考Spring文档。
Spring也提供其他方法来装配AOP,但都没有使用AspectJ注解的方式来得简洁明了,所以我们不再作介绍。
拦截器类型
顾名思义,拦截器有以下类型:
-
@Before:这种拦截器先执行拦截代码,再执行目标代码。如果拦截器抛异常,那么目标代码就不执行了;
-
@After:这种拦截器先执行目标代码,再执行拦截器代码。无论目标代码是否抛异常,拦截器代码都会执行;
-
@AfterReturning:和@After不同的是,只有当目标代码正常返回时,才执行拦截器代码;
-
@AfterThrowing:和@After不同的是,只有当目标代码抛出了异常时,才执行拦截器代码;
-
@Around:能完全控制目标代码是否执行,并可以在执行前后、抛异常后执行任意拦截代码,可以说是包含了上面所有功能。
小结
在Spring容器中使用AOP非常简单,只需要定义执行方法,并用AspectJ的注解标注应该在何处触发并执行。
Spring通过CGLIB动态创建子类等方式来实现AOP代理模式,大大简化了代码。
3.2使用注解装配AOP
上一节我们讲解了使用AspectJ的注解,并配合一个复杂的execution(* xxx.Xyz.*(..))
语法来定义应该如何装配AOP。
在实际项目中,这种写法其实很少使用。假设你写了一个SecurityAspect
:
@Aspect
@Component
public class SecurityAspect {
@Before("execution(public * com.itranswarp.learnjava.service.*.*(..))")
public void check() {
if (SecurityContext.getCurrentUser() == null) {
throw new RuntimeException("check failed");
}
}
}
基本能实现无差别全覆盖,即某个包下面的所有Bean的所有方法都会被这个check()
方法拦截。
还有的童鞋喜欢用方法名前缀进行拦截:
@Around("execution(public * update*(..))")
public Object doLogging(ProceedingJoinPoint pjp) throws Throwable {
// 对update开头的方法切换数据源:
String old = setCurrentDataSource("master");
Object retVal = pjp.proceed();
restoreCurrentDataSource(old);
return retVal;
}
这种非精准打击误伤面更大,因为从方法前缀区分是否是数据库操作是非常不可取的。
我们在使用AOP时,要注意到虽然Spring容器可以把指定的方法通过AOP规则装配到指定的Bean的指定方法前后,但是,如果自动装配时,因为不恰当的范围,容易导致意想不到的结果,即很多不需要AOP代理的Bean也被自动代理了,并且,后续新增的Bean,如果不清楚现有的AOP装配规则,容易被强迫装配。
使用AOP时,被装配的Bean最好自己能清清楚楚地知道自己被安排了。例如,Spring提供的@Transactional
就是一个非常好的例子。如果我们自己写的Bean希望在一个数据库事务中被调用,就标注上@Transactional
:
@Component
public class UserService {
// 有事务:
@Transactional
public User createUser(String name) {
...
}
// 无事务:
public boolean isValidName(String name) {
...
}
// 有事务:
@Transactional
public void updateUser(User user) {
...
}
}
或者直接在class级别注解,表示“所有public方法都被安排了”:
@Component
@Transactional
public class UserService {
...
}
通过@Transactional
,某个方法是否启用了事务就一清二楚了。因此,装配AOP的时候,使用注解是最好的方式。
我们以一个实际例子演示如何使用注解实现AOP装配。为了监控应用程序的性能,我们定义一个性能监控的注解:
@Target(METHOD)
@Retention(RUNTIME)
public @interface MetricTime {
String value();
}
在需要被监控的关键方法上标注该注解:
@Component
public class UserService {
// 监控register()方法性能:
@MetricTime("register")
public User register(String email, String password, String name) {
...
}
...
}
然后,我们定义MetricAspect
:
@Aspect
@Component
public class MetricAspect {
@Around("@annotation(metricTime)")
public Object metric(ProceedingJoinPoint joinPoint, MetricTime metricTime) throws Throwable {
String name = metricTime.value();
long start = System.currentTimeMillis();
try {
return joinPoint.proceed();
} finally {
long t = System.currentTimeMillis() - start;
// 写入日志或发送至JMX:
System.err.println("[Metrics] " + name + ": " + t + "ms");
}
}
}
注意metric()
方法标注了@Around("@annotation(metricTime)")
,它的意思是,符合条件的目标方法是带有@MetricTime
注解的方法,因为metric()
方法参数类型是MetricTime
(注意参数名是metricTime
不是MetricTime
),我们通过它获取性能监控的名称。
有了@MetricTime
注解,再配合MetricAspect
,任何Bean,只要方法标注了@MetricTime
注解,就可以自动实现性能监控。运行代码,输出结果如下:
Welcome, Bob!
[Metrics] register: 16ms
3.3AOP避坑指南
无论是使用AspectJ语法,还是配合Annotation,使用AOP,实际上就是让Spring自动为我们创建一个Proxy,使得调用方能无感知地调用指定方法,但运行期却动态“织入”了其他逻辑,因此,AOP本质上就是一个代理模式。
因为Spring使用了CGLIB来实现运行期动态创建Proxy,如果我们没能深入理解其运行原理和实现机制,就极有可能遇到各种诡异的问题。
我们来看一个实际的例子。
假设我们定义了一个UserService
的Bean:
@Component
public class UserService {
// 成员变量:
public final ZoneId zoneId = ZoneId.systemDefault();
// 构造方法:
public UserService() {
System.out.println("UserService(): init...");
System.out.println("UserService(): zoneId = " + this.zoneId);
}
// public方法:
public ZoneId getZoneId() {
return zoneId;
}
// public final方法:
public final ZoneId getFinalZoneId() {
return zoneId;
}
}
再写个MailService
,并注入UserService
:
@Component
public class MailService {
@Autowired
UserService userService;
public String sendMail() {
ZoneId zoneId = userService.zoneId;
String dt = ZonedDateTime.now(zoneId).toString();
return "Hello, it is " + dt;
}
}
最后用main()
方法测试一下:
@Configuration
@ComponentScan
public class AppConfig {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
MailService mailService = context.getBean(MailService.class);
System.out.println(mailService.sendMail());
}
}
查看输出,一切正常:
UserService(): init...
UserService(): zoneId = Asia/Shanghai
Hello, it is 2020-04-12T10:23:22.917721+08:00[Asia/Shanghai]
下一步,我们给UserService
加上AOP支持,就添加一个最简单的LoggingAspect
:
@Aspect
@Component
public class LoggingAspect {
@Before("execution(public * com..*.UserService.*(..))")
public void doAccessCheck() {
System.err.println("[Before] do access check...");
}
}
别忘了在AppConfig
上加上@EnableAspectJAutoProxy
。再次运行,不出意外的话,会得到一个NullPointerException
:
Exception in thread "main" java.lang.NullPointerException: zone
at java.base/java.util.Objects.requireNonNull(Objects.java:246)
at java.base/java.time.Clock.system(Clock.java:203)
at java.base/java.time.ZonedDateTime.now(ZonedDateTime.java:216)
at com.itranswarp.learnjava.service.MailService.sendMail(MailService.java:19)
at com.itranswarp.learnjava.AppConfig.main(AppConfig.java:21)
仔细跟踪代码,会发现null
值出现在MailService.sendMail()
内部的这一行代码:
@Component
public class MailService {
@Autowired
UserService userService;
public String sendMail() {
ZoneId zoneId = userService.zoneId;
System.out.println(zoneId); // null
...
}
}
我们还故意在UserService
中特意用final
修饰了一下成员变量:
@Component
public class UserService {
public final ZoneId zoneId = ZoneId.systemDefault();
...
}
用final
标注的成员变量为null
?逗我呢?
怎么肥四?
为什么加了AOP就报NPE,去了AOP就一切正常?final
字段不执行,难道JVM有问题?为了解答这个诡异的问题,我们需要深入理解Spring使用CGLIB生成Proxy的原理:
第一步,正常创建一个UserService
的原始实例,这是通过反射调用构造方法实现的,它的行为和我们预期的完全一致;
第二步,通过CGLIB创建一个UserService
的子类,并引用了原始实例和LoggingAspect
:
public UserService$$EnhancerBySpringCGLIB extends UserService {
UserService target;
LoggingAspect aspect;
public UserService$$EnhancerBySpringCGLIB() {
}
public ZoneId getZoneId() {
aspect.doAccessCheck();
return target.getZoneId();
}
}
如果我们观察Spring创建的AOP代理,它的类名总是类似UserService$$EnhancerBySpringCGLIB$$1c76af9d
(你没看错,Java的类名实际上允许$
字符)。为了让调用方获得UserService
的引用,它必须继承自UserService
。然后,该代理类会覆写所有public
和protected
方法,并在内部将调用委托给原始的UserService
实例。
这里出现了两个UserService
实例:
一个是我们代码中定义的原始实例,它的成员变量已经按照我们预期的方式被初始化完成:
UserService original = new UserService();
第二个UserService
实例实际上类型是UserService$$EnhancerBySpringCGLIB
,它引用了原始的UserService
实例:
UserService$$EnhancerBySpringCGLIB proxy = new UserService$$EnhancerBySpringCGLIB();
proxy.target = original;
proxy.aspect = ...
注意到这种情况仅出现在启用了AOP的情况,此刻,从ApplicationContext
中获取的UserService
实例是proxy,注入到MailService
中的UserService
实例也是proxy。
那么最终的问题来了:proxy实例的成员变量,也就是从UserService
继承的zoneId
,它的值是null
。
原因在于,UserService
成员变量的初始化:
public class UserService {
public final ZoneId zoneId = ZoneId.systemDefault();
...
}
在UserService$$EnhancerBySpringCGLIB
中,并未执行。原因是,没必要初始化proxy的成员变量,因为proxy的目的是代理方法。
实际上,成员变量的初始化是在构造方法中完成的。这是我们看到的代码:
public class UserService {
public final ZoneId zoneId = ZoneId.systemDefault();
public UserService() {
}
}
这是编译器实际编译的代码:
public class UserService {
public final ZoneId zoneId;
public UserService() {
super(); // 构造方法的第一行代码总是调用super()
zoneId = ZoneId.systemDefault(); // 继续初始化成员变量
}
}
然而,对于Spring通过CGLIB动态创建的UserService$$EnhancerBySpringCGLIB
代理类,它的构造方法中,并未调用super()
,因此,从父类继承的成员变量,包括final
类型的成员变量,统统都没有初始化。
有的童鞋会问:Java语言规定,任何类的构造方法,第一行必须调用super()
,如果没有,编译器会自动加上,怎么Spring的CGLIB就可以搞特殊?
这是因为自动加super()
的功能是Java编译器实现的,它发现你没加,就自动给加上,发现你加错了,就报编译错误。但实际上,如果直接构造字节码,一个类的构造方法中,不一定非要调用super()
。Spring使用CGLIB构造的Proxy类,是直接生成字节码,并没有源码-编译-字节码这个步骤,因此:
Spring通过CGLIB创建的代理类,不会初始化代理类自身继承的任何成员变量,包括final类型的成员变量!
再考察MailService
的代码:
@Component
public class MailService {
@Autowired
UserService userService;
public String sendMail() {
ZoneId zoneId = userService.zoneId;
System.out.println(zoneId); // null
...
}
}
如果没有启用AOP,注入的是原始的UserService
实例,那么一切正常,因为UserService
实例的zoneId
字段已经被正确初始化了。
如果启动了AOP,注入的是代理后的UserService$$EnhancerBySpringCGLIB
实例,那么问题大了:获取的UserService$$EnhancerBySpringCGLIB
实例的zoneId
字段,永远为null
。
那么问题来了:启用了AOP,如何修复?
修复很简单,只需要把直接访问字段的代码,改为通过方法访问:
@Component
public class MailService {
@Autowired
UserService userService;
public String sendMail() {
// 不要直接访问UserService的字段:
ZoneId zoneId = userService.getZoneId();
...
}
}
无论注入的UserService
是原始实例还是代理实例,getZoneId()
都能正常工作,因为代理类会覆写getZoneId()
方法,并将其委托给原始实例:
public UserService$$EnhancerBySpringCGLIB extends UserService {
UserService target = ...
...
public ZoneId getZoneId() {
return target.getZoneId();
}
}
注意到我们还给UserService
添加了一个public
+final
的方法:
@Component
public class UserService {
...
public final ZoneId getFinalZoneId() {
return zoneId;
}
}
如果在MailService
中,调用的不是getZoneId()
,而是getFinalZoneId()
,又会出现NullPointerException
,这是因为,代理类无法覆写final
方法(这一点绕不过JVM的ClassLoader检查),该方法返回的是代理类的zoneId
字段,即null
。
实际上,如果我们加上日志,Spring在启动时会打印一个警告:
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,我们需要一个避坑指南:
- 访问被注入的Bean时,总是调用方法而非直接访问字段;
- 编写Bean时,如果可能会被代理,就不要编写
public final
方法。
这样才能保证有没有AOP,代码都能正常工作。
思考
为什么Spring刻意不初始化Proxy继承的字段?
如果一个Bean不允许任何AOP代理,应该怎么做来“保护”自己在运行期不会被代理?