简谈前端开发中的AOP(二) -- 前端AOP的发展与完善

1,019 阅读28分钟

一、前言

距离上一篇文章发布已经过去很久,一直没有更新原因有很多。最大的原因是有一个疑虑,想寻求一个明确的结果。以免带偏节奏,给大家造成没必要的困扰。

二、回顾分析

上一篇文章很多概念没有讲清楚,所以这篇文章我们回顾先一下还遗留的一些问题,然后再简单阐述一下AOP的一些重要概念。进而继续讨论前端AOP的实现与完善。

1、还存在的一些问题?

上一篇文章我们讨论了AOP的简单实现,但还存在一些问题。

  • 没有真正解耦,需要针对性导入和织入切片(Aspect)
  • 没有区分静态方法和原形方法切入点(Pointcut)
  • 没有处理异步方法切点和通知(Advice)
  • 通知的执行顺序的理解有误
  • 缺少对类的支持
  • 多切面的执行和执行顺序问题

在处理这些问题之前,我们先回顾一下,前面讨论过的AOP的一些概念。

2、概念回顾

A、AOP的定义

AOP为Aspect Oriented Programming的缩写,意为:面向切面编程。

B、AOP的要解决什么问题?

AOP主要意图是将日志记录,性能统计,安全控制,事务处理,异常处理等,分散在业务逻辑中的非业务逻辑代码,划分出来集中起来统一编程、统一管理,最终实现业务逻辑编码和非业务逻辑编码分离解藕。

看下面示意图,如果把业务逻辑看作纵向流程,相对的非业务逻辑分离既是横切流程。这就是面向切面编程概念的原由。

C、AOP的几个重要概念

AspectJ是AOP中最具有代表性的工具,值得我们研究和借鉴。下面回顾一下,前面讨论过的几个相关概念,后面将以此为基础,讲一下前端AOP的设计思路。

  • 连接点(Joinpoint):表示在程序中明确定义的点,典型的包括方法调用,对类成员的访问以及异常处理程序块的执行等等

  • 切入点(Pointcut):表示一组Joinpoint,这些Joinpoint或是通过逻辑关系组合起来,或是通过通配、正则表达式等方式集中起来,它定义了将要放置增强Advice的地方。比如"所有类的submit和reset方法调用执行”

  • 切面(Aspect):由切点和增强组成,既包含了增强逻辑的具体实现和连接点的定义

  • 通知/增强(Advice):具体的增强逻辑实现。通常依据放置的地方不同,可分为前置通知(Before)、后置返回通知(AfterReturning)、后置异常通知(AfterThrowing)、后置通知(After)与围绕通知(Around)5种

  • 织入(Weaving):将切面应用到目标对象从而创建一个新的代理对象的过程

三、设计思路

1、简述

AOP(面向切面编程)在编程语言中(例如JAVA)已经广泛的应用。相关开发工具也已经臻于完善,其中最为大家熟知的是AspectJ 和 Spring AOP。我们的最终目标,是基于这些现有的成熟AOP工具设计思路,实现一套TypeScript版本的AOP工具

前端的发展非常迅速,尤其ES6和TypeScript的出现,极大丰富了前端开发工具。babel和node.js的迅速迭代,为ES6和TypeScript的普及创造了良好的应用环境。极大的促进了前端工程化的长足发展。前端工程化的日渐完善,给前端开发者们打开了一个又一个的新世界。基于此,才有了前端开发领域日新月异的新局面。很多服务端开发领域,杰出的开发工具和编程思想,都可以应用到前端领域。比如,我们要讨论的AOP。在java开发中有两个优秀的AOP工具,值得我们学习和借鉴:AspectJ 和 Spring AOP。

2、简析SpringAOP

首先,看一个基于Spring AOP的应用示例,对AOP的使用有一个大概的了解;其次,从应用角度分析一下设计思路;最后,结合TypeScript的语言特性,从技术角度分析实现原理。

这里简单讲解一下Spring AOP的应用,篇幅不会太大,以免带偏了方向

A、简单实例

Spring AOP中定义切面:

    @Aspect
    public class TestAspect {
        /*定义切点*/
        @Pointcut("execution(some.com.cn.Hello.say())")
        pointOne(){}

        @After({value:'pointOne'})
        public void afterHello(JoinPoint joinPoint) {
            System.err.println("after " + joinPoint);
        }

        @Around({value:'pointOne'})
        public void aroundHello(ProceedingJoinPoint joinPoint) throws Throwable {
            System.err.println("in around before " + joinPoint);
            if (joinPoint.getTarget().getClass().runAround) {
                joinPoint.proceed();
            }
            System.err.println("in around after " + joinPoint);
        }

        @Before({value:'pointOne'})
        public void beforeHello(JoinPoint joinPoint) {
            System.err.println("before " + joinPoint);
        }
    }

Spring AOP中定义目标类:

   class Hello {
       private static boolean runAround = true;

       public static void main(String[] args) {
           new Hello().sai();
           runAround = false;
           new Hello().sai();
       }

       public void sai() {
           System.err.println("sai something");
       }
   }

执行结果:

    1.  in around before execution(some.com.cn.Hello.say())
    2.  before execution(some.com.cn.Hello.say())
    3.  in hello
    4.  after execution(void some.com.cn.Hello.say())
    5.  in around after execution(void some.com.cn.Hello.say())
    6.  in around before execution(void some.com.cn.Hello.say())
    7.  in around after execution(void some.com.cn.Hello.say())

