理解AOP与装饰器

AOP与装饰器

因为自己职业(前端)的选择,所以对Java一窍不通,更别提人尽皆知的Spring Framework了,导致有意错过了其中涉及到的、很多优秀的设计思想,AOP就是其中一种,它翻译为面向切面编程(Aspect Oriented Programming),切面这一具体但又抽象的概念的确使我兴奋,仅此而已。纯粹偶然,在阳光明媚的昨天,我坐在炎热的寝室学习ES7的装饰器模式,突然联想到了AOP、以及自己2年前初学Python Decorator时遇到的困惑,简单搜索、理解、整理。遂以记录,当然鉴于水平有限,这篇博文仅适用于和我一样的小白,日后参加工作之后如有更深理解,再不断完善修改吧。

AOP概念

不管是AOP还是Decorator,它们本质上追求的都是调用方与被调用方的解耦,同时提高代码的灵活性和可扩展性。可能你会说,OOP也能很好地做到这些。在我们代码执行的步骤前后,添加一些共性逻辑并提取到其他代码文件中,在使用时按照代码重复抽取的原则放到单独的方法中即可。但事实上,当代码量、逻辑复杂度上升,散落在各处的代码对软件的可维护性以及人员对业务的专注度都是不利的。

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

上面那句话可能不好理解。我这里以图书馆借书来加以说明。我们可能会经历以下流程:

借书 => 用户鉴权 => 检查已借数量和图书剩余量 => 开启事务 => 改变借书人和图书馆的状态 => 提交

这边对上述流程的共性逻辑进行了加粗显示。按照OOP的思考方式,我们会考虑将用户鉴权以及开启事务抽取出来,然后在需要的时候将代码手动添加进去。此时,换了个人来借书,我们会进行同样的操作。虽然重复利用了代码逻辑,但是业务流程不够纯粹。

事实上,藉由AOP我们可以将其公共功能:日志、跟踪、鉴权、事务等彻底从主流程代码中拿出去放到单独的地方,就像是一个切面一样穿插在每个步骤中。整个业务流程很干净,功能却没有任何丢失。
理解AOP与装饰器_第1张图片

AOP是一种思想,它虽然与特定的语言无关。但是鉴于Spring对其提供了很好的支持,这边通过一个简单的例子进行说明[1],说明之前引入一些AOP的核心概念[2],当然你也可以直接跳过,不影响后续例子的理解:

Aspect: Aspect 声明类似于 Java 中的类声明,在 Aspect 中会包含着一些 Pointcut 以及相应的 Advice。

Joint point:表示在程序中明确定义的点,典型的包括方法调用,对类成员的访问以及异常处理程序块的执行等等,它自身还可以嵌套其它 joint point。

Pointcut:表示一组 joint point,这些 joint point 或是通过逻辑关系组合起来,或是通过通配、正则等方式集中起来,它定义了相应的 Advice 将要发生的地方。

Advice:Advice 定义了在 pointcut 里面定义的程序点具体要做的操作,它通过 before、after 和 around 来区别是在每个 joint point 之前、之后还是代替执行的代码。

首先我们定义一个借出方法:

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

现在我们通过AOP的方式给lendOut添加功能:

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)
    }
}

其中(0)(1)(2)...(5)分别代表了不同的代码逻辑。

TransactionAspect类中包含了一系列方法,其中doBefore为事务开始前的逻辑,doAfter为事务结束的提交逻辑,doThrowing为事务失败的回滚,doAround为主逻辑,并在前后(4)(5)添加了日志打印。

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>

在实际执行过程中,如无异常抛出,逻辑顺序执行如下:

(1) -> (4) -> (0) -> (5) -> (2)

AOP原理

在业务逻辑中切一刀,一般有两种实现方式:

  1. 编译时的静态织入
  2. 运行时的动态代理

AspectJ是一种面向切面的Java扩展,它使用了扩展的语法,特定的编译器把AspectJ的代码编译成class文件,实现了源代码与切面代码的链接。

Spring AOP则在程序运行时,依靠预先创建或运行时创建的代理类来完成切面功能的。 Spring AOP通过对接口类型使用JDK动态代理,对没有业务接口的普通类使用CGLIB等第三方库来实现,其中涉及到的具体的包括Java的拦截、反射与字节码技术,我没有了解过,就不展开说了。

谈到解耦,不得不提的是在Spring中与AOP同时出现的一个概念:IoC(Inversion of Control),控制反转。

顾名思义,就是将原有类的依赖和控制方向掉转,对象不由主流程进行创建,而交给由第三方(称作IoC容器)来控制,利用诸如构造函数、属性或者工厂模式等方法,注入到原有类中,这样就极大程度的实现了原类和依赖类之间的解耦。

控制反转是一种思想。Spring IoC通过依赖查找(DL,Dependency Lookup)以及依赖注入(DI,Dependency Injection)来实现对象的查找与注入。

同样以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( ... );
    }
}

daodata access object的缩写,为数据访问层。

bookDao方法用以更新借阅书籍的状态。

loanDao方法用以增加借阅记录。

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

最后要说明的是,AOP并没有解决任何新的问题,它只是为现有的一些问题,诸如系统健壮性、代码的可维护扩展性方面提供了一种新方案,减少了工作量。

中间小结

从上面的讨论,我们可以简单地总结AOPIoC解决的痛点,想必你会有一个更深刻的认识。

AOP 解决了以下痛点:

  • 切面逻辑编写繁琐,有多少个业务方法就需要编写多少次。

IoC解决了以下痛点:

  • 许多重复对象的创建,造成大量资源浪费;
  • 更换实现类涉及到多个地方的改动。

ES7中装饰器概念

