极客时间已完结课程限时免费阅读

11 | 剑走偏锋:面向切面编程

11 | 剑走偏锋:面向切面编程-极客时间

11 | 剑走偏锋:面向切面编程

讲述:四火

时长17:00大小13.64M

你好,我是四火。
今天我们要接触一个和 MVC 密切相关的,能带来思维模式改变的编程范型——面向切面编程(AOP,Aspect Oriented Programming)。

“给我一把锤子,满世界都是钉子”

我记得曾经有这样一个相当流行的观点,是说,编程语言只需要学习一门就够了,学那么多也没有用,因为技术是一通百通的,别的编程语言可以说是大同小异。我相信至今抱有这种观点的程序员也不在少数。
可惜,事实远没有那么美好。这个观点主要有两处值得商榷:
其一,不同的技术,在一定程度上确实是相通的,可是,技术之间的关联性,远不是“一通百通”这四个简简单单的字能够解释的。妄想仅仅凭借精通一门编程语言,就能够自动打通其它所有编程语言的任督二脉,这是不现实的。
其二,通常来说,说编程语言大同小异其实是很不客观的。编程语言经过了长时间的发展演化,如今已经发展出非常多的类型,用作编程语言分类标准之一的编程范型也可谓是百花齐放。
因此我们要学习多种编程语言,特别是那些能带来新的思维模式的编程语言。现在,把这个观点泛化到普遍的软件技术上,也一样适用。我们都知道要“一切从实际出发”,都知道要“具体问题具体分析”,可是,在眼界还不够开阔的时候,特别是职业生涯的早期,程序员在武器库里的武器还非常有限的时候,依然无法避免“给我一把锤子,满世界都是钉子”,在技术选择的时候眼光相对局限。
所以我们要学习全栈技术,尤其是要学习这些不一样,但一定层面上和已掌握知识相通的典型技术。今天我们要学习的这项在 MVC 框架中广泛使用的技术,是和面向对象编程一类层面的编程范型,叫做面向切面编程。
互联网有许多功能,如果使用传统的基于单个请求处理流程的方式来编码,代码就会非常繁琐,而使用 AOP 的方式,代码可以得到很大程度上的简化。希望通过今天的学习,你的武器库里,能够多一把重型机枪。

AOP 的概念

面向切面编程是一种通过横切关注点(Cross-cutting Concerns)分离来增强代码模块性的方法,它能够在不修改业务主体代码的情况下,对它添加额外的行为。
不好理解吗?没关系,我们来对它做进一步的说明。
首先需要明确的是,AOP 的目标是增强代码模块性,也就是说,本质上它是一种“解耦”的方法,在这方面它和我们之前介绍的分层等方法是类似的,可是,它分离代码的角度与我们传统、自然的模块设计思路截然不同。
我们来看下面这样一个例子,对于图书馆系统来说,有许多业务流程,其中借书和还书是最典型的两条。对于这些业务流程来说,从图书系统接收到请求开始,需要完成若干个步骤,但这些步骤都有一些“共性”,比如鉴权,比如事务控制:
那么,如果我们按照自然的思考方式,我们会把代码按照流程分解成一个一个的步骤,在每个步骤完成的前后添加这些“共性”逻辑。可是这样,这些逻辑就会散落在代码各处了,即便我们把它们按照重复代码抽取的原则,抽出来放到单独的方法中,这样的方法的“调用”还是散落在各处,无论是对软件工程上的可维护性,还是代码阅读时对于业务流程的专注度,都是不利的。
藉由 AOP 则可以有效地解决这些问题,对于图中横向的业务流程,我们能够保持它们独立不变,而把鉴权、事务这样的公共功能,彻底拿出去,放到单独的地方,这样整个业务流程就变得纯粹和干净,没有任何代码残留的痕迹,就好像武林高手彻底隐形了一般,但是,功能却没有任何丢失。就好比面条一般顺下来的业务流程,水平地切了几刀,每一刀,都是一个 AOP 的功能实现。
我们可能在 Java 的世界中谈论 AOP 比较多,但请注意,它并不是 Java 范畴的概念,它不依赖于任何框架,也和编程语言本身无关。