这是一个完整的AOP应用实例,从中我们可以了解到以下一些信息:

  • 需要一个切面类和以及对应的目标类
  • 用到了装饰器语法@Aspct、@PointCut、@Before、@After
  • @Aspct用于声明切面类,@PointCut声明切点,@Before和@After声明和切点对应的增强
  • 切点中声明了连接点匹配规则
  • 增强的参数是JoinPoint,可以获取目标位置的信息
  • Around增强参数是ProceedJoinPoint,带有proceed,可以手动执行目标方法

B、思路分析

从上面的例子以及前面的几个概念可以总结出,面向切面编程主要包含两个过程:

  • 从目标类分离非业务逻辑,编写切面的过程
  • 按照匹配规则,确定目标位置,把切面重新织入目标类的过程

初步的分析思路,包括以下两部分。

  • 分析上述两个过程中,主要涉及到的一些概念,以及各个概念之间的关联;
  • 讨论具体各个概念的具体实现;

在此之前,我们先明确以下几个问题,对设计有一个大概的思路:

  • AOP设计目标是什么?

    AOP的设计目标是,从业务逻辑中,分离出分散的非业务逻辑统一管理,实现解藕。

  • 从哪里分离逻辑?

    一般是指从业务逻辑类(目标类(Target)),中分离出特定逻辑。比如,SomePage就是目标类,我们需要从中分离一些逻辑。

    class SomePage {
        run(){
            Log.info('日志信息')
    
            doSomeThing() // 业务逻辑
    
            Track.send('埋点信息')
        }
    }
    
  • 需要分离那些逻辑?

    从业务角度,通常是指把分散在业务逻辑中的非业务逻辑分离出来,统一管理。例如,上例中的日志和埋点信息,分散在项目的各个角落,虽然不是业务节点,但本身也很重要,这部分就是属于AOP管理的逻辑。

    从代码角度,通常是指从目标类的执行节点,分离部分执行逻辑。目标类中的所有执行节点,通常称之为连接点(JoinPoint),例如SomPage的run方法执行;执行逻辑则是需要分离的逻辑,称之为增强(Advice)

    cont page = new SomePage()
    
    Log.info('日志信息')
    page.run()
    Track.send('埋点信息')
    
  • 分离逻辑到哪里?

    通常会把逻辑分离到独立的类进行管理,这个管理分离逻辑的类称之为切面(AspectClass)。切面由切点(PointCut)增强(Advice)两部分构成。其中切点用来记录分离和切入逻辑的位置,增强既是分离出来的相关逻辑。

    下面实例演示了,Aspect类管理分离逻辑的基本结构

    class Aspect{
        // 切点
        pt:PointCut = new PointCut(rules)
    
        // 增强
        beforeAction(jp:Joinpoint){
            // 分离逻辑,如:日志信息
            Log.info('日志信息')
        }
    
        // 增强
        afterAction(jp:Joinpoint){
            // 分离逻辑,如:埋点信息
            Track.send('埋点信息')
        }
    }
    
  • 如何确定增强织入位置?

    逻辑分离和切入位置由切入点(PointCut)来记录和声明。切入点是一个匹配连接点的断言或者表达式。Advice 与切入点表达式相关联,并在切入点匹配的任何连接点处运行。

    切入点主要包括匹配规则和增强管理容器:

      class PointCut {
          rules:string // 匹配规则
          advices = {} // 增强管理容器
      }
    

    将增强和切入点关联

      const AP = new Aspect()
      
      // 注册增强到advices
      const registAdviceToPointCut = (type, action, pt)=>{
          pt.advices[type] = action
      }
     
      registAdviceToPointCut('before', AP.beforeAction, AP.pt)
      registAdviceToPointCut('after', AP.afterAction, AP.pt1)
    
  • 如何执行分离出来的逻辑?

    分离逻辑是为了便于管理,并不会改变原有逻辑的执行顺序和执行结果。因此,需要把Advice重新切入到原来的位置,这个位置就是我们通常讲的切点,也就是符合匹配的连接点。这个过程称之为织入(Weaving)

    通常实现织入的方式有两种:

    • 一种是在编译阶段,把对应的代码,放入目标位置;
    • 一种是在执行阶段,通过动态代理的方式,将增强和业务逻辑重新整合成一个完整的逻辑。

    编译阶段织入代码,需要用AST语法分析,实现相对复杂。因此,我们优先选择动态代理的方式。TS中实现代理的方式有很多,很自然就会想到Proxy和defineProperty。无论哪种方式,都能很好满足我们的需求。譬如下面是TS代理织入逻辑的简单示例,基于Proxy实现。暂且可以初步了解一下,后面再详细讲解:

    const ProxyInstance = new Proxy(new SomePage(), {
        get(target, propKey, receiver){
            let origin = Reflect.get(target, propKey)
            if (propKey === 'run'){
                return funciton(...args){
                    // 执行 log Advice
                    
                    // 原方法
                    let rst = origin.apply(this, args)
                    
                    // 执行track Advice
                }   
            }
            
            return origin
        }
    })
    

这就是从目标类中分离分散的非业务逻辑,进而放置到切面类中进行管理,最后又重新切入到目标类的大概过程。

四、实现分析

通过上面几个问题,相信大家对AOP分离逻辑的过程,已经有了一个大概的思路。下面我们详细剖析上面涉及到的几个关键概念,一起探寻前端AOP工具的实现方式。

1、切面(Aspect)

A、切面实现分析

切面是放置从业逻辑中分离出零散的非业务逻辑的地方,也是AOP的核心。切面本身也是一个面向对象的类,包含以下几个部分:

  • 声明切面类(Aspect)
  • 声明和创建切点(PointCut)实例
  • 声明和切点实例对应的增强(Advice)

切面的声明格式:

