{T}

面向切面编程

AOP 的概念

面向切面编程是一种通过横切关注点(Cross-cutting Concerns)分离来增强代码模块性的方法,它能够在不修改业务主体代码的情况下,对它添加额外的行为

AOP 的目标是增强代码模块性,也就是说本质上它是一种“解耦”的方法,在这方面它和之前介绍的分层等方法是类似的,但是它分离代码的角度与传统、自然的模块设计思路截然不同。

来看这样一个例子,对于图书馆系统来说,有许多业务流程,其中借书和还书是最典型的两条。对于这些业务流程来说,从图书系统接收到请求开始,需要完成若干个步骤,但这些步骤都有一些“共性”,比如鉴权,比如事务控制:

image-20240520162403932

如果按照自然的思考方式,会把代码按照流程分解成一个一个的步骤,在每个步骤完成的前后添加这些“共性”逻辑。但是这些逻辑就会散落在代码各处了,即便把它们按照重复代码抽取的原则,抽出来放到单独的方法中,这样的方法的“调用”还是散落在各处,无论是对软件工程上的可维护性,还是代码阅读时对于业务流程的专注度,都是不利的。

藉由 AOP 则可以有效地解决这些问题,对于图中横向的业务流程,能够保持它们独立不变,而把鉴权、事务这样的公共功能,彻底拿出去,放到单独的地方,这样整个业务流程就变得纯粹和干净,没有任何代码残留的痕迹。但是功能却没有任何丢失。就好比面条一般顺下来的业务流程,水平地切了几刀,每一刀都是一个 AOP 的功能实现。

在 Java 的世界中谈论 AOP 比较多,但它并不是 Java 范畴的概念,它不依赖于任何框架,也和编程语言本身无关

Spring 中的应用

Spring 作为一个应用程序框架,提供了对于 AOP 功能上完整的支持,以图书借出的方法为例

java
public class BookService {
  public Book lendOut(String bookId, String userId, Date date) { ... (0) }
}

现在要给很多的业务方法以 AOP 的方式添加功能,而 lendOut 就是其中之一。定义一个 TransactionAspect 类:

java
public class TransactionAspect {
  public void doBefore(JoinPoint jp) { ... (1) }
  public void doAfter(JoinPoint jp) { ... (2) }
  public void doThrowing(JoinPoint jp, Throwable ex) { ... (3) }
  public void doAround(ProceedingJoinPoint pjp) throws Throwable {
    ... (4)
      pjp.proceed();
    ... (5)
  }
}

希望在 doBefore 方法中添加事务开始逻辑,doAfter 方法中添加事务结束的提交逻辑,doThrowing 方法中添加事务失败的回滚逻辑,而在 doAround 方法中业务执行前后添加日志打印逻辑,其中的 pjp.proceed() 方法表示对原方法的调用。

接着需要写一些 XML 配置,目的就是把原方法和 AOP 的切面功能连接起来。配置片段如下:

xml
<bean id="bookService" class="xxx.BookService"></bean>
<bean id="transactionAspect" class="xxx.TransactionAspect"></bean>

<aop:config>
  <aop:pointcut expression="execution(* xxx.BookService.*(..))" id="transactionPointcut"/>
  <aop:aspect ref="transactionAspect">
    <aop:before method="doBefore" pointcut-ref="transactionPointcut"/>
    <aop:after-returning method="doAfter" pointcut-ref="transactionPointcut"/>
    <aop:after-throwing method="doThrowing" pointcut-ref="transactionPointcut" throwing="ex"/>
    <aop:around method="doAround" pointcut-ref="transactionPointcut"/>
  </aop:aspect>
</aop:config>

前两行分别是对 BookService 和 TransactionAspect 这两个 Bean 的声明,在 aop:config 中定义了 pointcut 的切面匹配表达式,表示要捕获 BookService 的所有方法,并在 aop:aspect 标签内定义了希望实施的 AOP 功能。

在实际执行的过程中,如果没有异常抛出,上述这些逻辑的执行顺序将是:(1) → (4) → (0) → (5) → (2)

实现原理

对于常见的实现根据其作用的不同时间阶段进行分类,有这样两种:

  • 编译期间的静态织入,又称为编译时增强。 织入(Weaving)指的是将切面代码和源业务代码链接起来的过程。AspectJ 就是这样一个面向切面的 Java 语言扩展,称呼其为语言的“扩展”,就是因为它扩展了 Java 语言的语法,需要特定的编译器来把 AspectJ 的代码编译成 JVM 可识别的 class 文件
  • 运行期间的动态代理,又称为运行时增强。 这种方式是在程序运行时,依靠预先创建或运行时创建的代理类来完成切面功能的。比如 JDK 基于接口的动态代理技术,或 CGLib 基于类的代理对象生成技术就属于这一种

Spring AOP 默认支持的是后者——运行期间的动态代理。至于具体实现,通常来说应该优先考虑使用 JDK 的动态代理技术;但是如果目标类没有实现接口,我们只能退而求其次,使用 CGLib

动态代理的方式由于在运行时完成代理类或代理对象的创建,需要用到 Java 的拦截、反射和字节码生成等技术,因此运行时的性能表现往往没有静态织入好,功能也有较多限制,但是由于使用起来简便(不需要语言扩展,不需要特殊的编译器等),它的实际应用更为广泛