Spring 中的应用

Spring 作为一个应用程序框架,提供了对于 AOP 功能上完整的支持,下面让我们通过例子来学习。还记得我们在 [第 08 讲] 中举例介绍的将图书借出的方法吗?
public class BookService {
public Book lendOut(String bookId, String userId, Date date) { ... (0) }
}
现在,我们要给很多的业务方法以 AOP 的方式添加功能,而 lendOut 就是其中之一。定义一个 TransactionAspect 类:
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 的切面功能连接起来。配置片段如下:
<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)

实现原理

讲了 AOP 怎样配置,怎么表现,现在我要来讲讲它的实现原理了。通过这部分内容,希望你可以搞清楚,为什么不需要对代码做任何改动,就可以在业务逻辑的流水中切一刀,插入我们想要执行的其它逻辑呢?
对于常见的实现,我们根据其作用的不同时间阶段进行分类,有这样两种:
编译期间的静态织入,又称为编译时增强。织入(Weaving),指的是将切面代码和源业务代码链接起来的过程。AspectJ 就是这样一个面向切面的 Java 语言扩展,称呼其为语言的“扩展”,就是因为它扩展了 Java 语言的语法,需要特定的编译器来把 AspectJ 的代码编译成 JVM 可识别的 class 文件。
运行期间的动态代理,又称为运行时增强。这种方式是在程序运行时,依靠预先创建或运行时创建的代理类来完成切面功能的。比如 JDK 基于接口的动态代理技术,或 CGLib 基于类的代理对象生成技术就属于这一种。
Spring AOP 默认支持的是后者——运行期间的动态代理。至于具体实现,通常来说,我们应该优先考虑使用 JDK 的动态代理技术;但是如果目标类没有实现接口,我们只能退而求其次,使用 CGLib。
动态代理的方式由于在运行时完成代理类或代理对象的创建,需要用到 Java 的拦截、反射和字节码生成等技术,因此运行时的性能表现往往没有静态织入好,功能也有较多限制,但是由于使用起来简便(不需要语言扩展,不需要特殊的编译器等),它的实际应用更为广泛。

控制反转 IoC

通过 AOP 我们知道,某些问题如果我们换个角度来解决,会很大程度地简化代码。现在,让我们来了解在 Spring 中另一个经常和面向切面编程一起出现的概念——控制反转。控制反转是一种设计思想,也是通过“换个角度”来解决问题的。
控制反转,IoC,即 Inversion of Control,言下之意,指的是把原有的控制方向掉转过来了。在我们常规的程序流程中,对象是由主程序流程创建的,例如,在业务流程中使用 new 关键字来创建依赖对象。
但是,当我们使用 Spring 框架的时候,Spring 把对象创建的工作接管过来,它作为对象容器,来负责对象的查找、匹配、创建、装配,依赖管理,等等。而主程序流程,则不用关心对象是怎么来的,只需要使用对象就可以了。我们还是拿 BookService 举例子:
public class BookService {
@Autowired
private BookDao bookDao;
@Autowired
private LoanDao loanDao;
public Book lendOut(String bookId, String userId, Date date) {
bookDao.update( ... );
loanDao.insert( ... );
}
}
比如 BookService 的借出方法,假如它的实现中,我们希望:
调用数据访问对象 bookDao 的方法来更新被借书的状态;
调用借阅行为的访问对象 loanDao 来增加一条借阅记录。
在这种情况下,我们可以通过 @Autowired 注解,让容器将实际的数据访问对象注入进来,主程序流程不用关心“下一层”的数据访问对象到底是怎么创建的,怎么初始化的,甚至是怎么注入进来的,而是直接用就可以了,因为这些对象都已经被 Spring 管理起来了。
如果这些注入的对象之间还存在依赖关系,初始化它们的顺序就至关重要了,可是在这种情况下,Service 层依然不用关心,因为 Spring 已经根据代码或配置中声明的依赖关系自动确定了。总之,Service 层的业务代码,只管调用其下的数据访问层的方法就好了。
读到这里,你可能会回想起前文 AOP 的内容,和 IoC 似乎有一个共同的特点:都是为了尽可能保证主流程的纯粹和简洁,而将这些不影响主流程的逻辑拿出去,只不过这两种技术,“拿出去”的是不同的逻辑。值得注意的是,对象之间的依赖关系,各层之间的依赖关系,并没有因为 IoC 而发生任何的改变。
IoC 在实现上包含两种方式,一种叫做依赖查找(DL,Dependency Lookup),另一种叫做依赖注入(DI,Dependency Injection)。二者缺一不可,Spring 容器做到了两者,就如同上面的例子,容器需要先查找到 bookDao 和 loanDao 所对应的对象,再把它们注入进来。当然,我们平时听到的更多是第二种。
有了一个大致的感受,那么 IoC 到底能带来什么好处呢?我觉得主要有这样两个方面:
资源统一配置管理。这个方面很好,但并不是 IoC 最大的优势,因为,如果你不把资源交给容器管理,而是自己建立一个资源管理类来管理某项资源,一样可以得到“统一管理”的所有优势。
业务代码不再包含依赖资源的访问逻辑,因此资源访问和业务流程的代码解耦开了。我觉得这里的“解耦”才是 IoC 最核心的优势,它让各层之间的依赖关系变得松散。就如同上面的代码例子一样,如果哪一天我想把它依赖的 bookDao 和 loanDao 替换掉(比如,我想为 Service 层做测试),Service 一行代码都不用改,它压根都不需要知道。