// 声明切面
public class AspectClass {
   //声明切点
   pt = new PointCut(rules)
   
   // 声明增强
   public beforeAction(jp:JoinPoint){
       // JoinPoint是链接点信息
   }
}

实际应用中,会有多个切面类,管理不同类型的分离逻辑。因此,需要一个管理容器(Container),把切面类纳入管理,保证切面可以被扫描到,以便把增强注入到目标位置。

B、管理切面的容器

容器(Container)需要具备以下特征:

  • 注册功能,将切面纳入管理
  • 遍历访问功能,便于扫描匹配,织入增强到特定位置

符合这些条件的工具最理想的是数组Array<T>

  • 声明管理切面的容器
let Container: Array<any> = []
  • 将特定切面(Aspect)纳入容器管理

把切面纳入管理容器的逻辑非常简单:

const Aspect = (target) => {
    Container.push(target)
}
    
// 声明切面
public class AspectClass {
    // ...
}
    
// 纳入容器管理
Aspect(AspectClass)

当然,此处的Aspect还可以作为装饰器使用:

@Aspect
public class AspectClass {
    // ...
}

@Aspect是TypeScript类装饰器(class decorator),其本质是高阶函数语法糖。类装饰器用来装饰整个类。类装饰器函数接受一个参数target(目标类)。

typescript自带的lib.es5.ts中已经内置声明类类饰器。

// 类装饰器,typescript/lib.es5.ts自带
type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void

类装饰基本应用格式:

const decorator: ClassDecorator = (target)=>{
  target.someProp = true
  target.prototype.someMethod = ()=>{}
  Object.defineProperty(target,'secondProp', {...})
}

@decorator
class A {}

// 等同于
class A {}
A = decorator(A) || A;

相对应的@Aspect也符合上述逻辑规则

@Aspect
public class AspectClass {
    // ...
}

// 等同于
AspectClass = Aspect(AspectClass) || AspectClass

C、切面类的顺序

如果多个切面对应同一个连接点,会带来织入顺序的问题,织入顺序直接影响到增强的执行顺序。Spring AOP利用order决定切面的优先级,order越小优先级越高。

下面示例, FirstAspect将先于SecondAspect执行。

切面FirstAspect, Order == 1:

@Component
@Aspect
@Order(1)
public class FirstAspect {

    @Pointcut("@annotation(com.booleandev.data.aop.First) || @within(com.booleandev.data.aop.First)")
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint pjp) {
        // ...
    }

}

切面SecondAspect, Order == 2:

@Component
@Aspect
@Order(2)
public class SecondAspect{

    @Pointcut("@annotation(com.booleandev.data.aop.Second) || @within(com.booleandev.data.aop.Second)")
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object around(ProceedingJoinPoint pjp) {
        // ...
    }

}

我们仿照也加一个order字段,在织入时先对容器里的切面进行排序,决定切面织入的顺序。

@Aspect
public class AspectClass {
    static order = 1
    // 或者
    static getOrder():number {
        return 1
    }
}

当然还可以通过装饰器工厂给@Aspect传参数

@Aspect({order:1})
public class AspectClass {
   // ...
}

const Aspect = ({order})=> (target) => {
    target.order = order
    Container.push(target)
}

使用时Container排序

Container = Container.sort((a,b)=>b.order < a.order)

2、切点(PointCut)

上面我们讨论了切面,切面由切点和增强组成,用于管理和维护分离逻辑。一个切面可以有多个切点和多个增强。其中增强是分离逻辑,而切点则是增强织入的位置。

从逻辑上分析,一个增强可以对应多个切点,一个切点也可以对应多个增强。但切点的匹配规则可以是模糊匹配,也可以是数组,因此我把模型设计成一个切点对应多个增强的模式,简化设计逻辑。这样,在织入阶段,只要查询到满足条件的切点,就可以拿到对应的增强逻辑;这样做还有另外一个好处,我们可以以切点为基本单位,明确增强的执行顺序,这个后面增强部分再进行讨论。

看下面示意图,了解一下我们的设计思路。从概念上划分,PointCut和Advice隶属于Aspect;并且PointCut和Advice相关联,属于一对多的关系。

A、切点类的实现

切点的功能主要包括两个方面:

  • 声明连接点的匹配规则
  • 管理相关的增强

先看一下切点的基本设计模型:

// 切点类型接口
interface PointcutClassType {
    rules: PointcutRules // 匹配规则
    advices: Advices // 管理增强
    matches: (ctx: PointcutMatches) => boolean // 判断是否跟连接点匹配
    registAdvice<T extends AdviceKeys>(type: T, advice: Advice<T>): void // 注册增强
    findAdvice(type: AdviceKeys): AdviceTypes // 查找增强
}

// 实现
class PointcutClass implements PointcutClassType {
    // ...
}

其中类型成员作用如下:

  • rules(PointcutRules)是匹配规则
  • advices(Advices)是管理增强点容器
  • matches用于判断是否符合匹配条件
  • registAdvice用于注册添加增强

I、匹配规则(PointcutRules)

匹配规则PointcutRules,用于目标类连接点的匹配验证,主要验证四部分:

  • 命名空间 namespace
  • 类 className
  • 方法名称 methodName
  • 方法类型 type,包括静态方法(static)和原型方法(proto),默认proto,可省略

通俗讲,匹配规则是验证x包中的y类的z方法,是否符合增强织入条件。

type PointcutRuleType = {
    type: 'static' | 'proto' | '*'
    namespace: RegExp | string
    className: RegExp | string
    methodName: RegExp | string
}

type PointcutRules = PointcutRuleType

实例

