最近接到了一個需求,需要通過第三方提供的 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 的概念來進一步解決邏輯耦合的問題,希望可以給讀者一定的啟發。