控制反转 IoC

通过 AOP 知道,某些问题如果换个角度来解决,会很大程度地简化代码。现在来了解在 Spring 中另一个经常和面向切面编程一起出现的概念——控制反转。控制反转是一种设计思想,也是通过“换个角度”来解决问题的

控制反转 IoC (Inversion of Control) 指的是把原有的控制方向掉转过来了。在常规的程序流程中,对象是由主程序流程创建的,例如在业务流程中使用 new 关键字来创建依赖对象

但是使用 Spring 框架的时候,Spring 把对象创建的工作接管过来,它作为对象容器,来负责对象的查找、匹配、创建、装配,依赖管理,等等。而主程序流程,则不用关心对象是怎么来的,只需要使用对象就可以了

java
public class BookService {
  @Autowired
  private BookDao bookDao;
  @Autowired
  private LoanDao loanDao;
  
  public Book lendOut(String bookId, String userId, Date date) {
    bookDao.update( ... );
    loanDao.insert( ... );
  }
}

通过 @Autowired 注解,让容器将实际的数据访问对象注入进来,主程序流程不用关心“下一层”的数据访问对象到底是怎么创建的,怎么初始化的,甚至是怎么注入进来的,而是直接用就可以了,因为这些对象都已经被 Spring 管理起来了

如果这些注入的对象之间还存在依赖关系,初始化它们的顺序就至关重要了,可是在这种情况下,Service 层依然不用关心,因为 Spring 已经根据代码或配置中声明的依赖关系自动确定了

AOP 和 IoC 似乎有一个共同的特点:都是为了尽可能保证主流程的纯粹和简洁,而将这些不影响主流程的逻辑拿出去,只不过这两种技术,“拿出去”的是不同的逻辑。值得注意的是,对象之间的依赖关系,各层之间的依赖关系,并没有因为 IoC 而发生任何的改变

Ioc 实现

IoC 在实现上包含两种方式,一种叫做依赖查找(DL,Dependency Lookup),另一种叫做依赖注入(DI,Dependency Injection)。 二者缺一不可,Spring 容器做到了两者,就如同上面的例子,容器需要先查找到 bookDao 和 loanDao 所对应的对象,再把它们注入进来

IoC 带来的好处:

  • 资源统一配置管理。 这个方面很好,但并不是 IoC 最大的优势,因为,如果你不把资源交给容器管理,而是自己建立一个资源管理类来管理某项资源,一样可以得到“统一管理”的所有优势。
  • 业务代码不再包含依赖资源的访问逻辑,因此资源访问和业务流程的代码解耦开了。 我觉得这里的“解耦”才是 IoC 最核心的优势,它让各层之间的依赖关系变得松散

选修课堂:实践 AOP 的运行时动态代理

AOP 的实现原理中一种办法是通过 JDK 的动态代理技术来实现的。现在就来写一点代码,用它实现一个小例子。

现在建立 BookService.java,把 BookService 定义为一个接口,包含 lendOut 方法,同时也创建它的实现 BookServiceImpl:

java
import java.text.MessageFormat;
import java.util.Date;

interface BookService {
  void lendOut(String bookId, String userId, Date date);
}

class BookServiceImpl implements BookService {
  @Override
  public void lendOut(String bookId, String userId, Date date) {
    System.out.println(MessageFormat.format("{0}: The book {1} is lent to {2}.", date, bookId, userId));
  }
}

然后建立一个 ServiceInvocationHandler.java,在这里可以定义代理对象在对原对象的方法调用前后,添加的额外逻辑:

java
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

class ServiceInvocationHandler implements InvocationHandler {
  private Object target;

  public ServiceInvocationHandler(Object target) {
    this.target = target;
  }

  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    System.out.println("Before...");
    Object result = method.invoke(this.target, args);
    System.out.println("After...");
    return result;
  }
}

接着建立一个 Client.java 类,作为程序的起点,通过动态代理的方式来调用源代码中的 lendOut 方法:

java
import java.lang.reflect.Proxy;
import java.util.Date;

public class Client {
    public static void main(String[] args) throws Exception {
        BookService bookService = (BookService) Proxy.newProxyInstance(
            BookService.class.getClassLoader(),
            new Class[]{ BookService.class },
            new ServiceInvocationHandler(new BookServiceImpl())
        );
        bookService.lendOut("123", "456", new Date());
    }
}

创建了一个动态代理对象,并赋给 bookService,这个代理对象实际是会调用 BookServiceImpl 的,但调用的前后打印了额外的日志。并且,这个代理对象也实现自 BookService 接口,因此对于 BookService 的使用者来说,它实际并不知道调用到的是 BookServiceImpl 还是它的代理对象。请看图示:

image-20240520174346883

现在把这些代码编译一下,应该能看到它们的 class 文件分别生成了

java
javac BookService.java ServiceInvocationHandler.java Client.java

最后,执行 Client 的 main 方法,就能看到相应的执行结果,它显示 lendBook 方法前后的 AOP 的逻辑被实际执行了:

java
java Client
Before...
8/10/19 11:42 AM: The book 123 is lent to 456.
After...