@Asepct
class Test{
    @PointCut()
    get pt(): PointcutRules {
        return {
            namespace: 'namespace',
            className: 'TargetClass',
            methodName: 'doSomeThing'
        }
    }
    
    // ...
}

为了使用方便,我们参照SpringAOP,支持字符串(自动转化成正则)或者正则表达式,简化切点声明的使用,并且支持多匹配规则(数组)。

type PointcutRules = 
    | string | RegExp | PointcutRuleType 
    | Array<PointcutRuleType | RegExp | string>

匹配规则字符串按照type namespace:className.methodName格式书写,其中命名空间可省略,type默认为proto。

例如

'somehost.com:TargetClass.doSomeThing'
'TargetClass.doSomeThing'
// 正则
/^somehost\.com:TargetClass\.doSomeThing$/
/^TargetClass\.doSomeThing$/ig

三种匹配规则的应用实例

@Asepct
class Test{
    @PointCut() 
    get pt(): PointcutRules { 
        return { 
            namespace: 'somehost.com', 
            className: 'TargetClass', 
            methodName: 'doSomeThing' 
        } 
    }
    
    @PointCut()
    get pt1():PointcutRules {
        return 'somehost.com:TargetClass.doSomeThing'
    }
    
    @PointCut()
    get pt2(): PointcutRules{
        return /^somehost\.com:TargetClass\.doSomeThing$/
    }
    
    // ...
}

II、增强容器(Advices)

advices(Advices)是管理增强的容器,用于存放跟当前切点相关的增强逻辑,以及标注增强的类型。后面会在增强的部分详细讲解,这里可以简单了解一下。

增强容器主要涉及五种增强的注册和查找,类型如下:

interface Advices {
    after?: AfterAdviceType
    afterReturning?: AfterReturningAdviceType
    afterThrowing?: AfterThrowAdviceType
    before?: BeforeAdviceType
    around?: AroundAdviceType
}

基于上述讨论,简略版的PointcutClass实现如下:

// 实现
class PointcutClass implements PointcutClassType {
    rules: PointcutRules // 匹配规则
    advices: Advices = {} // 增强容器
    
    constructor(rules: PointcutRules){
        this.rules = rules
    }
    
    /**
     * 注册advice
     * @param type {AdviceKeys}
     * @param advice {Aspect[AdviceKeys]}
     */
    registAdvice<T extends AdviceKeys>(type: T, advice: Advice<T>) {
        this.advices[type] = advice
    }

    /**
     * 查找advice
     * @param type {AdviceKeys}
     * @returns Aspect[AdviceKeys]
     */
    findAdvice(type: AdviceKeys): AdviceTypes {
        return this.advices[type]
    }
    
    // ...
}

III、PointcutClass应用

上面介绍了PointcutClass、PointcutRules以及对应的Advices。下面讲一下PointcutClass的使用。PointcutClass的使用过程,主要涉及以下几步:

  • 创建PointcutClass的实例
let pointCut: PointcutClass = new PointcutClass(pointcutRules)
  • 把PointcutClass的实例纳入管理容器
let propKey = 'propKey'
let pointcuts = pointcuts = new Map<string, PointcutClass>()
    pointcuts.set(propKey, pointCut)
  • 将管理容器pointcuts挂载到切面类上面
AspectClass.__pointcuts = pointcuts

把上述过程整合到一起,就是一个完整的PointCut声明挂载工具:

const PointCut = (target, propKey, pointcutRules)=>{
    // 声明切点实例
    let pointCut: PointcutClass = new PointcutClass(pointcutRules)
    
    // 读取切面挂载到切点容器
    let pointcuts = target.__pointcuts
    
    // 如果没有就创建
    if(!pointcuts){
        pointcuts = new Map<string, PointcutClass>()
    }
    
    // 把切点纳入容器管理
    pointcuts.set(propKey, pointCut)
    
    // 重新挂载到切面
    target.__pointcuts = pointcuts
}

PointCut(AspectClass, 'pt', 'somehost.com:TargetClass.doSomeThing')

跟Aspect相似,PointCut也可以作为装饰器来使用,但需要略作改动。

@Aspect
class AspectClass{
    @PointCut
    get pt(){
        return 'somehost.com:TargetClass.doSomeThing'
    }
}

下面我们讨论一下@PointCut的实现。

B、@PointCut实现

前面我们讨论了PointcutClass,及其应用。其中涉及到了@PointCut,并且了解PointCut方法基本实现。但还是有很多细节需要探究。首先,我们一起分析一下切点声明格式,然后再进一步探讨如何实现@PointCut。

I、切点声明格式

切点声明格式,我们参考前面的Spring AOP实例:

/*定义切点*/
@Pointcut("execution(* some.com.cn.Hello.say())")
pointOne(){}

完整的切点声明格式包括下面三部分:

  • 装饰器 @Pointcut
  • 切点名称 pointOne
  • 匹配规则 execution(* some.com.cn.HelloAspect.hello())

对应的切点的声明格式可以像下面这样子,这是一个典型的方法装饰器

@PointCut(matchRules:string)
pointCutName() {}

pointCutName是一个空的类方法,略感费解,所以我把格式调整了一下,方法装饰器变成了访问符装饰器

@PointCut()
get pointCutName():string {
    return 'matchRules'
}

当然,还可以简化成属性装饰器去实现,:

@PointCut() const pointCutName: string = 'matchRules'

因为属性装饰器,有一定的局限性,这里我们选择访问符装饰器。

II、@PointCut

@PointCut本质是访问器装饰器,它的作用包含下面几部分:

  • 声明创建切点
  • 标示切点关联的连接点匹配规则
  • 收集切点纳入管理,方便后续织入扫描

