最近接到了一个需求,需要通过第三方提供的 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) 是否符合预期:
有了 AST,接下来就需要分析我们到底需要里面的什么信息。从前文的 d.ts 到 JS SDK 的例子可以看出,最重要的事情就是要知道 d.ts 里面的两个事情:
- 都定义了什么方法;
- 方法里都传入了什么参数。
通过 AST 可以知道,位于 ClassDeclaration
下的 MethodDeclaration
就是该 d.ts 所定义的一系列方法;而 MethodDeclaration
里面的 Parameter
则定义了方法的参数。
接下来是不是就要去读取 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 的概念来进一步解决逻辑耦合的问题,希望可以给读者一定的启发。