1.1 Spring容器

本节讨论Spring容器,并给出容器所具备的非常重要的两个功能特性,即依赖注入和面向切面编程。

1.1.1 IoC

在介绍Spring容器之前,我们先来介绍一个概念,即控制反转(Inversion of Control,IoC)。试想,如果想有效管理一个对象,就需要知道创建、使用以及销毁这个对象的方法。这个过程显然是繁杂而重复的。而通过控制反转,就可以把这部分工作交给一个容器,由容器负责控制对象的生命周期和对象之间的关联关系。这样,与一个对象控制其他对象的处理方式相比,现在所有对象都被容器控制,控制的方向做了一次反转,这就是“控制反转”这一名称的由来。而Spring扮演的角色就是这里的容器。

可以看到控制反转的重点是在系统运行中,按照某个对象的需要,动态提供它所依赖的其他对象,而这一点可以通过依赖注入(Dependency Injection,DI)实现。Spring会在适当的时候创建一个Bean,然后像使用注射器一样把它注入目标对象中,这样就完成了对各个对象之间关系的控制。

可以说,依赖注入是开发人员使用Spring框架的基本手段,我们可以通过依赖注入获取所需的各种Bean。Spring为开发人员提供了3种不同的依赖注入方式,分别是字段注入、构造器注入和Setter方法注入。

现在,假设我们有如下所示的HealthRecordService接口以及它的实现类:

public interface HealthRecordService {
 
     public void recordUserHealthData();
}
public class HealthRecordServiceImpl implements HealthRecordService {
     @Override
     public void recordUserHealthData () {
          System.out.println("HealthRecordService has been called.");
     }
}

下面我们来讨论具体如何在Spring中完成对HealthRecordServiceImpl实现类的注入,并分析各种注入类型的优缺点。

1.依赖注入的3种方式

首先,我们来看看字段注入,即在一个类中通过字段的方式注入某个对象,如下所示:

public class ClientService {
     @Autowired
     private HealthRecordService healthRecordService;
     public void recordUserHealthData() {
          healthRecordService.recordUserHealthData();
     }
}

可以看到,通过@Autowired注解,字段注入的实现方式非常简单而直接,代码的可读性也很高。事实上,字段注入是3种依赖注入方式中最常用、最容易使用的一种。但是,它也是3种注入方式中最应该避免使用的一种。如果使用过IDEA,你可能遇到过这个提示—Field injection is not recommended,告诉你不建议使用字段注入。字段注入的最大问题是对象在外部是不可见的。正如在上面的ClientService类中,我们定义了一个私有变量HealthRecordService来注入该接口的实例。显然,这个实例只能在ClientService类中被访问,脱离了容器环境就无法访问这个实例。

基于以上分析,Spring官方推荐的注入方式实际上是构造器注入。这种注入方式也很简单,就是通过类的构造函数来完成对象的注入,如下所示:

public class ClientService {
     private HealthRecordService healthRecordService;
     @Autowired
     public ClientService(HealthRecordService healthRecordService) {
          this.healthRecordService = healthRecordService;
     }
     public void recordUserHealthData() {
          healthRecordService.recordUserHealthData();
     }
}

可以看到构造器注入能解决对象外部可见性的问题,因为HealthRecordService是通过ClientService构造函数进行注入的,所以势必可以脱离ClientService而独立存在。构造器注入的显著问题就是当构造函数中存在较多依赖对象时,大量的构造器参数会让代码显得比较冗长。这时就可以使用Setter方法注入。我们同样先来看一下Setter方法注入的实现代码,如下所示:

public class ClientService {
     private HealthRecordService healthRecordService;
     @Autowired
     public void setHealthRecordService(HealthRecordService healthRecordService) {
          this.healthRecordService = healthRecordService;
     }
     public void recordUserHealthData() {
          healthRecordService.recordUserHealthData();
     }
}

Setter方法注入和构造器注入看上去有些类似,但Setter方法比构造函数更具可读性,因为我们可以把多个依赖对象分别通过Setter方法逐一进行注入。而且,Setter方法注入对于非强制依赖注入很有用,我们可以有选择地注入一部分想要注入的依赖对象。换句话说,可以实现按需注入,帮助开发人员只在需要时注入依赖关系。

作为总结,我们用一句话来概括Spring中所提供的3种依赖注入方式:构造器注入适用于强制对象注入;Setter方法注入适用于可选对象注入;而字段注入是应该避免的,因为对象无法脱离容器而独立运行。

2.Bean的作用域

所谓Bean的作用域,描述了Bean在Spring容器上下文中的生命周期和可见性。在这里,我们将讨论Spring框架中不同类型的Bean的作用域以及使用上的指导规则。

如果想要通过注解来设置Bean的作用域,可以使用如下所示的代码:

@Configuration
public class AppConfig {
     @Bean
     @Scope("singleton")
     public HealthRecordService createHealthRecordService() {
          return new HealthRecordServiceImpl();
     }
}

可以看到这里使用了一个@Scope注解来指定Bean的作用域为单例的“singleton”。在Spring中,除了单例作用域之外,还有一个“prototype”,即原型作用域,也可以称为多例作用域来与单例作用域进行区别。在使用方式上,我们同样可以使用如下所示的枚举值来对它们进行设置:

@Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON)
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)

在Spring IoC容器中,Bean的默认作用域是单例作用域,也就是说不管对Bean的引用有多少个,容器只会创建一个实例。而原型作用域则不同,每次请求Bean时,Spring IoC容器都会创建一个新的对象实例。

从两种作用域的效果而言,我们总结一条开发上的结论,即对于无状态的Bean,我们应该使用单例作用域,反之则应该使用原型作用域。

