基於 AST 的程式碼自動生成方案

jrainlau發表於2021-11-03

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

相關文章