在EJB年代,开发一个EJB需要大量接口和配置文件,其造成的结果就是往往配置的工作量比开发的工作量还要大,还有就是EJB是在EJB容器中运行,而JSP和Servlet却运行在Web容器中,而使用Web容器去调用EJB容器的服弊端包括:
在大家诟病EJB时,2002年澳大利亚工程师Rod Johnson提出了Spring的概念,并在2004年Rod Johnson主导推出了Spring项目的1.0版本。
Spring以强大的控制反转(IoC)来管理各类Java资源,从而降低了各种资源的耦合;使用Spring框架开发的编码,脱离Spring API也可以继续使用;Spring的面向切面编程(AOP)通过动态代理技术,循序我们按照约定配置编程,增强了Bean的功能,消除了大量重复代码,如数据库编程所需大量的try…catch…finally…语句以及数据库事务控制代码逻辑,让开发人员更专注于业务开发而非资源功能性开发。
随着JDK的更新,注解被广泛的使用起来,于是Spring内部也分为了两派,一派是使用xml的赞同派,一派是使用注解的赞同派,为了简化开发Spring 2.X以后也引入了注解,但是功能不够强大,此时绝大部分情况下还是以xml为主。
到了Spring 3.0以后引入了更多的注解,通过两派人的争执大家形成一个不成文的共识:对于业务类使用注解,例如,对于MVC开发,控制器使用@Controller
,业务层使用@Service
,持久层使用@Repository
;而对于一些公用的Bean,例如对数据库(如Redis)、第三方资源等则使用xml进行配置。
随着注解功能的增强,尤其是Servlet 3.0规范的提出,Web容器可以脱离web.xml的部署,使得Web容器完全可以基于注解开发,随着Spring3.X和Spring4.X的版本注解功能越来越强大,对于xml的依赖越来越少。与此同此,Pivotal团队在原有的Spring的基础上主要通过注解的方式继续简化了Spring框架的开发,他们基于Spring框架开发了Spring Boot,所以Spring Boot并非代替Spring框架,而是让Spring框架更加容易得到快速的使用。
Spring Boot除了以注解为主的开发,还有其他的绑定,例如对服务器进行了绑定和默认对Spring的最大化配置,这些符合了现如今微服务快速开发、测试和部署的需要,使得Spring Boot越发兴旺起来。
依据官方的文档,Spring Boot 优点如下:
约定优于配置,这是Spring Boot的主导思想。对于Spring Boot而言,大部分情况下存在着默认配置。
在传统的Spring MVC项目开发中,需要手动配置DispatcherServelet(前端控制器),也需要手动配置Spring IoC的容器。你可以选择使用web.xml的配置来实现,当然Servlet3.1之后也可以继承由SpringMVC提供的AbstractAnnotationConfigDispatcherServletInitializer来配置Spring MVC项目。开发完成后,开发者还需要找到对应的服务器去运行,如Tomcat或者Jetty等,这样纪要进行开发又要进行配置和部署,工作量还是不少的。
SpringBoot则在传统所需要配置的地方,都进行了约定,无需手动配置和手动选择,也就是你可以直接以Spring Boot约定的方式进行开发和运行项目。当你需要修改配置配置的时候,它也提供了一些快速配置的约定,正如它承诺的那样,尽可能的配置好Spring项目和绑定对应的服务器,使得开发人员的配置更少,更加直接的开发项目。
Spring Boot为什么在很少的配置下就能运行?
下面以常用的Spring MVC为例进行说明。首先打开Maven的本地仓库,找到对应的Spring Boot的文件夹,可以看到如下目录。
这里先谈spring-boot-start-web的内容,未来还会谈到spring-boot-autoconfigure文件夹的内容,打开spring-boot-start-web文件夹找到对应的pom.xml文件,打开后可以看到代码,其中包括了Spring Boot的依赖、JSON的依赖、Tomcat依赖、Spring Web依赖、Spring Web MVC依赖、Hibernate Validator依赖等。从中我们可以看出当加入spring-boot-start-web后,它会通过Maven将对应的资源加载到我们的工程中。但这样还不足以运行Spring MVC项目,要运行它还需要对SpringMVC进行配置,让它能够生成Spring MVC所需的对象,才能启用Spring MVC,所以还要进一步探讨。
为了探讨Spring MVC在Spring Boot自动配置的问题,首先在spring-boot-autoconfigure文件夹找到
spring-boot-autoconfigure-2.1.4.RELEASE.jar的包。这是一个源码包,解压后在spring-boot-autoconfigure-2.1.4.RELEASE\org\springframework\boot\autoconfigure\web\servlet中我们可以看到许多配置类,如图所示:
这个加框的类就是一个对DispatcherServelet(前端控制器)
进行自动配置的类。在这个类的源码中已经为我们做了很多关于DispatcherServelet的配置,其中的@EnableConfigurationProperties
还能够在读取配置内容的情况下自动生成Spring MVC所需的类,这些内容留在附录中讨论。
到此,应该明白了为什么几乎在没有配置下就能用Spring Boot启动Spring MVC项目,这些都是Spring Boot通过Maven依赖找到对应的jar包和嵌入的服务器,然后使用默认自动配置类来创建默认的开发环境。而有时我们对这些默认的环境修改的个性化需求,通过其中的@EnableConfigurationProperties
注解那样,它允许读入配置文件的内容来自定义自动初始化所需的内容。
一般我们创建的Spring Boot项目中有一个属性文件application.properties,这是一个默认的配置文件,通过它可以根据自己的需要实现自定义。例如,假设当前8080端口已经被占用,我们希望使用8090端口启动Tomcat,那么只需要在这文件中添加一行
server.port=8090
这样Tomcat就是以8090端口启动的了。也就是说,我们只需要修改配置文件,就能将开发的默认配置变为自定义配置。
事实上。Spring Boot的参数配置除了使用properties文件外,还可以使用yml文件等,它会以下列的优先级顺序进行加载:
java:comp/env的JNDI属性
;System.getProperties()
);RandomValuePropertySource
配置的random.*
属性值;application-{profile}.properties
或application.yml
(带spring.profile)配置文件;application-{profile}.properties
或application.yml
(带spring.profile)配置文件;application.properties
或application.yml
(不带spring.profile)配置文件;application.properties
或application.yml
(不带spring.profile)配置文件;@Configuration
注解类上的@PropertySource
;SpringApplication.setDefaultProperties
指定的默认属性。实际上,yml文件的配置与properties文件只是简写和缩进的差别,差异并不大。
上面我们修改了服务器的启动端口,有时候我们还需要修改Spring MVC的视图解析器(ViewResolver)。Spring MVC的视图解析器的作用主要是定位视图,也就是当控制器只返回一个逻辑名称的时候,是没有办法直接对应找到视图的,这就需要视图解析器进行解析了。
server.port=8090
spring.mvc.view.prefix=/WEB-INF/jsp/
spring.mvc.view.suffix=.jsp
这里的spring.mvc.view.prefix
和spring.mvc.view.suffix
是Spring Boot与我们约定的视图前缀和后缀的配置,意思是找到文件夹/WEB-INF/jsp/
下以.jsp为后缀的JSP文件,那么前缀和后缀之间显然又缺了一个文件名称,在Spring MVC机制中,这个名称则是由控制器(Controller)给出的。
现在给出一个demo步骤:
...Application.java
@SpringBootApplication
标志着这是一个SpringBoot入门文件(启动文件)。@Controller
public class IndexController{
@RequestMapping("/index")
public String index(){
return "index";
}
}
//这里定义了一个映射为/index的路径,然后方法返回了`“index”`,这个返回的字符串就
//与之前配置的前缀和后缀结合起来找对应的jsp文件。
Spring所依赖的两个核心理念,一个是控制反转(Inversion of Control,IoC),另一个是面向切面编程(Aspect Oriented Programming,AOP)。可以说Spring是一种基于IoC容器编程的框架。SpringBoot是基于注解的开发SpringIoC。
在Spring中把每个需要管理的对象称为Spring Bean(下文简称Bean),而Spring管理这些Bean的容器,被我们称为Spring IoC容器(下文简称IoC容器)。IoC容器需要具备两个功能:
Spring IoC是管理Bean的容器,Spring 要求所有IoC容器都需要实现接口BeanFactory(Bean工厂)
,这是一个顶级容器接口。由于接口BeanFactory的功能还不够强大,因此Spring在BeanFactory
的基础上还设计了一个更为高级的接口ApplicationContext
,它是BeanFactory
的子接口之一 ,在Spring的体系中BeanFactory
和ApplicationContext
是最为重要的接口设计,我们使用的大部分Spring IoC容器是ApplicationContext
接口的实现类,接口关系如图所示:
在SpringBoot中我们主要通过注解来装配Bean到Spring IoC容器中,在此主要介绍一个基于注解的IoC容器:AnnotationConfigApplicationContext
,从名称就可以看出它是一个基于注解的IoC容器。之所以研究它,是因为Spring Boot装配和获取Bean的方法与它如出一辙。
//首先定义一个java简单对象(POJO)
public class User{
private Long id;
private String userName;
private String note;
/**setter and getter**/
}
//定义Java配置文件
@Configuration
public class AppConfig{
@Bean(name="user")
public User initUser(){
User user = new User();
user.setId(1L);
user.setUserName("user_name_1");
user.setNote("note_1");
return user;
}
}
@Configuration
代表这是一个java配置文件,Spring的容器会根据它来生成IoC容器去装配Bean;@Bean
代表将initUser方法返回的POJO装配到IoC容器中,而其属性name定义这个Bean的名称,如果没有配置它,则将方法名initUser
作为Bean的名称保存到Spring IoC中。
然后就可以使用AnnotationConfigApplicationContext
来构建自己的IoC容器。
public class IoCTest{
private static Logger log = Logger.getLogger(IoCTest.class);
public static viod main(String[] args){
ApplicationContext ctx
= new AnnotationConfigApplicationContext(AppConfig.class);
User user = ctx.getBean(User.class);
log.info(user.getId());
}
}
代码中将Java配置文件AppConfig传递给AnnotationConfigApplicationContext
的构造方法,这样它就能够读取配置了。然后将配置里面的Bean装配到IoC容器中,于是可以使用getBean方法获取对应的POJO,从日志打印中看到,配置在配置文件中的名称为user的Bean已经被装配到IoC容器中,并且可以通过getBean方法获取对应的Bean,并将Bean的属性信息输出出来。当然这是很简单的方法,而注解@Bean也不是唯一创建Bean的方法,还有其他的方法也可以让IoC容器装配Bean,而且Bean之间还有依赖的关系需要进一步处理。
如果一个个的Bean使用注解@Bean注入Spring IoC容器中,那将是一件很麻烦的事情。好在Spring还允许我们进行扫描装配Bean到IoC容器中,对于扫描装配使用的注解是@Component
和@ComponentScan
。@Component
是标明哪个类被扫描进入Spring IoC,而@ComponentScan
则是标明采用何种策略去扫描装配Bean。
这里首先先将User.java放到AppConfig.java的包里,然后修改User.java
@Component("user")
public class User{
@value("1")
private Long id;
@value("user_name_1")
private String userName;
@value("note_1")
private String note;
/**setter and getter**/
}
这里的@Component表明这个类将被Spring IoC容器扫描装配,其中配置的“user”则是作为Bean的名称,当然你也可以不配置这个字符串,那么IoC容器就会把这个类名的第一个字母作为小写,其他不变作为Bean名称放入IoC容器中;注解@value
指定具体的值,使得Spring IoC给予对应的属性注入对应的值。
对于AppConfig改变如下:
@Configuration
@ComponentScan
public class AppConfig{
}
这里加入@ComponentScan
意味着他会进行扫描,但它只会扫描类AppConfig所在的当前包和其子包,之前移动User.java就是这个原因。这样就可以删除之前使用@Bean
标注的创建对象方法。
然而为了使User类能被扫描,上面我们把它迁移到了本不该放置他的地方,这显然不合理。为了更加合理,@ComponentScan
还允许我们自定义扫描的包。具体可以查看@ComponentScan
的源码,此处只说用法。
首先可以通过配置项basePackages定义扫描的包名;还可以通过basePackageClasses定义扫描的类;还有includeFilters和excludeFilters,includeFilters是定义满足过滤器(Filter)条件的Bean才去扫描,excludeFilters则是排除过滤器条件的Bean,它们都需要通过一个注解@ Filter
去定义,它有一个type类型,这里可以定义为注解或者正则式等类型。classes定义注解类,pattern定义正则式类。
让我们把User.java放到该放的位置,如com.springboot.chapter1.pojo
,把AppConfig中的@ComponentScan
修改为:
@ComponentScan("com.springboot.chapter1.*")
或
@ComponentScan(basePackages = {"com.springboot.chapter1.pojo"})
或
@ComponentScan(basePackageClasses = {User.class})
而有时候我们我们的需求是扫描一些包,将一些Bean装配到Spring IoC容器中,而不是想加载这个包里面的某些Bean。比方说,在com.springboot.chapter1.service
中我们有一个UserService类,为了标注它为服务类,将类标注@Service
(该标准注入了@Component
,所以默认情况下它是会被Spring扫描装配到IoC容器中的),这里假设我们采用的:
@ComponentScan("com.springboot.chapter1.*")
对于com.springboot.chapter1.service
和 com.springboot.chapter1.pojo
,这两个包都会被扫描,为了UserService类不被装配,需要把AppConfig中扫描的策略修改为
@ComponentScan(basePackages = "com.springboot.chapter1.*",
excludeFilters = {@Filter(classes = {Service.class})})
由于加入了excludeFilters的配置,使标注了@Service
的类将不被IoC容器扫描注入,这样就可以把UserService类排除到Spring IoC容器中了。
其实,注解@SpringBootApplication
也注入了@ComponentScan
,只是需要注意的是,它提供的exclude和excludeName两个方法是对其内部的自动配置类才会生效的。我们同样可以在不改动AppConfig的情况下排除UserService类。可以在启动配置文件加入 @ComponentScan
来达到我们的目的:
@SpringBootApplication
@ComponentScan(basePackages = {"com.springboot.chapter1"},
excludeFilters = {@Filter(classes = Service.class)})
这两种方法都可以扫描指定的包并排除对应的类。
有时我们希望把第三方包的类对象也放入到Spring IoC中,这时@Bean
注解就发挥作用了。例如引入一个DBCP(数据库连接池)数据源,我们先在pom.xml中加入所需要的DBCP和MySQL的依赖。
然后可以把下列代码放到咱们的AppConfig.java中:
@Bean(name = "dataSource")
public DataSource getDataSource(){
Properties props = new Properties();//创建一个map集合
props.setProperty("driver","com.mysql.jdbc.Driver");
props.setProperty("url","jdbc:mysql://localhost:3306/chapter1");
props.setProperty("username","root");
props.setProperty("password","123456");
DataSource ds = null;
try{
ds = BasicDataSourceFactory.createDataSource(props);
} catch (Exception e){
e.printStackTrace();
}
return ds;
}
通过@bean
定义了其配置项name为dataSource
,那么Spring就会把它返回的ds用名称dataSource保存到IoC容器中。通过这样的方式就可以把第三方包的类装配到Spring IoC容器中。
Bean之间的依赖,在Spring IoC的概念中,我们称为依赖注入(DI)。例如:人类(Person)有时候利用一些动物(Animal)去完成一些事情,比方说狗(Dog)是用来看门的,猫(Cat)是用来抓老鼠的……于是做一些事情就依赖于那些可爱的动物了。为了更好地展现这个过程,首先定义两个接口,一个人类 (Person),另一个是动物(Animal)。
public interface Person{
public void service();
public void setAnimal(Animal animal);
}
public interface Animal{
public void use();
}
两个实现类:
@Component
public class BussinessPerson implements Person{
@Autowired
private Animal animal = null;
@Override
public void service(){
this.setAnimal.use();
}
@Override
public void setAnimal(Animal animal){
this.animal = animal;
}
}
@Component
public class Dog implements Animal{
@Override
public void use(){
System.out.println("狗【"+Dog.class.getSipleName()+"】是用来看门的");
}
}
注解@Override是重写检测。 注解@Autowired
,这是我们在Spring中常用的注解之一,它会根据属性类型(by type)找到对应的Bean进行注入。这里的Dog类是动物的一种,Spring IoC容器会把Dog的实例注入BussinessPerson类中。下面是测试代码:
ApplicationContext ctx
= new AnnotationConfigApplicationContext(AppConfig.class);
Person person = ctx.getBean(BussinessPerson.class);
person.service();
这里我们进一步探讨注解@Autowired
,它注入的机制最基本的一条是根据类型(by type),我们回顾IoC容器的顶级接口BeanFactory
,就可以知道IoC容器是通过getBean
方法获取对应Bean的,而getBean
又支持根据类型(by type)或者根据名称(by name)。回到上面的例子,我们只是创建了一个动物——狗,而实际上动物还可以有猫,于是我们又创建了一个猫类。
@Component
public class Cat implements Animal{
@Override
public void use(){
System.out.println("猫【"+Cat.class.getSipleName()+"】是抓老鼠的");
}
}
没有修改BussinessPerson类,这时运行就出问题了,因为BussinessPerson类只是定义了一个动物属性(Animal),而我们却有两个动物,一个狗,一个猫,Spring IoC就会报异常,因为Spring IoC容器并不能知道你需要注入什么动物(是狗?是猫?)给BussinessPerson类对象。怎么改呢?
假设我们目前需要的是狗提供服务,那么可以把BussinessPerson类属性名称转化为dog,也就是原来的
@Autowired
private Animal animal = null;
改为
@Autowired
private Animal dog = null;
那么再测试,就是采用狗来提供服务的。那是因为注解@Autowired
提供了一个规则,首先它会根据类型找到对应Bean,如果这类型的Bean不唯一,那么它会根据其属性名称和Bean的名称进行匹配。如果匹配上就是用该Bean;匹配不上就会抛出异常。
需要注意的是@Autowired
是一个默认必须找到对应Bean的注解,如果不能确定其标注的属性一定会存在并且允许这个标注的属性为null,那么你可以配置@Autowired
属性required为false
,例如:
@Autowired(required = false)
同样,注解@Autowired
还可以标注方法,甚至我们可以使用在方法的参数上,后面会再谈到它。
在上面我们发现有猫有狗的时候,我们把属性名称animal修改为了dog,显然是不合适的做法。产生注入失败的问题根本是按类型查找,正如动物有多种类型,会使Spring IoC容器注入造成困扰,我们把这样的问题称为歧义性。这节就是解决这个问题。
首先是注解@Primary
,它是一个修改优先权的注解,当我们有猫有狗的时候,假设这次需要猫,那么只需要在猫类的定义上加入@Primary
就可以了,类似下面:
@Component
@Primary
public class Cat implements Animal{
@Override
public void use(){
System.out.println("猫【"+Cat.class.getSipleName()+"】是抓老鼠的");
}
}
注解@Primary
的含义告诉Spring IoC容器,当发现有多个同样类型的Bean时,请优先使用我注入,于是再进行测试,系统将用猫提供服务。
然而,有时候@Primary
也可以使用在多个类上,也许无论是猫还是狗都可能带上@Primary
注解,其结果是IoC容器还是无法区分用哪个Bean的实例进行注入,那么就用到了@Qualifier
,它的配置项value
需要一个字符串去定义,它与@Autowired
组合在一起,通过类型和名称一起找到Bean。Bean名称在Spring IoC中是唯一的标识,通过这个就能消除歧义性了。
假设猫已经标注了@Primary
,而我们需要狗提供服务,只需要修改BussinessPerson类属性animal的标注以适合我们的需要
@Autowired
@Qualifier("dog")
private Animal animal = null;
一旦这样声明,Spring IoC将以类型和名称去寻找对应的Bean进行注入。
在上面,都是基于不带参数的构造方法下实现依赖注入。但事实上,有些类只有带有参数的构造方法,于是上述的方法不能再使用,为了满足这个功能,我们可以使用@Autowired
对构造方法的参数进行注入。
@Component
public class BussinessPerson implements Person{
private Animal animal = null;
public BussinessPerson(@Autowired @Qualifier("dog") Animal animal){
this.animal = animal;
}
@Override
public void service(){
this.setAnimal.use();
}
@Override
public void setAnimal(Animal animal){
this.animal = animal;
}
}
上面只是Bean装配到IOC容器中,现在我们看看IoC容器如何装配和销毁Bean的过程。有时我们需要自定义初始化或者销毁Bean的过程。例如:数据库的数据源,我们希望在其关闭的时候调用其close方法,来释放数据库的连接资源。我们有必要了解Spring IoC初始化和销毁Bean的过程。Bean的生命周期的过程,大致分为Bean定义、Bean的初始化、Bean的生存期和Bean的销毁4个部分,Bean定义过程大致如下:
@ComponentScan
定义的扫描路径去找带有@Component
的类,这是一个资源定位的过程。这3步只是一个资源定位并将Bean的定义发布到IoC容器的过程,还没有Bean实例的生成,更没有完成依赖注入。在默认情况下,Spring会继续完成Bean的实例化和依赖注入,这样从IoC容器中就可以得到一个依赖注入完成的Bean。但是,有些Bean会受到变化因素的影响,这是我们希望是取出Bean的时候完成初始化和依赖注入,换句话说就是让那些Bean只是将定义发布到IoC容器而不做实例化和依赖注入,当我们取出来的时候才做初始化和依赖注入等操作。
我们先了解一下Spring Bean的初始化流程:
@ComponentScan
中还有一个配置项lazyInit
,只可以配置Boolean值,且默认值为false,也就是默认不进行延迟初始化,因此在默认的情况下Spring会对Bean进行实例化和依赖注入对应的属性值。因此修改配置类AppConfig的@ComponentScan
中的lazyInit
为true就可以延迟初始化。
@ComponentScan(basePackages = "com.springboot.chapter1.*", lazyInit = true)
下面是Spring Bean的生命周期图:
我们需要注意两点:
ApplicationContextAware接口
,但是有时候并不会调用,这要根据你的IoC容器来决定。我们知道,Spring IoC容器最低的要求是实现BeanFactory接口
,而不是实现ApplicationContext接口
。对于那些没有实现ApplicationContext接口
的容器,在生命周期对应的ApplicationContextAware
定义的方法也是不会被调用的,只有实现了ApplicationContext接口
的容器,才会在生命周期调用ApplicationContextAware
所定义的setApplicationContext方法
。通过注解@PostConstruct
自定义初始化方法,通过注解@PreDestroy
自定义销毁方法。
有时候Bean的定义可能使用的是第三方的类,此时可以使用注解@Bean
来配置自定义初始化和销毁方法,如下所示:
@Bean(initMethod ="init",destroyMethod = "destroy")
如今Java开发使用属性文件已经十分普遍,这里谈谈这方面的内容,由于读取配置文件的方法很多,这里只介绍最常用的方法。
首先,我们在Maven配置文件中加载依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
有了依赖,就可以直接使用application.properties
文件为你工作了。现在为属性文件添加数据库配置属性:
database.driverName=com.mysql.jdbc.Driver
database.url=jdbc:mysql://localhost:3306/chapter1
database.username=root
database.password=123456
application.properties
文件是Spring Boot默认的文件,它会通过其机制读取到上下文中,这样可以引用它了。对于它的引用,有两种方法,首先是用Spring表达式。本节我们只限于读取属性而不涉及运算。关于其运算,后面再谈及。
@Component
public class DataBaseProperties {
@Value("${database.driverName}")
private String driverName = null;
@Value("${database.url}")
private String url = null;
private String username = null;
private String password = null;
public void setDriverName(String driverName) {
System.out.println(driverName);
this.driverName = driverName;
}
public void setUrl(String url) {
System.out.println(url);
this.url = url;
}
@Value("${database.username}")
public void setUsername(String username) {
System.out.println(username);
this.username = username;
}
@Value("${database.password}")
public void setPassword(String password) {
System.out.println(password);
this.password = password;
}
/**** getters ****/
}
可以通过@Value注解
,使用${......}
这样的占位符读取配置在属性文件的内容
。这里的@Value注解
,既可以加载属性,也可以加在方法上。
有时我们也可以使用注解@ConfigurationProperties
,使得配置上有所减少,例如,下面我们修改DataBaseProperties的代码:
@Component
@ConfigurationProperties("database")
public class DataBaseProperties {
private String driverName = null;
private String url = null;
private String username = null;
private String password = null;
public void setDriverName(String driverName) {
System.out.println(driverName);
this.driverName = driverName;
}
public void setUrl(String url) {
System.out.println(url);
this.url = url;
}
public void setUsername(String username) {
System.out.println(username);
this.username = username;
}
public void setPassword(String password) {
System.out.println(password);
this.password = password;
}
/**** getters ****/
}
注解@ConfigurationProperties
中配置的字符串database
,将与POJO的属性名称组成属性的全限定名去配置文件里查找,这样就能将对应的属性读入到POJO当中。
如果把所有的内容都配置到application.properties
,显然这个文件将有很多内容。有时为了更好地配置,我们选择使用新的配置文件。例如,数据库的属性可以配置在jdbc.properties
中,于是把application.properties
中的数据库配置属性迁到jdbc.properties
中,然后使用@PropertySource
去定义对应的属性文件,把它加载到Spring的上下文中。
@SpringBootApplication
@ComponentScan(basePackages = {"com.springboot.chapter1"})
@PropertySource(value={"classpath:jdbc.properties"}, ignoreResourceNotFound=true)
public class Chapter1Application {
public static void main(String[] args) {
SpringApplication.run(Chapter1Application.class, args);
}
}
value
可以配置多个配置文件。使用classpath
前缀,意味着去类文件路径下找到属性文件;ignoreResourceNotFound
则是是否忽略配置文件找不到的问题。ignoreResourceNotFound
的默认值为false
,也就是没有找到属性文件,就会报错;这里配置为true
,也就是找不到就忽略掉,不会报错。
有时候某些客观的因素会使一些Bean无法进行初始化,例如,在数据库连接池的配置中漏掉一些配置会造成数据源不能连接上。在这样的情况下,IoC容器如果还进行数据源的装配,则系统将会抛出异常,导致应用无法继续。这时倒是希望IoC容器不去装配数据源。为了处理这样的场景,Spring提供了@Conditional注解
帮助我们,而它需要配合另外一个接口Condition(org.springframework.context.annotation.Condition)
来完成对应的功能。
/***使用属性初始化数据库连接池***/
@Bean(name = "dataSource", destroyMethod="close")
@Conditional(DatabaseConditional.class)
public DataSource getDataSource(
@Value("${database.driverName}") String driver,
@Value("${database.url}") String url,
@Value("${database.username}") String username,
@Value("${database.password}") String password
) {
Properties props = new Properties();
props.setProperty("driver", driver);
props.setProperty("url", url);
props.setProperty("username", username);
props.setProperty("password", password);
DataSource dataSource = null;
try {
dataSource = BasicDataSourceFactory.createDataSource(props);
} catch (Exception e) {
e.printStackTrace();
}
return dataSource;
}
加入了@Conditional注解
,并且配置了类DatabaseConditional
,那么这个类就必须实现Condition接口
。对于Condition接口
则要求实现matches方法
。
//定义初始化数据库的条件
package com.springboot.chapter1.condition;
/******** imports ********/
public class DatabaseConditional implements Condition {
/**
* 数据库装配条件
*
* @param context 条件上下文
* @param metadata 注释类型的元数据
* @return true装配Bean,否则不装配
*/
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
// 取出环境配置
Environment env = context.getEnvironment();
// 判断属性文件是否存在对应的数据库配置
return env.containsProperty("database.driverName")
&& env.containsProperty("database.url")
&& env.containsProperty("database.username")
&& env.containsProperty("database.password");
}
}
matches方法
首先读取其上下文环境,然后判定是否已经配置了对应的数据库信息。这样,当这些都已经配置好后则返回true。这个时候Spring会装配数据库连接池的Bean,否则是不装配的。
IoC容器最顶级接口BeanFactory
中,可以看到isSingleton
和isPrototype
两个方法。其中,isSingleton方法
如果返回true
,则Bean在IoC容器中以单例存在,这也是Spring IoC容器的默认值;如果isPrototype
方法返回true
,则当我们每次获取Bean的时候,IoC容器都会创建一个新的Bean,这显然存在很大的不同,这便是Spring Bean的作用域的问题。在一般的容器中,Bean都会存在单例(Singleton)
和原型(Prototype)
两种作用域,Java EE广泛地使用在互联网中,而在Web容器中,则存在页面(page)、请求(request)、会话(session)和应用(application)4种作用域。对于页面(page),是针对JSP当前页面的作用域,所以Spring是无法支持的。为了满足各类的作用域,在Spring的作用域中就存在如下几种类型。
其中application作用域
,完全可以使用单例
来代替。下面我们探讨一下单例(Singleton)
和原型(Prototype)
的区别。
//定义作用域类
package com.springboot.chapter3.scope.pojo;
/******** imports ********/
@Component
public class ScopeBean{
}
这样就是启动默认的作用域,实际就是单例。我们进行一下测试:
//测试作用域类
public class IoCTest{
private static logger log = Logger.getLogger(IoCTest.class);
public static void main(String[] args)throws SQLException{
AnnotationConfigApplicationContext ctx
= new AnnotationConfigApplicationContext(AppConfig.class);
ScopeBean scopeBean1 = ctx.getBean(ScopeBean.class);
ScopeBean scopeBean2 = ctx.getBean(ScopeBean.class);
System.out.println(scopeBean1 == scopeBean2);
}
}
在输出这行加入断点。
从测试结果看出显然scopeBean1和scopeBean2这两个变量都指向了同一的实例,所以在IoC容器中,只有一个ScopeBean的实例。下面在在定义作用域类中添加一行代码:
//定义作用域类
package com.springboot.chapter3.scope.pojo;
/******** imports ********/
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ScopeBean{
}
进行同样的测试,则可以看到scopeBean1 == scopeBean2返回的将是false,而不再是true,那是因为我们将Bean的作用域修改为了prototype
,这样就能让IoC容器在每次获取Bean时,都新建一个Bean的实例返回给调用者。
这里的ConfigurableBeanFactory
只能提供单例(SCOPE_SINGLETON)和原型(SCOPE_PROTOTYPE)两种作用域供选择,如果是在Spring MVC环境中,还可以使用WebApplicationContext
去定义其他作用域,如请求(SCOPE_REQUEST)、会话(SCOPE_SESSION)和应用(SCOPE_APPLICATION)。例如,下面的代码就是定义请求作用域。例如定义请求作用域:
package com.springboot.chapter3.scope.pojo;
/******** imports ********/
@Component
@Scope(WebApplicationContext.SCOPE_REQUEST)
public class ScopeBean {
}
这样同一个请求范围内去获取这个Bean的时候,只会共用同一个Bean,第二次请求就会产生新的Bean。因此两个不同的请求将获得不同的实例的Bean。
在企业开发的过程中,项目往往要面临开发环境、测试环境、准生产环境(用于模拟真实生产环境部署所用)和生产环境的切换,这样在一个互联网企业中往往需要有4套环境,而每一套环境的上下文是不一样的。例如,它们会有各自的数据库资源,这样就要求我们在不同的数据库之间进行切换。为了方便,Spring还提供了Profile机制,使我们可以很方便地实现各个环境之间的切换。假设存在dev_spring_boot
和test_spring_boot
两个数据库,这样可以使用注解@Profile
定义两个Bean。
//定义Profile
@Bean(name = "dataSource", destroyMethod = "close")
@Profile("dev")
public DataSource getDevDataSource() {
Properties props = new Properties();
props.setProperty("driver", "com.mysql.jdbc.Driver");
props.setProperty("url", "jdbc:mysql://localhost:3306/dev_spring_boot");
props.setProperty("username", "root");
props.setProperty("password", "123456");
DataSource dataSource = null;
try {
dataSource = BasicDataSourceFactory.createDataSource(props);
} catch (Exception e) {
e.printStackTrace();
}
return dataSource;
}
@Bean(name = "dataSource", destroyMethod = "close")
@Profile("test")
public DataSource getTestDataSource() {
Properties props = new Properties();
props.setProperty("driver", "com.mysql.jdbc.Driver");
props.setProperty("url", "jdbc:mysql://localhost:3306/test_spring_boot");
props.setProperty("username", "root");
props.setProperty("password", "123456");
DataSource dataSource = null;
try {
dataSource = BasicDataSourceFactory.createDataSource(props);
} catch (Exception e) {
e.printStackTrace();
}
return dataSource;
}
在Spring中存在两个参数可以提供给我们配置,以修改启动Profile机制,一个是spring.profiles.active
,另一个是spring.profiles.default
。在这两个属性都没有配置的情况下,Spring将不会启动Profile机制,这就意味着被@Profile标注的Bean将不会被Spring装配到IoC容器中。Spring是先判定是否存在spring.profiles.active
配置后,再去查找spring.profiles.default
配置的,所以spring.profiles.active
的优先级要大于spring.profiles.default
。
在Java启动项目中,我们只需要如下配置就能够启动Profile机制:
JAVA_OPTS="-Dspring.profiles.active=dev"
启动指定Profile的方式有很多,可以直接谷歌Spring Boot启动指定profile的方式详解。
对于属性配置(properties)
文件而言,在Spring Boot中还存在一个约定,即允许比较方便地切换配置环境。例如,现实中开发环境和测试环境的数据库是两个库,开发人员测试可能比较随意地增删查改,而测试人员则不是,测试人员需要搭建数据库的测试数据往往也需要比较多的时间和精力,因此在很多情况下,他们希望有独立的数据库,这样配置数据库连接的文件就需要分开了,而Spring Boot可以很好地支持切换配置文件的功能。首先我们在配置文件目录新增application-dev.properties
文件,然后将日志配置为DEBUG级别,这样启动Spring Boot就会有很详细的日志显示。配置内容如下:
logging.level.root=DEBUG
logging.level.org.springframework=DEBUG
这个时候请注意,按照Spring Boot的规则,假设把选项-Dspring.profiles.active配置
的值记为{profile}
,则它会用application-{profile}.properties
文件去代替原来默认的application.properties
文件,然后启动Spring Boot的程序,就可以看到日志以DEBUG级别打出,非常详尽。通过这样就能够有效地切换各类环境,如开发、测试和生产。
Spring Boot建议使用注解和扫描配置Bean,但它并不拒绝使用XML配置Bean。需要使用的是注解@ImportResource
,通过它可以引入对应的XML文件,用以加载Bean。有时候有些框架(如Dubbo)是基于Spring的XML方式进行开发的,这个时候需要引入XML的方式来实现配置。
//定义一个松鼠POJO
package com.springboot.other.pojo;
/******** imports ********/
public class Squirrel implements Animal {
@Override
public void use() {
System.out.println("松鼠可以采集松果");
}
}
注意,这个POJO所在的包并不在@ComponentScan
定义的扫描包com.springboot.chapter1.*
之内,而且没有标注@Component
,所以不会被扫描机制所装配。在这里,我们使用XML的方式来装配它,于是就可以定义一个XML文件:
//使用XML配置POJO——Spring-other.xml
<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
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="squirrel" class="com.springboot.other.pojo.Squirrel"/>
</beans>
这样我们就定义了一个Bean,然后在Java配置文件中就可以直接载入它。
//装配XML定义的Bean
package com.springboot.chapter1.config;
/******* imports ********/
@Configuration
@ComponentScan(basePackages = "com.springboot.chapter1.*")
@ImportResource(value = {"classpath:spring-other.xml"})
public class AppConfig {
......
}
这样就可以引入对应的XML,从而将XML定义的Bean也装配到IoC容器中。
在上述代码中,我们是在没有任何运算规则的情况下装配Bean的。为了更加灵活,Spring还提供了表达式语言Spring EL。通过Spring EL可以拥有更为强大的运算规则来更好地装配Bean。
最常用的例如,读取属性文件的值:
@Value("${database.driverName}")
String driver
这里的@Value中的${......}
代表占位符
,它会读取上下文的属性值装配到属性中,这便是一个最简单的Spring表达式。除此之外,它还能够调用方法,例如,我们记录一个Bean的初始化时间:
@Value("#{T(System).currentTimeMillis()}")
private Long initTime = null;
注意,这里采用#{......}代表启用Spring表达式
,它将具有运算的功能;T(…)代表的是引入类;System是java.lang.*包的类,这是Java默认加载的包,因此可以不必写全限定名,如果是其他的包,则需要写出全限定名才能引用类;currentTimeMillis是它的静态(static)方法,也就是我们调用一次System.currentTimeMillis()方法来为这个属性赋值。
此外还可以给属性直接进行赋值,如:
// 赋值字符串
@Value("#{'使用Spring EL赋值字符串'}")
private String str = null;
// 科学计数法赋值
@Value("#{9.3E3}")
private double d;
// 赋值浮点数
@Value("#{3.14}")
private float pi;
显然这比较灵活,有时候我们还可以获取其他Spring Bean的属性来给当前的Bean属性赋值,例如:
@Value("#{beanName.str}")
private String otherBeanProp = null;
注意,这里的beanName是Spring IoC容器Bean的名称。str是其属性,代表引用对应的Bean的属性给当前属性赋值。有时候,我们还希望这个属性的字母全部变为大写,这个时候就可以写成:
@Value("#{beanName.str?.toUpperCase()}")
private String otherBeanProp = null;
这里引用str属性后跟着是一个?
,这个符号的含义是判断这个属性是否为空
。如果不为空才会去执行toUpperCase方法,进而把引用到的属性转换为大写,赋予当前属性。除此之外,还可以使用Spring EL进行一定的运算,如:
#数学运算
@Value("#{1+2}")
private int run;
#浮点数比较运算
@Value("#{beanName.pi == 3.14f}")
private boolean piFlag;
#字符串比较运算
@Value("#{beanName.str eq 'Spring Boot'}")
private boolean strFlag;
#字符串连接
@Value("#{beanName.str + ' 连接字符串'}")
private String strApp = null;
#三元运算
@Value("#{beanName.d > 1000 ? '大于' : '小于'}")
private String resultDesc = null;
从上面的代码可以看出,Spring EL能够支持的运算还有很多,其中等值比较如果是数字型的可以使用==比较符,如果是字符串型的可以使用eq比较符。当然,Spring EL的内容远不止这些,具体可以去看Spring EL的详细使用方法,这里只列举了比较常用的一些。
AOP的基础概念有很多,如切点、通知、连接点、引入和织入等,面对那些晦涩难懂的概念,初学者往往很容易陷入理解的困境。所以我们先不谈这些,先谈谈一个简单的约定编程。对于约定编程,首先你需要记住的是约定的流程是什么,然后就可以完成对应的任务,却不需要知道底层设计者是怎么将约定的内容织入对应的流程中的。
本节我们完全抛开AOP的概念,先来看一个约定编程的实例。当你弄明白了这个实例后,相信Spring AOP的概念也就很容易理解了,因为实质上它们是异曲同工的东西。只是这需要自己去实践,如果仅仅是看看,那么效果肯定就会相差几个档次。
先定义一个简单接口:
package com.springboot.chapter1.service;
public interface HelloService {
public void sayHello(String name);
}
这个接口很简单,就是定义一个sayHello的方法,其中的参数name是名字,这样就可以对该名字说hello了。于是很快我们可以得到这样的一个实现类:
package com.springboot.chapter1.service.impl;
import com.springboot.chapter1.service.HelloService;
public class HelloServiceImpl implements HelloService {
@Override
public void sayHello(String name) {
if (name == null || name.trim() == "") {
throw new RuntimeException ("parameter is null!!");
}
System.out.println("hello " + name);
}
}
这样一个几乎就是最简单的服务写好了。下面先来定义一个拦截器接口,它十分简单,只是存在几个方法。
package com.springboot.chapter1.intercept;
import java.lang.reflect.InvocationTargetException;
import com.springboot.chapter1.invoke.Invocation;
public interface Interceptor {
// 事前方法
public boolean before();
// 事后方法
public void after();
/**
* 取代原有事件方法
* @param invocation -- 回调参数,可以通过它的proceed方法,回调原有事件
* @return 原有事件返回对象
* @throws InvocationTargetException
* @throws IllegalAccessException
*/
public Object around(Invocation invocation)
throws InvocationTargetException, IllegalAccessException;
// 是否返回方法。事件没有发生异常执行
public void afterReturning();
// 事后异常方法,当事件发生异常后执行
public void afterThrowing();
// 是否使用around方法取代原有方法
boolean useAround();
}
后面会给出约定,将这些方法织入流程中。这里我们首先给出around方法中的参数Invocation对象的源码:
package com.springboot.chapter1.invoke;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class Invocation {
private Object[] params;
private Method method;
private Object target;
public Invocation(Object target, Method method, Object[] params) {
this.target = target;
this.method = method;
this.params = params;
}
// 反射方法
public Object proceed() throws
InvocationTargetException, IllegalAccessException {
return method.invoke(target, params);
}
/**** setter and getter ****/
}
它没有太多可以探讨的内容,唯一值得探讨的就是proceed方法,它会以反射的形式去调用原有的方法。
接着,你可以根据拦截器(Interceptor)接口的定义
开发一个属于你自己的拦截器MyInterceptor
:
package com.springboot.chapter1.intercept;
import java.lang.reflect.InvocationTargetException;
import com.springboot.chapter1.invoke.Invocation;
public class MyInterceptor implements Interceptor {
@Override
public boolean before() {
System.out.println("before ......");
return true;
}
@Override
public boolean useAround() {
return true;
}
@Override
public void after() {
System.out.println("after ......");
}
@Override
public Object around(Invocation invocation)
throws InvocationTargetException, IllegalAccessException {
System.out.println("around before ......");
Object obj = invocation.proceed();
System.out.println("around after ......");
return obj;
}
@Override
public void afterReturning() {
System.out.println("afterReturning......");
}
@Override
public void afterThrowing() {
System.out.println("afterThrowing......");
}
}
这个拦截器的功能也不复杂,接着就要谈谈我和你的约定了。约定是本节的核心,也是Spring AOP的本质。我先提供一个类——ProxyBean(代理Bean)给读者使用,它有一个静态的(static)方法:
public static Object getProxyBean(Object target, Interceptor interceptor)
这个方法的说明如下:
刚开始HelloService()定义的接口对象
;于是你就可以使用它拿到proxy
了,例如,下面的代码:
HelloService helloService = new HelloServiceImpl();
HelloService proxy = (HelloService) ProxyBean.getProxyBean(helloService, new
MyInterceptor());
然后我再提供下面的约定。请注意,这个约定是十分重要的。
当调用proxy对象的方法时,其执行流程如下。
proxy调用方法
时会先执行拦截器的before方法。useAround方法
返回true,则执行拦截器的around方法
,而不调用target对象对应的方法
,但around方法
的参数invocation对象
存在一个proceed方法
,它可以调用target对象对应的方法
;如果useAround方法
返回false
,则直接调用target对象的事件方法
。after方法
。around方法
或者回调target的事件方法
时,可能发生异常,也可能不发生异常。如果发生异常,就执行拦截器的afterThrowing方法
,否则就执行afterReturning方法
。下面我们再用流程图来展示这个约定流程,这样会更加清晰一些。注意around方法是可以回调target事件方法的:
图4-1
有了这个流程图,就可以进行约定编程了。例如代码:
//测试约定流程
private static void testProxy() {
HelloService helloService = new HelloServiceImpl();
// 按约定获取proxy
HelloService proxy = (HelloService) ProxyBean.getProxyBean(
helloService, new MyInterceptor());
proxy.sayHello("zhangsan");
System.out.println("\n###############name is null!!#############\n");
proxy.sayHello(null);
}
按照我们的约定,这段代码打印的信息如下:
before ......
around before ......
hello zhangsan
around after ......
after ......
afterReturning......
###############name is null!!#############
before ......
around before ......
after ......
afterThrowing......
可以看到,我已经把服务和拦截器的方法织入约定的流程中了。那么怎样实现把服务和拦截器的方法织入约定的流程中呢?那就是ProxyBean
的内容了,下面我们将讨论它。
如何将服务类和拦截方法织入对应的流程,是ProxyBean要实现的功能。首先要理解动态代理模式。其实代理很简单,例如,当你需要采访一名儿童时,首先需要经过他父母的同意,在一些问题上父母也许会替他回答,而对于另一些问题,也许父母觉得不太适合这个小孩会拒绝掉,显然这时父母就是这名儿童的代理(proxy)了。通过代理可以增强或者控制对儿童这个真实对象(target)的访问。
也就是需要一个代理对象。在JDK中,提供了类Proxy的静态方法——newProxyInstance
,其内容具体如下:
public static Object newProxyInstance(ClassLoader classLoader, Class<?>[] interfaces,
InvocationHandler invocationHandler) throws IllegalArgumentException
给予我们来生成一个代理对象(proxy),它有3个参数:
invocationHandler
是一个接口InvocationHandler对象
,它定义了一个invoke方法
,这个方法就是实现代理对象的逻辑的
其定义如下所示:/**
* 处理代理对象方法逻辑
* @param proxy 代理对象
* @param method 当前方法
* @param args 运行参数
* @return 方法调用结果
*/
public Object invoke(Object proxy, Method method, Object[] args);
然后通过目标对象(target)、方法(method)和参数(args)就能够反射方法运行了,于是我们可以实现ProxyBean的源码如下代码:
//实现ProxyBean
package com.springboot.chapter1.proxy;
package com.springboot.chapter1.proxy;
/**** imports ****/
public class ProxyBean implements InvocationHandler {
private Object target = null;
private Interceptor interceptor = null;
/**
* 绑定代理对象
* @param target 被代理对象
* @param interceptor 拦截器
* @return 代理对象
*/
public static Object getProxyBean(Object target, Interceptor interceptor) {
ProxyBean proxyBean = new ProxyBean();
// 保存被代理对象
proxyBean.target = target;
// 保存拦截器
proxyBean.interceptor = interceptor;
// 生成代理对象
Object proxy = Proxy.newProxyInstance(target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
proxyBean);
// 返回代理对象
return proxy;
}
/**
* 处理代理对象方法逻辑
* @param proxy 代理对象
* @param method 当前方法
* @param args 运行参数
* @return 方法调用结果
* @throws Throwable 异常
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
// 异常标识
boolean exceptionFlag = false;
Invocation invocation = new Invocation(target, method, args);
Object retObj = null;
try {
if (this.interceptor.before()) {
retObj = this.interceptor.around(invocation);
} else {
retObj = method.invoke(target, args);
}
} catch (Exception ex) {
// 产生异常
exceptionFlag = true;
}
this.interceptor.after();
if (exceptionFlag) {
this.interceptor.afterThrowing();
} else {
this.interceptor.afterReturning();
return retObj;
}
return null;
}
}
首先,这个ProxyBean
实现了接口InvocationHandler
,因此就可以定义invoke方法
了。其中在getBean方法
中,我让其生成了一个代理对象,并且创建了一个ProxyBean实例保存目标对象(target)和拦截器(interceptor),为后面的调用做好了准备。其次,生成了一个代理对象,而这个代理对象挂在target实现的接口之下,所以你可以用target对象实现的接口对这个代理对象实现强制转换,并且将这个代理对象的逻辑挂在ProxyBean实例之下,这样就完成了目标对象(target)和代理对象(proxy)的绑定。最后,将代理对象返回给调用者。于是在测试约定流程代码中我们只需要通过以下两句代码:
HelloService helloService = new HelloServiceImpl();
// 按约定获取proxy
HelloService proxy = (HelloService) ProxyBean.getProxyBean(helloService, new
MyInterceptor());
就可以获取这个代理对象了,当我们使用它调用方法时,就会进入到ProxyBean的invoke方法
里,而这个invoke方法
就是按照图4-1所约定的流程来实现的,这就是我们可以通过一定的规则完成约定编程的原因。动态代理的概念比较抽象,掌握不易,这里建议对invoke方法进行调试,一步步印证它运行的过程。编程是门实践学科,通过自己动手会有更加深入的理解。
到现在为止,并没有讲述关于AOP的概念,本节只是通过约定告诉读者,只要提供一定的约定规则,按照约定编程后,就可以把自己开发的代码织入约定的流程中。而实际上在开发中,你只需要知道框架和你的约定便可以了。在现实中很多框架也是这么做的,换句话说,Spring AOP也是这么做的,它可以通过与我们的约定,把对应的方法通过动态代理技术织入约定的流程中,这就是SpringAOP编程的本质。所以掌握Spring AOP的根本在于掌握其对我们的约定规则,下面的部分让我们来学习它们。
通过上面约定编程的例子,可以看到,只要按照一定的规则,我就可以将你的代码织入事先约定的流程中。实际上Spring AOP也是一种约定流程的编程,在Spring中可以使用多种方式配置AOP,因为Spring Boot采用注解方式,所以为了保持一致,这里就只介绍使用@AspectJ注解
的方式,不过在开启AOP术语的时候,我们先来考虑为什么要使用AOP。
AOP最为典型的应用实际就是数据库事务的管控。例如,当我们需要保存一个用户时,可能要连同它的角色信息一并保存到数据库中。于是,可以看到下图所示的一个流程图。
数据库事务不能用OOP处理
这里的用户信息和用户角色信息,我们都可以使用面向对象编程(OOP)进行设计,但是它们==在数据库事务中的要求是,要么一起成功,要么一起失败,这样OOP就无能为力了==。数据库事务毫无疑问是企业级应用关注的核心问题之一,而使用AOP可以解决这些问题。 AOP还可以减少大量重复的工作。在Spring流行之前,我们可以使用JDBC代码实现很多的数据库操作。例如,插入一个用户的信息,我们可以用JDBC代码来实现://JDBC实现插入用户
package com.springboot.chapter4.jdbc;
/**** imports ****/
public class UserService {
public int insertUser() {
UserDao userDao = new UserDao();
User user = new User();
user.setUsername("user_name_1");
user.setNote("note_1");
Connection conn = null;
int result = 0;
try {
//获取数据事务连接
Class.forName("com.mysql.jdbc.Driver");
conn = DriverManager.getConnection(
"jdbc:mysql://localhost:3306/chapter3", "root", "123456");
// 非自动提交事务
conn.setAutoCommit(false);
result = userDao.insertUser(conn, user);
//提交事务
conn.commit();
} catch (Exception e) {
try {
//回滚事务
conn.rollback();
} catch (SQLException ex) {
ex.printStackTrace();
}
e.printStackTrace();
} finally {
//释放数据连接资源
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
return result;
}
}
package com.springboot.chapter4.jdbc;
/**** imports ****/
public class UserDao {
public int insertUser(Connection conn, User user) throws SQLException {
PreparedStatement ps = null;
try {
ps = conn.prepareStatement("insert into t_user(user_name, note) values( ?, ?)");
ps.setString(1, user.getUsername());
ps.setString(2, user.getNote());
return ps.executeUpdate();
} finally {
ps.close();
}
}
}
这里可以注意到,我们获取数据库事务连接、事务操控和关闭数据库连接的过程,都需要使用大量的try … catch … finally…语句去操作,这显然存在大量重复的工作。是否可以替换这些没有必要重复的工作呢?答案是肯定的,因为这里存在着一个默认的流程,我们先描述一下这个流程。
执行更新SQL的流程
这张图与图4-1虽然有些不太一样,但还是接近的。如果我们通过约定流程编程设计成下图的样子,也许你就会更感兴趣了。事务流程约定的默认实现
上图可以看到,关于数据库的打开和关闭以及事务的提交和回滚都有流程默认给你实现。换句话说,你都不需要完成它们,你需要完成的任务是编写SQL这一步而已,然后织入流程中。于是你可以看到大量在工作中的类似基于Spring开发的代码:@Autowired
private UserDao = null;
......
@Transactional
public int inserUser(User user) {
return userDao.insertUser(user);
}
当然,这里只是为了让读者知道约定编程的好处,AOP也是一种约定编程。这里可以看到仅仅使用了一个注解@Transactional
,表明该方法需要事务运行,没有任何数据库打开和关闭的代码,也没有事务回滚和提交的代码,却实现了数据库资源的打开和关闭、事务的回滚和提交。那么Spring是怎么做到的呢?大致的流程是:Spring帮你把insertUser方法织入类似于上图所示的流程中,而数据库连接的打开和关闭以及事务管理都由它给你默认实现,也就是它可以将大量重复的流程通过约定的方式抽取出来,然后给予默认的实现。例如,这里的数据库连接的打开和释放、事务的处理,可见它可以帮助我们减少大量的代码,尤其是那些烦人的try…catch…finally…语句。
从上面的代码中,我们可以看到使用Spring AOP可以处理一些无法使用OOP实现的业务逻辑。其次,通过约定,可以将一些业务逻辑织入流程中,并且可以将一些通用的逻辑抽取出来,然后给予默认实现,这样你只需要完成部分的功能就可以了,这样做可以使得开发者的代码更加简短,同时可维护性也得到提高。在后面的数据库事务和Redis的开发章节中,我们还会再次看到它的威力。
上面的内容已经介绍了约定编程和为什么要使用AOP,接下来是时候去介绍AOP的术语和流程了,相信有了约定编程的概念之后AOP的概念也会更加容易理解。不过,Spring AOP是一种基于方法的AOP,它只能应用于方法上。
下面我们先来讲解AOP术语。
连接点(join point)
:对应的是具体被拦截的对象,因为Spring只能支持方法,所以被拦截的对象往往就是指特定的方法,例如,我们前面提到的HelloServiceImpl的sayHello方法
就是一个连接点,AOP将通过动态代理技术把它织入对应的流程中。切点(point cut)
:有时候,我们的切面不单单应用于单个方法,也可能是多个类的不同方法,这时,可以通过正则式和指示器的规则去定义,从而适配连接点。切点就是提供这样一个功能的概念。通知(advice)
:就是按照约定的流程下的方法,分为前置通知(beforeadvice)
、后置通知(after advice)
、环绕通知(around advice)
、事后返回通知(afterReturning advice)
和异常通知(afterThrowing advice)
,它会根据约定织入流程中,需要弄明白它们在流程中的顺序和运行的条件。目标对象(target)
:即被代理对象,例如,约定编程中的HelloServiceImpl实例就是一个目标对象,它被代理了。引入(introduction)
:是指引入新的类和其方法,增强现有Bean的功能。织入(weaving)
:它是一个通过动态代理技术,为原有服务对象生成代理对象,然后将与切点定义匹配的连接点拦截,并按约定将各类通知织入约定流程的过程。切面(aspect)
:是一个可以定义切点、各类通知和引入的内容,Spring AOP将通过它的信息来增强Bean的功能或者将对应的方法织入流程。Spring AOP流程约定
显然,它与我们之前所做的约定编程比较类似,从图中可以知道连接点
、通知
、织入
、目标对象
和切面
的概念。后面我们还将讨论引入
和切点
的概念,本书根据Spring Boot建议注解开发的特点,我们只讨论关于@AspectJ注解的方式实现AOP。
这里我们采用@AspectJ的注解方式
讨论AOP的开发。因为Spring AOP只能对方法进行拦截,所以首先要确定需要拦截什么方法,让它能织入约定的流程中。
任何AOP编程,首先要确定的是在什么地方需要AOP,也就是需要确定连接点(在Spring中就是什么类的什么方法)的问题。现在我们假设有一个UserService接口,它有一个printUser方法,如下述代码所示:
package com.springboot.chapter1.aspect.service;
import com.springboot.chapter1.pojo.User;
//用户服务接口
public interface UserService {
public void printUser(User user);
}
这样我们就可以给出它的一个实现类,如下代码所示:
package com.springboot.chapter1.aspect.service.impl;
/**** imports ****/
//用户服务接口实现类
@Service
public class UserServiceImpl implements UserService {
@Override
public void printUser(User user) {
if (user == null) {
throw new RuntimeException("检查用户参数是否为空......");
}
System.out.print("id =" + user.getId());
System.out.print("\tusername =" + user.getUsername());
System.out.println("\tnote =" + user.getNote());
}
}
这样一个普通的服务的接口和实现类就实现了。下面我们将以printUser方法为连接点,进行AOP编程。
有了连接点,我们还需要一个切面,通过它可以描述AOP其他的信息,用以描述流程的织入。下面我们来创建一个切面类,如下代码所示:
ppackage com.springboot.chapter1.aspect;
/**** imports ****/
//定义切面
@Aspect
public class MyAspect {
@Before("execution(* com.springboot.chapter1.aspect.service.impl.UserServiceImpl.printUser(..))")
public void before() {
System.out.println("before ......");
}
@After("execution(* com.springboot.chapter1.aspect.service.impl.UserServiceImpl.printUser(..))")
public void after() {
System.out.println("after ......");
}
@AfterReturning("execution(* com.springboot.chapter1.aspect.service.impl.UserServiceImpl.printUser(..))")
public void afterReturning() {
System.out.println("afterReturning ......");
}
@AfterThrowing("execution(* com.springboot.chapter1.aspect.service.impl.UserServiceImpl.printUser(..))")
public void afterThrowing() {
System.out.println("afterThrowing ......");
}
}
首先Spring是以@Aspect作为切面
声明的,当以@Aspect
作为注解时,Spring就会知道这是一个切面,然后我们就可以通过各类注解来定义各类的通知了。正如代码当中的@Before
、@After
、@AfterReturning
和@AfterThrowing
等几个注解,通过我们之前AOP概念和流程的介绍,相信大家也知道它们就是定义流程中的方法,然后即将由Spring AOP将其织入约定的流程中,只是这里我们还没有讨论它们的配置内容,尤其是它们里面的正则式,这是切点需要讨论的问题。而且,上述我们还没有讨论环绕通知的内容。因为环绕通知是最强大的通知,还要涉及别的内容讨论,所以后面会以单独的小节去讨论它。下面我们先来讨论切点的问题。
在上述切面的定义中,我们看到了@Before
、@After
、@AfterReturning
和@AfterThrowing
等注解,它们还会定义一个正则式,这个正则式的作用就是定义什么时候启用AOP,毕竟不是所有的功能都是需要启用AOP的,也就是Spring会通过这个正则式去匹配、去确定对应的方法(连接点)是否启用切面编程,但是我们在上一个代码中可以看到每一个注解都重复写了同一个正则式,这显然比较冗余。为了克服这个问题,Spring定义了切点(Pointcut)的概念,切点的作用就是向Spring描述哪些类的哪些方法需要启用AOP编程。有了切点的概念,就可以把上一段代码修改为下面代码的样子,从而把冗余的正则式定义排除在外:
package com.springboot.chapter1.aspect;
/**** imports ****/
//定义切点
@Aspect
public class MyAspect {
@Pointcut("execution(* com.springboot.chapter1.aspect.service.impl.UserServiceImpl.printUser(..))")
public void pointCut() {
}
@Before("pointCut()")
public void before() {
System.out.println("before ......");
}
@After("pointCut()")
public void after() {
System.out.println("after ......");
}
@AfterReturning("pointCut()")
public void afterReturning() {
System.out.println("afterReturning ......");
}
@AfterThrowing("pointCut()")
public void afterThrowing() {
System.out.println("afterThrowing ......");
}
}
代码中,使用了注解@Pointcut来定义切点
,它标注在方法pointCut上,则在后面的通知注解中就可以使用方法名称来定义了。
此时我们对这个正则式做进一步的分析,首先我们来看下面的正则式:
execution(* com.springboot.chapter1.aspect.service.impl.UserServiceImpl.printUser(..))
其中:
execution
表示在执行的时候,拦截里面的正则匹配的方法;
com.springboot.chapter1.aspect.service.impl.UserServiceImpl
指定目标对象的全限定名称;printUser
指定目标对象的方法;(..)
表示任意参数进行匹配。这样Spring就可以通过这个正则式知道你需要对类UserServiceImpl的printUser方法
进行AOP增强,它就会将正则式匹配的方法和对应切面的方法织入约定流程当中,从而完成AOP编程。对于这个正则式而言,它还可以使用AspectJ的指示器。下面我们稍微讨论一下它们,如表4-1所示。
AspectJ关于Spring AOP切点的指示器
例如,上述服务类对象在Spring IoC容器的名称为userServiceImpl
,而我们只想让这个类的printUser方法
织入AOP的流程,那么我们可以做如下限定:
execution(* com.springboot.chapter1.*.*.*.*. printUser(..) && bean('userServiceImpl')
bean中定义的字符串代表对Spring Bean名称的限定,这样就限定具体的类了。
上面完成了连接点、切面和切点等定义,接下来我们可以进行测试AOP,为此需要先搭建一个Web开发环境,开发一个用户控制器(UserController
),如下所示:
package com.springboot.chapter1.aspect.controller;
/**** imports ****/
// 定义控制器
@Controller
// 定义类请求路径
@RequestMapping("/user")
public class UserController {
// 注入用户服务
@Autowired
private UserService userService = null;
// 定义请求
@RequestMapping("/print")
// 转换为JSON
@ResponseBody
public User printUser(Long id, String userName, String note) {
User user = new User();
user.setId(id);
user.setUsername(userName);
user.setNote(note);
userService.printUser(user);// 若user=null,则执行afterthrowing方法
return user;// 加入断点
这里,通过自动注入UserService服务接口,然后使用它进行用户信息打印,因为方法标注了@ResponseBody
,所以最后Spring MVC会将其转换为JSON响应请求。这里的UserService的实现类
正是满足了切点的定义,因此Spring AOP会将其织入对应的流程中,这是我们本节所关注的内容。然后我们配置Spring Boot的配置文件,使其能够运行,如下所示。
package com.springboot.chapter1.main;
//Spring Boot配置启动文件
/**** imports ****/
// 指定扫描包
@SpringBootApplication(scanBasePackages = {"com.springboot.chapter1.aspect"})
public class Chapter1Application {
// 定义切面
@Bean(name="myAspect")
public MyAspect initMyAspect() {
return new MyAspect();
}
// 启动切面
public static void main(String[] args) {
SpringApplication.run(Chapter1Application.class, args);
}
}
调试运行这段代码,打开浏览器,等待服务器启动完成后,在UserController
中注释加入断点的地方加入断点,然后再打开浏览器,最后在地址栏输入请求地址http://localhost:8080/user/print?id=1&userName= user_name_1¬e=2323
,就可以看到请求进入了断点中,如下图所示。
从图中的调试监控可以看到userService对象,实际上是一个JDK动态代理对象,它代理了目标对象UserServiceImpl,通过这些Spring会将我们定义的内容织入AOP的流程中,这样我们的AOP就成功运行了。与此同时,也可以看到后台打出的日志:
before ......
id =1 username =user_name_1 note =2323
after ......
afterReturning ......
显然这就是Spring与我们约定的流程,从打印的日志来看,我们测试成功了,也就是说Spring已经通过动态代理技术帮助把我们所定义的切面和服务方法织入约定的流程中了。如果我们把控制器(UserController
)中的user对象
设置为null
,那么它将抛出异常,这个时候将会执行异常通知(afterThrowing
)而不会再执行返回通知(afterReturning
),但是无论如何它都会执行after方法。下面是设置用户对象为空时进行打印得到的测试日志:
before ......
after ......
afterThrowing ......
2022-03-18 09:37:23.897 ERROR 35596 --- [nio-8080-exec-1]
o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in
context with path [] threw exception [Request processing failed; nested exception is
java.lang.RuntimeException: 检查用户参数是否为空......] with root cause
java.lang.RuntimeException: 检查用户参数是否为空......
......
可以看到,无论是否发生异常,后置通知(after)都会被流程执行;而因为发生了异常,所以按照约定,异常通知(afterThrowing)会被触发,返回通知(afterReturning)则不会被触发。这都是Spring AOP与我们约定的流程。
环绕通知(Around)是所有通知中最为强大的通知,强大也意味着难以控制。一般而言,使用它的场景是在你需要大幅度修改原有目标对象的服务逻辑时,否则都尽量使用其他的通知。环绕通知是一个取代原有目标对象方法的通知,当然它也提供了回调原有目标对象方法的能力。我们首先在切面MyAspect代码
中加入环绕通知,如下所示:
@Around("pointCut()")
public void around(ProceedingJoinPoint jp) throws Throwable {
System.out.println("around before......");
//回调目标对象的原有方法
jp.proceed();
System.out.println("around after......");
}
这样我们就加入了一个环绕通知,并且在它之前和之后都加入了我们自己的打印内容,而它拥有一个ProceedingJoinPoint类型的参数
。这个参数的对象有一个proceed方法,通过这个方法可以回调原有目标对象的方法。然后我们可以在
jp.proceed();
这行代码加入断点进行调试,通过调试启动SpringBoot服务,在浏览器地址栏输入请求地址http://localhost:8080/user/print?id=1&userName=user_name_1¬e=2323
,这样就可以来到断点了:
从监控的信息可以看到对于环绕通知的参数(jp),它是一个被Spring封装过的对象,但是我们可以明显地看到它里面的属性,带有原有目标对象的信息,这样就可以通过它的proceed方法回调原有目标对象的方法。测试的日志如下:
around before......
before ......
id =1 username =user_name_1 note =2323
around after......
after ......
afterReturning ......
注意,这个结果是真实测试的结果,但却不是期待的结果,因为在期待的结果中日志的顺序应该如下:
before ......
around before......
id =1 username =user_name_1 note =2323
around after......
after ......
afterReturning ......
这里测试的Spring版本为4.3.9
,作者使用XML测试Spring AOP的结果时,是能够得到期待的结果的,但用注解测试的时候总是在顺序上出现这样的出入,估计是Spring版本之间的差异留下的问题,这是在使用时需要注意的。所以在没有必要的时候,应尽量不要使用环绕通知,正如前面说过的,它很强大,但是也很危险。
在测试AOP的时候,我们打印了用户信息,如果用户信息为空,则抛出异常。事实上,我们还可以检测用户信息是否为空,如果为空则不再打印,这样就没有异常产生了。但现有的UserService接口
并没有提供这样的功能,这里假定UserService
这个服务并不是自己所提供,而是别人提供的,我们不能修改它,这时Spring还允许增强这个接口的功能,我们可以为这个接口引入新的接口,例如,要引入一个用户检测的接口UserValidator
,其定义如代码所示:
package com.springboot.chapter1.aspect.validator;
import com.springboot.chapter1.pojo.User;
public interface UserValidator {
// 检测用户对象是否为空
public boolean validate(User user);
}
很快我们可以给出它的实现类,这个实现类也十分简单,如下代码所示:
package com.springboot.chapter1.aspect.validator.impl;
/**** imports ****/
public class UserValidatorImpl implements UserValidator {
@Override
public boolean validate(User user) {
System.out.println("引入新的接口:" + UserValidator.class.getSimpleName());
return user != null;
}
}
这样,我们通过Spring AOP引入的定义就能够增强UserService接口
的功能,这个时候在切面MyAspect代码
加入以下代码:
......
@Aspect
public class MyAspect {
@DeclareParents(
value= "com.springboot.chapter1.aspect.service.impl.UserServiceImpl+",
defaultImpl=UserValidatorImpl.class)
public UserValidator userValidator;
这里我们看到了一个注解@DeclareParents
,它的作用是引入新的类来增强服务,它有两个必须配置的属性value
和defaultImpl
。
UserServiceImpl
对象,因此可以看到配置为com.springboot.chapter1.aspect.service.impl.UserServiceImpl+
。UserValidatorImpl
,用来提供校验用户是否为空的功能。上述的通知中,我们没有给通知传递参数。有时候我们希望能够传递参数给通知,我们只需要在切点处加入对应的正则式就可以了。当然,对于非环绕通知还可以使用一个连接点(JoinPoint)类型的参数,通过它也可以获取参数。我们在代码清单4-13中加入代码清单4-21所示的代码片段。
@Before("pointCut()&& args(user)")
public void beforeParam(JoinPoint point,User user){
Object[] args = point.getArgs();
System.out.println("before ......");
}
pointCut()
表示启用原来定义切点的规则,并且约定将连接点(目标对象方法)名称为user的参数传递进来
。这里要注意,JoinPoint类型
的参数对于非环绕通知而言,Spring AOP会自动地把它传递到通知中;对于环绕通知而言,可以使用ProceedingJoinPoint类型
的参数。