那么,什么样的Bean属于有状态的呢?结合Web应用程序,我们可以明确,对每次HTTP请求而言,都应该创建一个Bean来代表这一次的请求对象。同样,对会话而言,我们也需要针对每个会话创建一个会话状态对象。这些都是常见的有状态的Bean。为了更好地管理这些Bean的生命周期,Spring还专门针对Web开发场景提供了对应的“request”和“session”作用域。

1.1.2 AOP

在本小节中,我们将讨论Spring容器的另一项核心功能,即面向切面编程(Aspect Oriented Programming,AOP)。我们将介绍AOP的概念以及实现这些概念的方法。

所谓切面,本质上解决的是关注点分离的问题。在面向对象编程的世界中,我们把一个应用程序按照职责和定位拆分成多个对象,这些对象构成了不同的层次。而AOP可以说是面向对象编程的一种补充,目标是将一个应用程序抽象成各个切面。

举个例子,假设一个Web应用中存在ServiceA、ServiceB和ServiceC这3个服务,而每个服务都需要考虑安全校验、日志记录、事务处理等非功能性需求。这时,就可以引入AOP的思想把这些非功能性需求从业务需求中拆分出来,构成独立的关注点,如图1-1所示。

图1-1 AOP的思想示意

从图1-1可以很形象地看出,所谓切面相当于应用对象间的横切面,我们可以将其抽象为单独的模块进行开发和维护。

为了理解AOP的具体实现过程,我们需要引入一组特定的术语,具体如下。

连接点(Join Point):连接点表示应用执行过程中能够插入切面的一个点。这种连接点可以是方法调用、异常处理、类初始化或对象实例化。在Spring框架中,连接点只支持方法的调用。

通知(Advice):通知描述了切面何时执行以及如何执行对应的业务逻辑。通知有很多种类型,在Spring中提供了一组注解用来表示通知,包括@Before、@After、@Around、@AfterThrowing和@AfterReturning等。我们会在后续代码示例中看到这些注解的使用方法。

切点(Point Cut):切点是连接点的集合,用于定义必须执行的通知。通知不一定应用于所有连接点,因此切点提供了在应用程序中的组件上执行通知的细粒度控制。在Spring中,可以通过表达式来定义切点。

切面(Aspect):切面是通知和切点的组合,用于定义应用程序中的业务逻辑及其应执行的位置。Spring提供了@Aspect注解来定义切面。

现在,假设有这样一个代表转账操作的TransferService接口:

public interface TransferService {
 
     boolean transfer(Account source, Account dest, int amount) throws MinimumAmountException;
}

然后我们提供它的实现类:

package com.demo;
 
public class TransferServiceImpl implements TransferService {
 
     private static final Logger LOGGER = Logger.getLogger(TransferServiceImpl.class);
 
     @Override
     public boolean transfer(Account source, Account dest, int amount) throws MinimumAmountException {
          LOGGER.info("Tranfering " + amount + " from " + source.getAccountName() + " to " + dest.getAccountName());
 
          if (amount < 10) {
               throw new MinimumAmountException("转账金额必须大于10");
          }
          return true;
     }
}

针对转账操作,我们希望在该操作之前、之后以及执行过程进行切入,并添加对应的日志记录,那么可以实现如下所示的TransferServiceAspect类:

@Aspect
public class TransferServiceAspect {
 
     private static final Logger LOGGER = Logger.getLogger(TransferServiceAspect.class);
 
     @Pointcut("execution(* com.demo.TransferService.transfer(..))")
     public void transfer() {}
 
     @Before("transfer()")
     public void beforeTransfer(JoinPoint joinPoint) {
          LOGGER.info("在转账之前执行");
     }
 
     @After("transfer()")
     public void afterTransfer(JoinPoint joinPoint) {
          LOGGER.info("在转账之后执行");
     }
 
     @AfterReturning(pointcut = "transfer() and args(source, dest, amount)", returning = "isTransferSucessful")
     public void afterTransferReturns(JoinPoint joinPoint, Account source, Account dest, Double amount, boolean isTransferSucessful) {
          if (isTransferSucessful) {
               LOGGER.info("转账成功了");
          }
     }
     @AfterThrowing(pointcut = "transfer()", throwing = "minimumAmountException")
     public void exceptionFromTransfer(JoinPoint joinPoint, MinimumAmountException minimumAmountException) {
          LOGGER.info("转账失败了:" + minimumAmountException.getMessage());
     }
     @Around("transfer()")
     public boolean aroundTransfer(ProceedingJoinPoint proceedingJoinPoint){
          LOGGER.info("方法执行之前调用");
          boolean isTransferSuccessful = false;
          try {
               isTransferSuccessful = (Boolean)proceedingJoinPoint.proceed();
          } catch (Throwable e) {
               LOGGER.error(e.getMessage(), e);
          }
          LOGGER.info("方法执行之后调用");
          return isTransferSuccessful;
     }
}

上述代码代表了Spring AOP机制的典型使用方法。使用@Pointcut注解定义了一个切入点,并通过“execution”指示器限定该切入点匹配的包结构为“com.demo”,匹配的方法是TransferService类的transfer()方法。

请注意,在TransferServiceAspect中综合使用了@Before、@After、@Around、@AfterThrowing和@AfterReturning注解用来设置5种不同类型的通知。其中@Around注解会将目标方法封装起来,并执行动态添加返回值、异常信息等操作。这样@AfterThrowing和@AfterReturning注解就能获取这些返回值或异常信息并做出响应,而@Before和@After注解可以在方法调用的前后分别添加自定义的处理逻辑。