总结思考

今天我们一起学习了面向切面编程,从学习概念,熟悉配置,到了解实现原理,希望你对于 AOP 已经有了一个清晰的认识,在未来设计和开发系统的时候,无论技术怎样演进,框架怎么变化,始终知道什么时候需要它,并能够把它从你的武器库中拿出来使用。
现在我来提两个问题,我们一起讨论吧:
你过去的项目中有没有应用 AOP 的例子,能说说吗?
我介绍了 AOP 的优点,但却没有提到它的缺点,但其实任何技术都是有两面性的,你觉得 AOP 的缺点都有哪些呢?

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

我们学习了 AOP 的实现原理,知道其中一种办法是通过 JDK 的动态代理技术来实现的。现在,我们就来写一点代码,用它实现一个小例子。
首先,请你准备好一个项目文件夹,我们会在其中创建一系列文件。你可以使用 Eclipse 来管理项目,也可以自己建立一个独立的文件夹,这都没有关系。
现在建立 BookService.java,这次我们把 BookService 定义为一个接口,包含 lendOut 方法,同时也创建它的实现 BookServiceImpl:
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,在这里我们可以定义代理对象在对原对象的方法调用前后,添加的额外逻辑:
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 方法:
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 还是它的代理对象。请看图示:
好,现在我们把这些代码编译一下:
javac BookService.java ServiceInvocationHandler.java Client.java
你应该能看到它们的 class 文件分别生成了。
最后,执行 Client 的 main 方法,就能看到相应的执行结果,它显示 lendBook 方法前后的 AOP 的逻辑被实际执行了:
java Client
Before...
8/10/19 11:42 AM: The book 123 is lent to 456.
After...

扩展阅读

Spring 官方文档中关于 AOP 的教程,如果你希望看到中文版,那么互联网上有不少对于这部分的翻译,只不过对应的 Spring 版本不同,内容大致是一样的,比如这一篇
Comparing Spring AOP and AspectJ,这是一篇关于静态织入和动态代理这两种 AOP 方式比较的文章。
对于 AspectJ,如果想一瞥其扩展的语法语义,维基百科的词条就足矣;如果想了解某些细节,请参阅官方文档
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 5

提建议

上一篇
10 | MVC架构解析:控制器(Controller)篇
下一篇
12 | 唯有套路得人心:谈谈Java EE的那些模式
unpreview
 写留言