讨论@PointCut实现之前,需要先了解一下访问符装饰器。

  • 访问符装饰器(Accessor Decorators)

访问器装饰器声明在一个访问器的声明之前(紧靠着访问器声明)。访问器装饰器应用于访问器的属性描述符并且可以用来监视,修改或替换一个访问器的定义。如果访问器装饰器返回一个值,它会被用作方法的属性描述符。

访问符装饰器声明格式:

const accessorDecorator = (target, name, descriptor)=>{
    return descriptor
}

访问符装饰器三个参数分别对应:

  • target静态方法中表示类本身,原型方法中表示原型对象
  • name类方法属性名称
  • descriptor 属性描述符

实例

function enumerable(value: boolean) {
    return function (
        target: any, 
        propertyKey: string, 
        descriptor: PropertyDescriptor
    ) {
        descriptor.enumerable = value
    }

}

class A{
    @enumerable(false)
    get someProp(){
        return 'prop value'
    }
}

相应的@PointCut应该调整成下面这种结构:

const Pointcut = () =>
    (target: any, propKey: string, descriptor: PropertyDescriptor) => {
       // ...
       return descriptor
    }

结合前面的PointCut函数,我们来分析一下参数的异同:

  • 这里的target是Class.prototype原型,而前面PointCut参数中的target是Class,通过原型可以获取到Class
  • 这里propKey是修饰的成员名称,而前面PointCut参数中的propKey是一个字符串,可以等同使用
  • 这里pointcutRules其实是target中propKey的值,同样可以直接使用
const Pointcut = () =>
    (target: any, propKey: string, descriptor: PropertyDescriptor) => {
    
    // 获取到切面类
    let cls = target.constructor
    
     // 声明切点实例
    let pointCut: PointcutClass = new PointcutClass(pointcutRules)
    
    // 读取切面挂载到切点容器
    let pointcuts = cls.__pointcuts
    
    // 如果没有就创建
    if(!pointcuts){
        pointcuts = new Map<string, PointcutClass>()
    }
    
    // 把切点纳入容器管理
    pointcuts.set(propKey, pointCut)
    
    // 重新挂载到切面
    cls.__pointcuts = pointcuts
        
    return descriptor
}

这就是@PointCut的实现

  • 元数据(Reflect Metadata)

到这里,@PointCut其实基本已经完成。但是在typescript语言中,处理数据存取有更好的办法 --- 元数据(Reflect Metadata)

Reflect Metadata 是 ES7 的一个提案,它主要用来在声明的时候添加和读取元数据。TypeScript 在 1.5+ 的版本已经支持它,你只需要:

  • npm i reflect-metadata --save
  • 在 tsconfig.json 里配置 emitDecoratorMetadata 选项

就可以开始方便的使用Reflect Metadata。

元数据的应用实例:

@Reflect.metadata('inClass', 'A')
class Test {
  @Reflect.metadata('inMethod', 'B')
  public hello(): string {
    return 'hello world';
  }
}

console.log(Reflect.getMetadata('inClass', Test)); // 'A'
console.log(Reflect.getMetadata('inMethod', new Test(), 'hello')); // 'B'

Reflect.metadata方法本质是装饰器工厂,是Reflect.defineMatadata方法的语法糖,作用是在类或者类成员上面声明元数据。Reflect.getMetadata方法用于读取声明在对象上的元数据。

基于元数据,可以把@PointCut的实现调整成下面这样:

const Pointcut = () =>
    (target: any, propKey: string, descriptor: PropertyDescriptor) => {
    
    // 获取到切面类
    let cls = target.constructor
    
     // 声明切点实例
    let pointCut: PointcutClass = new PointcutClass(pointcutRules)
    
    // 读取切面挂载到切点容器
    let metaKey: string = `MetaData:pointcuts`
    let pointcuts: PointcutMap = Reflect.getMetadata(metaKey, cls)
    
    // 如果没有就创建
    if(!pointcuts){
        pointcuts = new Map<string, PointcutClass>()
    }
    
    // 把切点纳入容器管理
    pointcuts.set(propKey, pointCut)
    
    // 重新挂载到切面
    Reflect.defineMetadata(metaKey, pointcuts, cls)
        
    return descriptor
}

3、增强(Advice)

增强是从业务逻辑中分离出来的非业务逻辑的具体实现。通常依据放置的地方不同,可分为:

  • 前置通知(Before Advice)
  • 后置返回通知(AfterReturning Advice)
  • 后置异常通知(AfterThrowing Advice)
  • 后置通知(After Advice)
  • 环绕通知(Around Advice)

A、Advice声明格式

增强本身是分离的逻辑模块,增强的实现并不具体,但也有一定的格式和规则。增强逻辑是从连接点分离得到的,因此增强需要关注对应的连接点信息。增强应该很方便就能访问到原执行环境的上下文信息。逻辑代码分离是为了更好的管理和维护,但执行不能脱离原有的执行环境,连接点的相关信息就是逻辑执行的上下文环境。

下面是五种增强的类型声明。JoinPoint是连接点信息,result连接点的执行结果,error是连接点的执行异常。

// 后置
type AfterAdviceType = (joinPoint: JoinPoint, result: any, error: Error | null) => void
// 后置返回
type AfterReturningAdviceType = (joinPoint: JoinPoint, result: any) => any
// 后置异常
type AfterThrowAdviceType = (joinPoint: JoinPoint, error: Error | null) => void
// 前置
type BeforeAdviceType = (joinPoint: JoinPoint) => void
// 环绕
type AroundAdviceType = (joinPoint: ProceedJoinPoint) => any

