lxf-spring开发
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,是復雜的,因為必須要在真實的數據庫環境下執行。
從上面的例子可以看出,如果一個系統有大量的組件,其生命周期和相互之間的依賴關系如果由組件自身來維護,不但大大增加了系統的復雜度,而且會導致組件之間極為緊密的耦合,繼而給測試和維護帶來了極大的困難。
因此,核心問題是:
解決這一問題的核心方案就是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,這個小小的改動雖然簡單,卻帶來了一系列好處:
因此,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的容器中運行。這種無侵入的設計有以下好處:
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"), // bobnew User(2, "alice@example.com", "password", "Alice"), // alicenew User(3, "tom@example.com", "password", "Tom"))); // tompublic 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/beanshttps://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;
- 在userServiceBean中,通過<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 {@AutowiredMailService 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 {@AutowiredList<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:@BeanZoneId 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();@PostConstructpublic void init() {System.out.println("Init mail service with zoneId = " + this.zoneId);}@PreDestroypublic 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 {@BeanZoneId createZoneOfZ() {return ZoneId.of("Z");}@BeanZoneId 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"的ZoneIdZoneId 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@PrimaryDataSource createMasterDataSource() {...}@Bean@Qualifier("slave")DataSource createSlaveDataSource() {...} }其他Bean默認注入的就是主數據源。如果要注入從數據源,那么只需要指定名稱即可。
使用FactoryBean
我們在設計模式的工廠方法中講到,很多時候,可以通過工廠模式創建對象。Spring也提供了工廠模式,允許定義一個工廠,然后由工廠創建真正的Bean。
用工廠模式創建Bean需要實現FactoryBean接口。我們觀察下面的代碼:
@Component public class ZoneIdFactoryBean implements FactoryBean<ZoneId> {String zone = "Z";@Overridepublic ZoneId getObject() throws Exception {return ZoneId.of(zone);}@Overridepublic 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;@PostConstructpublic 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;@BeanZoneId 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的視角來編寫上述業務,可以依次實現:
然后,以某種方式,讓框架來把上述3個Aspect以Proxy的方式“織入”到BookService中,這樣一來,就不必編寫復雜而冗長的Proxy模式。
AOP原理
如何把切面織入到核心邏輯中?這正是AOP需要解決的問題。換句話說,如果客戶端獲得了BookService的引用,當調用bookService.createBook()時,如何對調用方法進行攔截,并在攔截前后進行安全檢查、日志、事務等處理,就相當于完成了所有業務功能。
在Java平臺上,對于AOP的織入,有3種方式:
最簡單的方式是第三種,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文檔。
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 {// 有事務:@Transactionalpublic User createUser(String name) {...}// 無事務:public boolean isValidName(String name) {...}// 有事務:@Transactionalpublic 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: 16ms3.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 {@AutowiredUserService 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: zoneat 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 {@AutowiredUserService 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 {@AutowiredUserService 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 {@AutowiredUserService 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,我們需要一個避坑指南:
這樣才能保證有沒有AOP,代碼都能正常工作。
思考
為什么Spring刻意不初始化Proxy繼承的字段?
如果一個Bean不允許任何AOP代理,應該怎么做來“保護”自己在運行期不會被代理?
總結
以上是生活随笔為你收集整理的lxf-spring开发的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 企业网络中广域网出口介绍及业务办理
- 下一篇: 人工智障对话日记1:为狗写诗