精选留言(10)

  • 不记年
    2019-10-11
    要说AOP的缺点的话,代码会变得不直观,需要脑补运行过程。不易调试,因为代码变得不直观,断点有时都不知道打在哪里。如果多层嵌套的话,上面的问题会加倍~

    作者回复: 👍

    7
  • Kevin·程
    2020-04-04
    远离代码开发工作10来年年了,利用以前的积累,这节课听了两遍,总算对mvc和aop、ioc有所感觉了,老师的讲解和分析很到位,很清晰,让我这样的也能搞明白。但是细细想来,所谓万变不离其宗,有根基才能了解上层的东西。对于spring的ioc,实施DI应该是提前完成底层资源对象的初始化工作,节省运行时调用的时间开销,简化主代码逻辑。这也需要丰厚的内存资源的支撑吧。另外spring会把所有对象提前装载到内存中吗,还是基于一定策略进行部分对象装载呢?
    展开

    作者回复: 都有,对象的装载有几种方式,是不是“提前”装载还取决于lazyload的配置。

    2
  • 老沈
    2021-05-02
    前端部分的 生命周期。路由钩子等是否参考了aop的设计呢。

    作者回复: 从AOP的角度来理解确实是没问题的,但是最早是否参考了它的设计我就不清楚了。

  • Demon.Lee
    2021-03-19
    “因为Spring基于动态代理,所以Spring只支持方法连接点。这与一些其他的AOP框架是不同的,例如AspectJ和JBoss,除了方法切点,它们还提供了字段和构造器接入点。Spring缺少对字段连接点的支持,无法让我们创建细粒度的通知,例如拦截对象字段的修改。而且它不支持构造器连接点,我们就无法在bean创建时应用通知。” 摘录自《Spring 实战》第4版,想请问老师,为啥基于动态代理就只能支持方法连接点,而其他的(如AspectJ)则可以支持构造器和字段接入点,不是特别理解,谢谢。
    展开

    作者回复: 我的理解是,这和这两者的实现机制有关。动态代理它的实现本质上就是反射,字节码是不变的,因此这个限制是跑在JVM上的Java语言本身的限制;而静态织入则可以在编译期介入,直接改变字节码的生成,一般来说后者要强得多。

    1
  • szWest
    2020-01-15
    CGLIB不应该是编译时增强吗?

    作者回复: 是运行时

  • 小寞子。(≥3≤)
    2019-11-24
    写过安卓开发。。在spring的时候遇到aop代理问题。。第一次听到这个概念。。 当时还不知道我用了半年的安卓开发里面很多功能实现其实就是aop。 一查才知道是什么。
  • anginiit
    2019-10-10
    今年的项目都是springboot架构的,直接使用aop的就是写过一个用户操作日志记录,就是通过切面来拦截用户请求然后把用户信息记录下来的,没有特别深的使用过呢。不过原理已经明白了。
  • pyhhou
    2019-10-10
    1. 之前没怎么了解过 AOP,到时 DI 这个概念倒是一直听到,但也就是知道个概念,今天算是弄清楚 AOP、IoC、DI 这几个东西的基本概念了 2. 从我个人角度来看,AOP 确实隐藏了资源层的一些实现,程序员只需要关注业务层的代码,不需要特别考虑资源访问的逻辑,但是如果资源层的实现出现了问题,在 AOP 的架构下,会增加一定的 debug 难度,因为在这种架构下,资源层的逻辑可以说是不透明的;另外就是 AOP 其实和一般的设计思想有点不太一样,使用和适应上会有一定的门槛吧;性能方面的话,动态 AOP 实现会影响代码的实际运行效率,静态 AOP 实现又相对比较复杂,这也是一个需要权衡的地方
    展开
  • Dream.
    2019-10-09
    没接触过Spring,很多地方都没看懂再说什么 比如xml配置后执行顺序为(1) → (4) → (0) → (5) → (2) 没看到(0)在哪里,这个顺序是怎么来的看了好几遍也没看懂

    作者回复: 这里介绍的是配置切面逻辑以后的实际执行过程,(0) 就是原始代码,在你看到的(1) - (5) 的代码块的上方。

  • sky
    2019-10-05
    iOS里用的runtime的一些方法也是aop了

    作者回复: 👍