interface Advices {
    after?: AfterAdviceType
    afterReturning?: AfterReturningAdviceType
    afterThrowing?: AfterThrowAdviceType
    before?: BeforeAdviceType
    around?: AroundAdviceType
}

B、增强的应用

增强的使用主要包括下面几个步骤:

  • 声明增强
let beforeAction: BeforeAdviceType = (jp:JoinPoint) =>{
   // do something for jp
}
  • 将增强跟切点关联
const createAdvice = (target, pointCutName, type, advice)=>{
    let metaKey = 'MetaData:pointcuts'
    // 获取到切点容器
    let pointcuts = Reflect.getMetadata(metaKey, target)
    // 通过切点名称获取到切点
    let pt = pointcuts.get(pointcutName)
    // 将增强注册到pt的Advices
    pt.registAdvice(type, advice)
    // 重置切点
    pointcuts.set(pointcutName, pt)
    // 重新挂载切点容器到切面
    Reflect.defineMetadata(metaKey, pointcuts, target)
}
    
// 声明前置增强
createAdvice(target, 'pt', 'before', beforeAction)

这就是增强的使用过程,成功将不同类型的增强,注册到了Pointcut的advices容器。

C、Advice装饰器

上面我讨论了增强的声明格式和使用方法。实际开发中,我们是通过装饰器声明Advice并跟PointCut关联起来的。

先看一下最终应用实例:

@Asepct
class Test{
    @PointCut()
    get pt():PointcutRules {
        return 'somehost.com:TargetClass.doSomeThing'
    }

    @Before({value:'pt'})
    beforeAction(jp:JoinPoint){
        // do something for jp
    }
    
    @After({value:'pt'})
    afterAction(jp:JoinPoint, result:any, error:any){
        // do something for jp
    }
}

其中,@Before和@After分别是声明前置增强和后置增强的工具,除此之外还有@Around(环绕增强)、@AfterReturning(后置返回增强)、@AfterThrowing(后置异常增强)。这些工具,本质是方法装饰器(Mehtod Decorator)。下面,我们先一起了解一下方法装饰器,再讨论增强装饰器工具的实现。

I、方法装饰器

顾名思义,方法装饰器是用于类方法的装饰器。

增强(Advice)的实现基于方法装饰器,因此需要理解和掌握。

方法装饰器的声明:

const methodDecorator = (target, name, descriptor)=>{
    return descriptor
}

方法装饰器三个参数分别对应:

  • target静态方法中表示类本身,原型方法中表示原型对象
  • name类方法属性名称
  • descriptor 属性描述符

注:如果方法装饰器有返回值,将被应用于属性描述符descriptor

这里的@enumerable(false)是一个装饰器工厂。 当装饰器 @enumerable(false)被调用时,它会修改属性描述符的enumerable属性。

function enumerable(value: boolean) { 
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { 
        descriptor.enumerable = value; 
    }; 
}

class A{
    @enumerable(false)
    doSomeThing(){
    }
}
II、Advice装饰器的实现

我们对照着createAdvice,讨论一下增强装饰器工具的实现。target和advice可以通过装饰器分别获取到,只有type和pointCutName需要动态传参。

// 原来
const createAdvice = (target, pointCutName, type, advice)=>{}

// 调整成

/**
 * type {'before' | 'after' | 'afterReturning' | 'around' | 'afterThrowing'}
 **/
const createAdvice = (type,pointCutName) 
    => (target: any, propertyKey: string, descriptor: PropertyDescriptor)=>{
        // 增强函数
        let advice = descriptor.value
    }

此时createAdvice是一个装饰器工厂,应用格式如下:

@Asepct
class Test{
    @PointCut()
    get pt():PointcutRules {
        return 'somehost.com:TargetClass.doSomeThing'
    }

    @createAdvice('before', 'pt')
    beforeAction(jp:JoinPoint){
        // do something for jp
    }
    
    @createAdvice('after', 'pt')
    afterAction(jp:JoinPoint, result:any, error:any){
        // do something for jp
    }
}

为了使用方便,也为了代码看起来更直观,进一步调整createAdvice。我们可以提前预制好可读性更强的装饰器简化工具。(当然这里也可以采用柯理化去处理)

/**
 * type {'before' | 'after' | 'afterReturning' | 'around' | 'afterThrowing'}
 **/
const createAdvice = (type) => (options) 
    => (target: any, propertyKey: string, descriptor: PropertyDescriptor)=>{
        // 切点名称
        let pointCutName = options.value
        // 增强函数
        let advice = descriptor.value
        
        // ....
    }
    
const Before = createAdvice('before')
const After = createAdvice('after')
const AfterReturning = createAdvice('afterReturning')
const Around = createAdvice('around')
const AfterThrowing = createAdvice('afterThrowing')

最终使用方式变成我们熟悉的格式:

@Asepct
class Test{
    @PointCut()
    get pt():PointcutRules {
        return 'somehost.com:TargetClass.doSomeThing'
    }

    @Before({value:'pt'})
    beforeAction(jp:JoinPoint){
        // do something for jp
    }
    
    @After({value:'pt'})
    afterAction(jp:JoinPoint, result:any, error:any){
        // do something for jp
    }
}

至此,基本已经完成了切面的声明,只有JoinPoint还是黑盒,下面我们继续讨论有关JoinPoint相关概念和实现。

4、连接点(JoinPoint)

连接点(JoinPoint), 表示在程序中明确定义的点,典型的包括方法调用,对类成员的访问、异常处理、程序块的执行等。