装饰器模式(Decorator Pattern)允许向一个现有的对象添加新的功能,同时又不改变其结构。这种类型的设计模式属于结构型模式,它是作为现有的类的一个包装。在保持类方法签名完整性的前提下,提供了额外的功能。

当然不仅仅限于类的包装,类的属性和方法同样可以使用装饰器模式动态扩展。很明显,就简单功能的增加上,同OOP实现相同功能的继承相比,装饰器模式更简单、更灵活。

个人很喜欢Javascript/Python装饰器的写法,它使得代码执行中日志打印、权限校验和数据格式的处理变得优雅。虽然Decorator已经进入ES7规范,但是目前使用上还需要借助于Babel或者TypeScript的配置支持。

简单滴讲,装饰器就是一种在运行时改变类或类的属性、方法的一种设计模式。如下是一个简单的Typescript的例子:

// test.ts
function log(target: any, name: string, descriptor: PropertyDescriptor) {
  let oldValue = descriptor.value
  descriptor.value = function () {
    const args = [...arguments]
    console.log(`Calling name ${name} with arguments:`, args)
    return oldValue.apply(this, arguments)
  }
  return descriptor
}

class myMath {
  @log
  add(a: number, b: number): number {
    return a + b
  }
}

let m = new myMath()
console.log(m.add(5, 7)) 

注意,在用tsc编译的时候,需要加入一些字段:

tsc --target ES5 --experimentalDecorators --downlevelIteration test.ts

输出结果:

Calling name add with arguments: [ 5, 7 ]
12

这里简单使用了log装饰器修饰了add方法,使得其在得出结果之前打印调用信息。

装饰器原理

事实上,装饰器函数需要返回一个函数,而这也涉及了闭包的概念。

在使用装饰器的时候,不管是Javascript还是Python。其解析器把被装饰的函数作为参数传递给装饰器,然后再返回一个函数对象。装饰器内部实现需要额外增加的功能和被装饰函数的功能,虽然被装饰函数的调用方法没有改变,但实际上已经不是原来函数, 而变成了装饰器返回的函数对象。

就拿上面的例子来说。add方法被作为参数传入到log中,其中:

target:  { add: [Function (anonymous)] }
name:  add
descriptor:  [Function (anonymous)]

oldValue保存了原来add方法的引用,然后在log装饰器中进行重写,实现额外的功能,并返回新的descriptor,而oldValue.apply(this, arguments)则通过调用原方法逻辑,保证了原有类方法的签名与功能。

我们可以看看tsc编译myMath后的结果:

var myMath = /** @class */ (function () {
  function myMath() {}
  myMath.prototype.add = function (a, b) {
    return a + b;
  };
  __decorate([log], myMath.prototype, 'add', null);
  return myMath;
})();

其中__decorate函数逻辑如下:

var __decorate =
  (this && this.__decorate) ||
  function (decorators, target, key, desc) {
    var c = arguments.length,
      r =
        c < 3
          ? target
          : desc === null
          ? (desc = Object.getOwnPropertyDescriptor(target, key))
          : desc,
      d;
    if (typeof Reflect === 'object' && typeof Reflect.decorate === 'function')
      r = Reflect.decorate(decorators, target, key, desc);
    else
      for (var i = decorators.length - 1; i >= 0; i--)
        if ((d = decorators[i]))
          r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
  };

如果Reflect对象以及Reflect.decorate存在,则直接调用(ReflectES6引入的API, 可以获取目标对象的行为,它与 Object 类似,但提供了一种更优雅的方式),否则利用Object.defineProperty对原有的descriptor进行封装,更改属性。

这边又多了一个问题,如果一个类或方法有多个装饰器,它们的执行方式又是如何?下面是一个简单的Demo,定义了一个加粗装饰器函数makeBold与斜体装饰器函数makeItalic

function makeBold(target: any, name: string, descriptor: PropertyDescriptor) {
  console.log("Bold...")
  const oldValue = descriptor.value
  descriptor.value = function () {
    console.log("bold_bold_bold")
    return `${oldValue.apply(this, arguments)}`
  }
  return descriptor
}

function makeItalic(target: any, name: string, descriptor: PropertyDescriptor) {
  console.log("Italic...")
  const oldValue = descriptor.value
  descriptor.value = function () {
    console.log("italic_italic_italic")
    return `${oldValue.apply(this, arguments)}`
  }
  return descriptor
}

class Sentence {
  content: string
  constructor() {
    this.content = "Hello, World"
  }

  @makeBold
  @makeItalic
  print() {
    return this.content
  }
}

const s = new Sentence()
console.log(s.print())

编译运行,其输出结果为:

Italic...
Bold...
bold_bold_bold
italic_italic_italic
Hello, World

从打印结果可以看出,Hello, World先斜体处理,然后加粗。也即是说装饰器的加载从内到外,从下往上(这让人又联想到koa的洋葱模型)。

AOP与装饰器的对比

从上面的原理对比可以看出:

Spring AOP是基于动态代理的方式,依靠预先创建或运行时创建的代理类来完成切面功能的,在不修改业务主体代码的情况下,对它添加额外的行为,其中动态代理也体现了责任链模式。

装饰器则是通过组合的方式添加功能或行为。例如在Javascript通过装饰器作用于类的属性的时候,实际上是利用解释器,通过闭包以及 Object.defineProperty方法对原有的descriptor进行封装实现,个人觉得也可以称作为运行时组合。

参考资料

1: “剑走偏锋:面向切面编程”(https://time.geekbang.org/column/article/143882 )
2: “百度百科”(https://baike.baidu.com/item/AOP/1332219?fr=aladdin)
3: “装饰器模式”(https://www.runoob.com/design-pattern/decorator-pattern.html)

你可能感兴趣的:(设计思想,aop,javascript,spring)