@muyi0327/jsaop

typescript aspect aop

Usage no npm install needed!

<script type="module">
  import muyi0327Jsaop from 'https://cdn.skypack.dev/@muyi0327/jsaop';
</script>

README

jsaop

jsaop是一个前端AOP工具。用于在面向对象编程开发模式中,对目标方法织入通知(Advice)。从而实现业务代码和功能性代码分离解耦。

适用场景

  1. 面向对象开发模式,基于typescript或者es6均可
  2. 埋点、日志、异常收集等需要跟业务逻辑分离的逻辑代码

准备工作

安装

npm i --save jsaop

或者

yarn add --save jsaop

应用

开启decorator支持

  1. ts文件配置tsconfig.json,js文件配置jsconfig.json
{
    "compilerOptions": {
        // ...
        // 启用装饰器
        "experimentalDecorators": true
        // ...
    }
}

  1. 配置babel

babel需要配置decorator和class语法支持,需要用到下面两个plugin

yarn安装plugin


yarn add @babel/plugin-proposal-decorators -D

npm安装plugin


npm i @babel/plugin-proposal-decorators -D

配置信息(.babelrc / babel.config.js / babel.config.js)

@babel/plugin-proposal-decorators需要开启legacy(值为true),同时启用setPublicClassFields、setClassMethods,以支持和使用stage1的decorator语法。

具体可以参考babel官网相关信息

{
    "assumptions": {
        "setClassMethods": true,
        "setComputedProperties":true,
        "setPublicClassFields": true
    },
    "presets": [
        ["@babel/preset-env", {
            "modules": false
        }]
    ],
    "plugins": [
        ["@babel/plugin-proposal-decorators", { "legacy": true }]
    ]
}

1. 基本用法

import {Aspect, Before, After, Around, Pointcut, Weaving} from 'jsaop'

@Aspect()
class TestAspect{
    @Pointcut()
    get pointcut(){
        return 'app.tsa.pages:TestPage.do*'
    }

    @Before({value:'pointcut'})
    beforeAction(jp){
        console.log('before action:',jp)
    }

    @After({value:'pointcut'})
    afterAction(jp, rst, err){
        console.log('after action:', jp, rst, err)
    }
}

@Weaving()
class TestPage{
    doSomeThing(){
        console.log('doSomeThing')
        return 'success'
    }
}

new TestPage().doSomeThing()


2. 支持异步async/await和Promise

import {Aspect, Before, After, Around, Pointcut, Weaving} from 'jsaop'

@Aspect()
class TestAspect{
    //...
}

@Weaving()
class TestPage{
    async doSomeThing(){
        console.log('doSomeThing')
        await return Promise.resolve('success')
    }
}

new TestPage().doSomeThing()


API文档说明


切面(Aspect)

切面由切点(PointCut)和增强(Advice)组成,它既包括了横切逻辑的定义,也包括了连接点(JoinPoint)的定义,AOP就是将切面所定义的横切逻辑织入到切面所制定的连接点中。

这是一个完整的Aspect定义实例,包括切点pointcut,增强beforeAction和afterAction,以及连接点匹配逻辑'app.tsa.pages:TestPage.do*'

@Aspect()
class TestAspect{
    @Pointcut()
    get pointcut(){
        return 'app.tsa.pages:TestPage.do*'
    }

    @Before({value:'pointcut'})
    beforeAction(jp){
        console.log('before action:',jp)
    }

    @After({value:'pointcut'})
    afterAction(jp, rst, err){
        console.log('after action:', jp, rst, err)
    }
}


连接点(JoinPoint)

程序执行的某个特定位置,如某个方法调用前,调用后,方法抛出异常后,这些代码中的特定点称为连接点。通常作为通知(Advice)的参数出现

interface JoinPoint {
    target: any; // 目标类
    args: any[]; // 目标方法参数
    thisArg: any; // this指向,目标类的实例,目标方法的上下文context
    value: any; // 目标方法
    // ...
}

切入点(Pointcut)

每个程序的连接点有多个,如何定位到某个感兴趣的连接点,就需要通过切点来定位。

定义Pointcut语法格式

@Pointcut([type]) 
get [pointcut name](){
    return [pointcut rules]
} 
  1. type:'prototype'|'static'指方法类型,原型方法(prototype)和静态方法(static);
  2. pointcut name切点名称;
  3. pointcut rules匹配规则: 命名空间?:类名.方法名称(namespace?:className.methodName)

例如

@Pointcut('prototype')
get pointcut(){
    return 'app.tsa.pages:TestPage.do*'
}

rules:PointcutRules匹配说明

  1. 匹配类型
type PointcutRuleType = { 
    namespace?: RegExp | string; 
    className: RegExp | string; 
    methodName: RegExp | string 
}