连接点也是切面分离和织入逻辑的位置。为了能在增强函数中访问到连接点的环境变量,需要将连接点的相关信息传参给增强函数。如:target(目标对象)、this(上下文)、arguments(函数执行传入的参数)、result(函数执行结果)、error(函数执行异常)等信息。这些就是逻辑执行的上下文环境的相关信息。被分离的逻辑,可以通过JoinPoint很方便的访问到这些信息,才能保分离逻辑的按照原有方式正常执行。

A、JoinPoint的声明格式

连接点JoinPoint的声明格式,类似下例所示:

/**
 * @param target 目标对象
 * @param thisArg 上下文
 * @param fn 目标函数
 * @param args 目标函数参数
 **/

class JoinPoint {
    public readonly target: any // 目标类
    public readonly args: any[] = [] // 执行方法参数
    public readonly thisArg: any // 方法上下文
    public readonly fn: any // 成员

    constructor(jp: JoinPointType) {
        let { target, args, thisArg, fn } = jp
        this.target = target
        this.args = args
        this.thisArg = thisArg
        this.fn = fn
    }
}

上面这些信息都可以在增强运行时获取到,当然如果你愿意还可以包括更多的信息,例如:执行时间戳timestamp,方法名称methodName。

总之,连接点JoinPoint的功能主要包含两方面:

  • 明确逻辑分离和织入位置
  • 收集逻辑执行的上下文信息

B、ProceedingJoinpoint

ProceedingJoinpoint 继承了 JoinPoint,并在JoinPoint的基础上暴露出proceed方法。proceed用于执行目标方法。ProceedingJoinpoint用于Around增强的参数。因此Around增强里可以决定目标方法是否执行,以及在执行前后执行什么动作?从功能上面讲,Around可以取代另外四种增强。

看下面的例子,更加直观。proceed打包了原有的执行方法。这意味着,我们可以通过proceed拿到原方法的执行权。包括何时执行?执行前后的动作?如何处理执行结果和异常?

@Asepct
class Test{
    @PointCut()
    get pt():PointcutRules {
        return 'somehost.com:TargetClass.doSomeThing'
    }

    @Around({value:'pt'})
    aroundAction(jp:ProceedingJoinpoint){
        // do something before
        let rst = jp.proceed()
        // do something after
        
        return rst
    }

}

至此AOP第一阶段,分离逻辑并声明切面的所有实现,基本都已经实现。下面我们一起讨论一下第二阶段,将逻辑重新织入目标位置的实现。

5、织入(Weaving)

AOP的目标是将逻辑分离,具体讲就是把不同的逻辑代码分开管理,至少从代码管理角度,代码从分散、耦合,变成了统一管理。但有一个大前提,不能影响原有的代码功能逻辑和程序执行顺序。所以,还需要把分离的逻辑代码,重新织入到原有位置,保证代码的执行顺序和功能。

A、织入功能实现分析

前面我们讨论过,实现织入的方式主要有两种。一种是编译阶段织入;一种是执行阶段动态织入。我们优先选择动态代理的方式。TS中实现代理的方式有很多,比如Proxy和Object.defineProperty。

