jrainlau / blog-articles

My personal blog.
https://jrain.vercel.app
40 stars 11 forks source link

基于 AST 的代码自动生成方案 #33

Open jrainlau opened 2 years ago

jrainlau commented 2 years ago

ast

最近接到了一个需求,需要通过第三方提供的 d.ts 文件来定义对应的 JS SDK 文件,其形式如下:

第三方提供的 d.ts 文件:

export class SDK {
  start(account: string);
  close();
  init(id: string): Promise<{ result: number; }>
}

定义出来的 JS SDK 文件:

 // 初始化 wrapper 对象,省略了细节
const wrapper = (wrap) => wrap;

// 定义 JS SDK
const SDK = {
  async start({ account }) {
    return await wrapper.start(account)
  },
  async close() {
    return await wrapper.close(account)
  },
  async init({ id}) {
    return await wrapper.init(id)
  },
}

export default SDK;

在项目初期的时候,我们是根据第三方提供的 d.ts 文件,手动地去撰写 JS SDK。由于这个 d.ts 经常会变动,我们需要不停地同步 JS SDK;同时由于我们的项目是多人维护的,手写的 JS SDK 难免会有许多的冲突,这些问题对于研发效率来说都是不利的。

通过分析 d.ts 及其对应的 JS SDK 可以看出,它们的格式是基本固定的,两者之间也有着非常清晰的对应关系。于是我们可以思考,能不能通过自动化的方法,直接从 d.ts 里生成对应的 JS SDK 呢?

相对简单的思路是逐行分析 d.ts 代码,通过正则等方式去匹配关键字来获得关键信息。这种方式简单粗暴却不够优雅,需要非常复杂的匹配规则才能满足需求,一旦 d.ts 格式有变化,原来的匹配规则也许会直接无法使用,维护成本太高。

若要避免因为格式的变化带来的一系列问题,“抽象”可以说是一种相对更合适的方案。而代码的 AST 就是一种抽象的方式,它能够有效地避免因格式、写法地变化带来的影响,把源码转化成一份可以方便脚本阅读的树状结构数据,以方便后续的操作。

d.ts 的 AST 分析

由于 d.ts 也是一个 typescript 文件,因此我们可以使用 typescript 官方提供的 API 来生成对应的 AST:

// https://ts-ast-viewer.com/
const dTsFile = fs.readFileSync(resolve(__dirname, filePath), 'utf-8')

const sourceFile= ts.createSourceFile(
  'sdk.ts',                       // 自定义一个文件名
  dTsFile,                        // 源码
  ts.ScriptTarget.Latest          // 编译的版本
)

我们也可以借助 https://ts-ast-viewer.com 这个网站来检查生成出来的 sourceFile(AST) 是否符合预期:

image

有了 AST,接下来就需要分析我们到底需要里面的什么信息。从前文的 d.ts 到 JS SDK 的例子可以看出,最重要的事情就是要知道 d.ts 里面的两个事情:

  1. 都定义了什么方法;
  2. 方法里都传入了什么参数。

通过 AST 可以知道,位于 ClassDeclaration 下的 MethodDeclaration 就是该 d.ts 所定义的一系列方法;而 MethodDeclaration 里面的 Parameter 则定义了方法的参数。

image

接下来是不是就要去读取 AST 的节点信息,然后直接生成 JS SDK 呢?答案是否定的。究其原因,如果把“分析 AST”和“生成 JS SDK”的逻辑都耦合在一起的话,由于 AST 节点数量多、类型丰富的特点,可能需要大量的条件判断,最终的逻辑会非常混乱,有一种“看一点做一点”的感觉,反而和逐行读取 d.ts 然后生成 JS SDK 的思路没什么两样。

为了避免这种过于耦合带来的难以维护的问题,我们可以引入“领域特定语言(domain-specific language)(DSL)”。

使用 DSL 来生成 JS SDK

关于 DSL 的定义,可以参考这篇文章《开发者需要了解的领域特定语言(DSL)》。DSL 的定义听起来好像很厉害,其实说白了就是自行定义一种可以承上启下的过渡格式。

在我们的场景中,可以定义一种 JSON 格式的 DSL,用于记录从 AST 中提取出来的关键信息,而后再从这个 DSL 中去生成所需要的 JS SDK 文件。这种方式看起来似乎多了一步工作,增加了工作量,但实际使用下来会发现其对于逻辑的解耦是非常有帮助的,对于后续的维护也是一个极大的利好。

对于我们的例子来说:

export class SDK {
  start(account: string);
  close();
  init(id: string): Promise<{ result: number; }>
}

通过分析其 AST,可以整理成这么一个 DSL:

const DSL = [{
  name: 'start',
  parameters: [{
    name: 'account',
    type: 'string'
  }]
}, {
  name: 'close',
  parameters: []
}, {
  name: 'init',
  parameters: [{
    name: 'id',
    type: 'string'
  }]
}]

DSL 里面清晰记录了方法的名称和参数,如果有需要也可以很方便地往里添加更多的信息,如返回值的类型等等。

接下来就是分析 JS SDK 的格式了:

const wrapper = (wrap) => wrap;

// 定义 JS SDK
const SDK = {
  async start({ account }) {
    return await wrapper.start(account)
  },
  async close() {
    return await wrapper.close(account)
  },
  async init({ id}) {
    return await wrapper.init(id)
  },
}

export default SDK;

由于格式也是固定的,因此只需要准备一个字符串模板,然后遍历 DSL,把组织好的方法填到模板就可以了:

const apiArrStr = DSL.map(api => {
  // 伪代码,省略了信息提取的步骤
  return `
  async ${name}(${params}) { return await wrapper.${name}(${params}) }
  `
})

const template = `
const SDK = {
  ${apiArrStr}
}

export default SDK;
`

return template;

小结

本文介绍了通过 AST 的方式来分析 d.ts 代码,进而自动生成对应的 JS SDK 的方法,同时引入了 DSL 的概念来进一步解决逻辑耦合的问题,希望可以给读者一定的启发。

askmeanythingdao commented 2 years ago

great,thanks