type PointcutRules = string | RegExp | PointcutRuleType | Array<PointcutRuleType | RegExp | string>
  1. string类型匹配

匹配命名空间app.tsa.pages,TestPage类,以do开头的任意方法

'app.tsa.pages:TestPage.do*'

多个匹配规则可以用 && 分割

'app.tsa.pages:TestPage.do* && app.tsa.pages:ArticlePage.submit*'
  1. 正则匹配

任意命名空间(可省),SomeClass类,以do开头的任意方法

/^([\d\w][_./-\w\d]*[:]?)?SomeClass.do[\w\d]+$/
  1. PointcutRuleType匹配
@Pointcut()
get pointcut() {
    return {
        className: 'SomeClass',
        methodName: 'submit*'
    }
}
  1. 支持数组放置上述单个或者多个匹配规则
@Pointcut()
get pointcut() {
    return [
        'app.tsa.pages:TestPage.do*',
        'app.tsa.pages:ArticlePage.submit*',
        /^([\d\w][_./-\w\d]*[:]?)?SomeClass.do[\w\d]+$/
    ]
}
  1. "*"匹配多个字符,"?"匹配单个字符

  2. namespace可以省略,但不建议这么做,因为匹配基于className和methodName,极易发生冲突


通知(Advice)~

  1. 前置通知(Before Advice),语法@Before({value:[pointcut name]}),目标动作执行之前织入通知。 前置通知只有一个参数,即:连接点jp:JoinPoint
@Before({value:'pointcut'})
beforeAction(jp){
    console.log('Before action:',jp)
}

  1. 后置通知(After Advice),语法@After({value:[pointcut name]}),目标动作执行之后织入通知,无论成功,还是发生异常都会执行。 后置通知有三个参数,分别是:连接点jp:JoinPoint, 返回值rst:any, 异常err: Error。
@After({value:'pointcut'})
afterAction(jp, rst, err){
    console.log('After action:',jp, rst, err)
}

  1. 返回结果通知(AfterReturning Advice),语法@AfterReturning({value:[pointcut name]}),目标动作执行成功之后执行 后置通知有两个参数,分别是:连接点jp:JoinPoint, 结果rst:any
@AfterReturning({value:'pointcut'})
afterReturningAction(jp, rst){
    console.log('AfterReturning action:',jp, rst)
}

  1. 异常通知(AfterThrowing Advice),语法@AfterThrowing({value:[pointcut name]}),目标动作执行发生异常后执行 后置通知有两个参数,分别是:连接点jp:JoinPoint, 结果err:Error
@AfterThrowing({value:'pointcut'})
afterThrowingAction(jp, err){
    console.log('AfterThrowing action:',jp, err)
}
  1. 环绕通知(Around Advice),语法@Around({value:[pointcut name]}),目标动作执行发生异常后执行 后置通知有一个参数,即:连接点jp:ProceedJoinPoint。ProceedJoinPoint继承JoinPoint,含有一个procced方法,缓存了目标动作的执行,以及其他通知的执行
interface ProceedJoinPoint extends JoinPoint {
    new(jp: ProceedJoinPointType)
    procced(): any
}

执行jp.procced(),才会触发执行动作

@Around({value:'pointcut'})
aroundAction(jp){
    console.log('Before Around action:',jp)
    let rst = jp.procced()
    console.log('After Around action:',jp)

    return rst
}
  1. 通知执行顺序

正常执行顺序:

Around => Before => target method => AfterReturning => After => Around

发生异常执行顺序:

Around => Before => target method => AfterThrowing => After => Around

Tree Shaking 问题

Aspect类文件和目标类文件,属于隐式的依赖关系,很容易被Tree Shaking清理掉。有几种办法解决这个问题

  1. package.json添加sideEffects清单,使文件不受tree shaking影响
{
    "sideEffects": [
        "./src/aspects/**/*.ts",
        "./src/assets/**/*.js",
        "./src/assets/**/*.scss",
        "./src/assets/**/*.css"
    ],
}

  1. babel-loader添加sideEffects清单, 使文件不受tree shaking影响
module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
        },
        sideEffects:  ['"./src/aspects/**/*.ts"']
      }
    ]
  }

代码压缩开启混淆,造成Pointcut的rules匹配失效问题

  1. 排除掉className和methodName混淆,以Terser为例
const TerserPlugin = require('terser-webpack-plugin')

new TerserPlugin({
    cache: true, // 开启缓存,提升编译速度
    parallel: true, // 开启多进程,提升编译速度
    terserOptions: {
        mangle: true, // 混淆代码
        keep_classnames: true, // 保持classname不混淆(解决AOP动态匹配)
        keep_fnames: true// 函数、方法名称不混淆(解决AOP动态匹配)
    }
})