如果采用Proxy代理的方式,大概的实现思路如下面

  const Weaving = (target) => {
      // target类 静态方法织入
      const ProxyInstance = new Proxy(target, {
          get(target, propKey, receiver){
              let origin = Reflect.get(target, propKey)
              if (typeof origin ===function){
                  return funciton(...args){
                      // 执行before
                      
                      // 原方法
                      let rst = origin.apply(this, args)
                      
                      // 执行after
                      
                      return rst
                  }   
              }
              return origin
          }
      }
      
      // target类 原型方法织入
      ProxyInstance.prototype = new Proxy(target.prototype, {
           get(target, propKey, receiver){
               let origin = Reflect.get(target, propKey)
              if (typeof origin ===function){
                  return funciton(...args){
                      // 执行before
                      
                      // 原方法
                      let rst = origin.apply(this, args)
                      
                      // 执行after
                      return rst
                  }   
              }
              return origin
           }
      })
      
      return ProxyInstance
  }

如果采用Object.defineProperty代理的方式,大概的实现思路如下:

const Weaving = (target) => {
    const props = Object.getOwnPropertyNames(target.prototype)
    const statics = Object.getOwnPropertyNames(target)
   
    props.forEach((prop)=>{
        var origin = target.prototype[prop]
        if (typeof origin === function){
            Object.defineProperty(target.prototype, prop, {
               writable: true,
               enumerable: true,
               value(...args: any[]) {
                   // 执行 before

                   // 原方法
                   let rst = origin.apply(this, args)

                   // 执行after
                   return rst
               }
            }
       }
        
    })
    
    statics.forEach((prop)=>{
        var origin = target[prop]
        if (typeof origin === function){
            Object.defineProperty(target, prop, {
               writable: true,
               enumerable: true,
               value(...args: any[]) {
                   // 执行 before

                   // 原方法
                   let rst = origin.apply(this, args)

                   // 执行after
                   return rst
               }
            }
       }
    })
}

无论那种思路,都能够完成织入增强的目标。下面我们采用基于defineProperty实现织入逻辑。上述只是大体的实现思路,详细的实现,还要深入讨论。

B、查找匹配PointCut信息

首先需要找出有织入增强的连接点,以及连接点相应的切点和增强信息。

依据前面的实现,我们可以扫描Container,查找所有跟当前方法匹配的PointCut信息。通过PointCut的matches方法,我们可以找出符合条件的pointcus。

let pointcuts = Container.reduce((rst, Aspect)=>{
    lt pts = Reflect.getMetadata('metadata:pointcuts', Aspect)
    return rst.concat(pts.filter((pt)=>pt.matches({
        type: 'proto',
        namespace: '',
        className: target.name,
        methodName: prop
    })))
}, [])

接下来,我们就可以从PointCut中取出声明的增强信息,织入到执行方法的前面或者后面。可是很明显,这里还有一个问题需要解决,因为,匹配出来的pointcus不止一个。那么对应的增强又该如何执行呢?也就是增强的执行顺序是怎样的?

C、增强的执行顺序

前面我们讨论过Aspect的顺序,在查找pointcuts之前,我们可以先对Aspect排序。

Container = Container.sort((a,b)=>b.order < a.order)

这样保证了Aspect的顺序,从而就能保证pointcuts的顺序。但advice的的执行顺序是怎样的呢?

从网上查找Spring AOP资料同一个Aspect,advice的大多数的执行顺:

成功执行advice执行顺序:

异常执行advice执行顺序:

多个Aspect中advice执行顺序

从Spring AOP 5.2.7开始,在相同@Aspect类中Spring AOP遵循与AspectJ相同的优先级规则来确定advice执行的顺序。

参考文档信息:

At a particular join point, advice is ordered by precedence.
A piece of around advice controls whether advice of lower precedence will run by calling proceed. The call to proceed will run the advice with next precedence, or the computation under the join point if there is no further advice.
A piece of before advice can prevent advice of lower precedence from running by throwing an exception. If it returns normally, however, then the advice of the next precedence, or the computation under the join pint if there is no further advice, will run.
Running after returning advice will run the advice of next precedence, or the computation under the join point if there is no further advice. Then, if that computation returned normally, the body of the advice will run.
Running after throwing advice will run the advice of next precedence, or the computation under the join point if there is no further advice. Then, if that computation threw an exception of an appropriate type, the body of the advice will run.
Running after advice will run the advice of next precedence, or the computation under the join point if there is no further advice. Then the body of the advice will run.

翻译过来大概的意思:

在同一个JoinPoint多个Aspect的advice执行顺序,遵循如下原则:

Around Advice通过procced方法,控制流程是否继续执行;

Before Advice发生异常会阻止后续的Advice执行。如果Before Advice正常执行,将会执行下一个Aspect里的advices,如果没有其他advice,将会执行原方法;

Advice执行顺序优先级: Around -> Before -> [Method] -> AfterReturning/AfterThrowing -> After -> Around

下面我们用几张流程图标示一下执行顺序:

成功执行流程:

异常执行流程:

多个PointCut执行流程:

按照上面执行流程,同一个JoinPoint中多Aspect中的advices执行顺序,基本符合洋葱模型的执行规则。

回到我们的设计实现,不同于Spring AOP,我们的Advices基于PointCut,执行顺序依然按照上面图中的执行顺序。实现洋葱头模型的方式有很多,比如递归、compose等

下面是递归实现的关键代码:

const executeChain = () => {
    index++
    const pointcut: any = pointcuts[index]

    if (pointcut instanceof PointcutClass) {
       const before = pointcut.findAdvice('before')
       const after = pointcut.findAdvice('after') 
       const around = pointcut.findAdvice('around') 
       const afterThrowing = pointcut.findAdvice('afterThrowing') 
       const afterReturning = pointcut.findAdvice('afterReturning')
       
       if (before) {
          before(joinpint)
       }

       try {
          if (index < len - 1) {
             rst = executeChain()
          } else {
             rst = Reflect.apply(method, thisArg, args)
          }
      } catch (error: any) {
         err = error
      }
    }
    
    // ...此处省略一万字
}

D、Weaving类装饰器

下面是织入Weaving函数的实现语法,完全符合类装饰的模型,同样可以用做类装饰器

const Weaving = (target) => {
    // ...
}

当然,我们还需要为目标类定义命名空间namespace,避免类名称冲突。还需要一些优化功能,比如,不需要匹配的方法的白名单,可以用whitelist排除掉。

所以Weaving需要传参数

const Weaving = ({namespace:string, whitelist: string[]})=>(target) => {
    // ...
}

看一下应用实例:

@Aspect()
class ProtoMethodAspect {
    @Pointcut()
    get pointcut() {
        return 'ProtoMethod.do*'
    }

    @Before({ value: 'pointcut' })
    beforeAction(jp) {
        //...
    }
}

@Weaving({namespace:'xx.com.pages'})
class ProtoMethod {
   doSomething() {
      // do something
   }
}

F、异步处理

前端大部分业务逻辑,大部分场景都是异步方法。异步方法势必会影响Advice的处理结果。至少能保证AfterReturning、AfterThrowing和After三种增强能够拿到正确的结果,而且还有多PointCut的场景,情况更加复杂。因此需要用到异步判断和处理。前端异步应用基本都是Promise或者最终编译成Promise,所以只需判断是否是Promise。

判断是否是Promise的工具函数:

const isPromise = (fn: any) => !!fn && typeof fn.then === 'function' && fn[Symbol.toStringTag] === 'Promise'

然后,针对结果做isPromise判断,如果是Promise,返回new Promise处理逻辑。否则继续执行同步流程。

rst = Reflect.apply(method, thisArg, args)

if(isPromise(rst)){
    return new Promise((resolve, reject)=>{
    
    })
}

// ...

return rst

需要说明,这里只考虑了目标方法的异步情况,并没有考虑Advice本身是否异步。

好了,有关前端AOP的实现先讨论到这里。

完整的源代码可以参考这里

别忘了给个star,你的认可就是老刀动力的源泉哪!

五、参考资料

zhuanlan.zhihu.com/p/266111498

baike.baidu.com/item/AOP/13…

blog.csdn.net/u010502101/…

blog.csdn.net/moreevan/ar…

nullcc.github.io/2019/01/11/…