原文:
www.edgedb.com/blog/how-we…
作者:James Clarke
發布時間:MAY 26, 2022
文章首發于知乎
zhuanlan.zhihu.com/p/524296632 著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
Deno 是新出的 JAVAScript 的運行時,默認支持 TypeScript 而不需要編譯。 Deno 的創作者 Ryan Dahl,同樣也是 Node.js 的創作者,解決了很多 Node 的基礎設計問題和安全漏洞,更好的支持了 ESM 和 TypeScript。
在 EdgeDB 中,我們開發了 Node.js 下的 EdgeDB client 庫 ,并且在 NPM 中發布。然而 Deno 有完全不同的另一套依賴管理機制,imports URL 地址 來引入 在 deno.land/x 上發布的包。我們想找到一條簡單的路 “Denoify” 代碼庫,可以將現有的 Node.js 包 生成 Deno 可以使用的包。這樣可以減輕維護的復雜度。
Node.js vs Deno
Node.js 和 Deno 的運行時有幾個關鍵的區別,我們在調整使用 Node.js 編寫的庫時必須考慮到:
- TypeScript 支持:Deno 可以直接執行 TypeScript 文件,Node.js 只能運行 JavaScript 代碼。
- 模塊解析:默認 Node.js 支持 CommonJS 規范的模塊引入,使用 require/module.exports 語法。同時 Node.js 有一個復雜的依賴解析算法, 當 從 node_modules 中加載像 react 這樣的模塊時,對于 react 導出的內容會自動加后綴,比如說增加 .js 或 .json,如果 import 是目錄的話,將會直接查找目錄下的 index.js 文件。Deno 極大簡化了這一過程。Deno 支持 ESM 模塊規范,支持 import /export 語法。同時 TypeScript 同樣支持這一語法。所有的引入如果不是一個相對路徑包含顯示文件擴展名就是一個 URL 路徑。這表明 Deno 不需要 node_modules 文件或是如 npm 或是 yarn 之類的包管理工具。外部包通過 URL 路徑直接導入,如 deno.land/x 或 GitHub。
- 標準庫:Node.js 有一套內置的標準模塊,如 fs path crypto http 等。這些模塊可以直接通過 require(’fs’) 導入。Deno 的標準庫是通過 URL deno.land/std/ 導入。兩個標準庫的功能也不同,Deno 放棄了一些舊的或過時的 Node.js api,引入了一個新的標準庫(受 Go 的啟發),統一支持現代 JavaScript 特性,如 Promises (而許多 Node.js api 仍然依賴于舊的回調風格)。
- 內置的全局變量:Deno 將核心 API 都封裝在 Deno 變量下,除了這之外 就沒有其他暴露的全局變量,沒有 Node.js 里的 Buffer 和 process 變量。
因此,我們如何才能解決這些差異,并讓我們的 Node.js 庫盡可能輕松運行在 Deno ?讓我們逐一分析這些變化。
TypeScript 和模塊語法
幸運的是,我們不需要太擔心將 CommonJS require/module 語法轉換為 ESM import/export。我們完全用 TypeScript 編寫 edgedb-js,它已經使用了 ESM 語法。在編譯過程中,tsc 利用CommonJS 語法將我們的文件轉換成普通的 JavaScript 文件。Node.js 可以直接使用編譯后的文件。
這篇文章的其余部分將討論如何將 TypeScript 源文件修改為 Deno 可以直接使用的格式。
依賴
幸運的是 edgedb-js 沒有任何第三方依賴,所以我們不必擔心任何外部庫的 Deno 兼容性。然而,我們需要將所有從 Node.js 標準庫中導入的文件(例如 path, fs 等)替換為 Deno 等價文件。
??注意:如果你的程序依賴于外部的包,需要到 deno.land/x 中檢查是否有 Deno 的版本。如果有繼續向下閱讀。如果沒有你需要和庫作者一起努力,將包改為 Deno 的版本。
由于 Deno 標準庫提供了 Node.js 兼容模塊,這個任務變得更加簡單。這個模塊在 Deno 的標準庫上提供了一個包裝器,它試圖盡可能忠實地遵守 Node 的 API。
- import * as crypto from "crypto";
+ import * as crypto from "<https://deno.land/std@0.114.0/node/crypto.ts>";
復制代碼
為了簡化流程,我們將所有引入的 Node.js API 包裝到 adapter.node.ts 文件中,然后重新導出
// adapter.node.ts
import * as path from "path";
import * as util from "util";
import * as crypto from "crypto";
export {path, net, crypto};
復制代碼
同樣 我們在 Deno 中使用相同的方式 adapter.deno.ts
// adapter.deno.ts
import * as crypto from "<https://deno.land/std@0.114.0/node/crypto.ts>";
import path from "<https://deno.land/std@0.114.0/node/path.ts>";
import util from "<https://deno.land/std@0.114.0/node/util.ts>";
export {path, util, crypto};
復制代碼
當我們需要 Node.js 特定的功能時,我們直接從 adapter.node.ts 導入。通過這種方式,我們可以通過簡單地將所有 adapter.node.ts 導入重寫為 adapter.deno.ts 來使 edgedb-js 兼容 deno。只要這些文件重新導出相同的功能,一切都應該按預期工作。
實際上,我們如何重寫這些導入呢?我們需要編寫一個簡單的 codemod 腳本。為了讓它更有詩意,我們將使用 Deno 本身來編寫這個腳本。
寫 Denoify 腳本
首先我們列舉下腳本需要實現的功能:
- 將 Node.js 式 import 轉換為更具體的 Deno 式引入。具體是將引用文件都增加 .ts 后綴,給引用目錄都增加 /index.ts 文件。
- 將 adapter.node 文件中的引用都轉換到 adapter.deno.ts
- 將 Node.js 全局變量 如 process 和 Buffer 注入到 Deno-ified code。雖然我們可以簡單地從適配器導出這些變量,但我們必須重構 Node.js 文件以顯式地導入它們。為了簡化,我們將檢測在哪里使用了 Node.js 全局變量,并在需要時注入一個導入。
- 將 src 目錄重命名為 _src,表示它是 edgedb-js 的內部文件,不應該直接導入
- 將 main 目錄下的 src/index.node.ts 文件都移動到項目根目錄,并重命名為 mod.ts。注意:這里的 index.node.ts 并不表明這是 node 格式的,只是為了區分 index.browser.ts
創建一系列文件
首先,我們需要計算源文件的列表。
import {walk} from "<https://deno.land/std@0.114.0/fs/mod.ts>";
const sourceDir = "./src";
for await (const entry of walk(sourceDir, {includeDirs: false})) {
// iterate through all files
}
復制代碼
??注意:我們這里使用的是 Deno 的 std/fs,而不是 Node 的 std/node/fs。
讓我們聲明一組重寫規則,并初始化一個 Map,它將從源文件路徑映射到重寫的目標路徑。
const sourceDir = "./src";
+ const destDir = "./edgedb-deno";
+ const pathRewriteRules = [
+ {match: /^src/index.node.ts$/, replace: "mod.ts"},
+ {match: /^src//, replace: "_src/"},
+];
+ const sourceFilePathMap = new Map<string, string>();
for await (const entry of walk(sourceDir, {includeDirs: false})) {
+ const sourcePath = entry.path;
+ sourceFilePathMap.set(sourcePath, resolveDestPath(sourcePath));
}
+ function resolveDestPath(sourcePath: string) {
+ let destPath = sourcePath;
+ // Apply all rewrite rules
+ for (const rule of pathRewriteRules) {
+ destPath = destPath.replace(rule.match, rule.replace);
+ }
+ return join(destDir, destPath);
+}
復制代碼
以上部分比較簡單,下面開始修改源文件。
重寫 imports 和 exports
為了重寫 import 路徑,我們需要知道文件的存放位置。幸運的是 TypeScript 曝露了 編譯 API,我們可以用來解析源文件到 AST,并查找 import 聲明。
我們需要從 typescript 的 NPM 包中 import 編譯 API。幸運的是,Deno 提供了引用 CommonJS 規范的方法,需要在運行時 添加 --unstable 參數
import {createRequire} from "<https://deno.land/std@0.114.0/node/module.ts>";
const require = createRequire(import.meta.url);
const ts = require("typescript");
復制代碼
讓我們遍歷這些文件并依次解析每個文件。
import {walk, ensureDir} from "<https://deno.land/std@0.114.0/fs/mod.ts>";
import {createRequire} from "<https://deno.land/std@0.114.0/node/module.ts>";
const require = createRequire(import.meta.url);
const ts = require("typescript");
for (const [sourcePath, destPath] of sourceFilePathMap) {
compileFileForDeno(sourcePath, destPath);
}
async function compileFileForDeno(sourcePath: string, destPath: string) {
const file = await Deno.readTextFile(sourcePath);
await ensureDir(dirname(destPath));
// if file ends with '.deno.ts', copy the file unchanged
if (destPath.endsWith(".deno.ts")) return Deno.writeTextFile(destPath, file);
// if file ends with '.node.ts', skip file
if (destPath.endsWith(".node.ts")) return;
// parse the source file using the typescript Compiler API
const parsedSource = ts.createSourceFile(
basename(sourcePath),
file,
ts.ScriptTarget.Latest,
false,
ts.ScriptKind.TS
);
}
復制代碼
對于每個已解析的 AST,讓我們遍歷其頂層節點以查找 import 和 export 聲明。我們不需要深入研究,因為 import/export 總是 top-level 語句(除了動態引用 dynamic import(),但我們在 edgedb-js中不使用它們)。
從這些節點中,我們提取源文件中 import/export 路徑的開始和結束偏移量。然后,我們可以通過切片當前內容并插入修改后的路徑來重寫導入。
const parsedSource = ts.createSourceFile(/*...*/);
+ const rewrittenFile: string[] = [];
+ let cursor = 0;
+ parsedSource.forEachChild((node: any) => {
+ if (
+ (node.kind === ts.SyntaxKind.ImportDeclaration ||
+ node.kind === ts.SyntaxKind.ExportDeclaration) &&
+ node.moduleSpecifier
+ ) {
+ const pos = node.moduleSpecifier.pos + 2;
+ const end = node.moduleSpecifier.end - 1;
+ const importPath = file.slice(pos, end);
+
+ rewrittenFile.push(file.slice(cursor, pos));
+ cursor = end;
+
+ // replace the adapter import with Deno version
+ let resolvedImportPath = resolveImportPath(importPath, sourcePath);
+ if (resolvedImportPath.endsWith("/adapter.node.ts")) {
+ resolvedImportPath = resolvedImportPath.replace(
+ "/adapter.node.ts",
+ "/adapter.deno.ts"
+ );
+ }
+
+ rewrittenFile.push(resolvedImportPath);
}
});
rewrittenFile.push(file.slice(cursor));
復制代碼
這里的關鍵部分是 resolveImportPath 函數,它實現了將 Node 類型的引入改為 Deno 類型的引入 。首先,它檢查路徑是否對應于磁盤上的實際文件;如果失敗了,它會嘗試添加 .ts 后綴;如果失敗,它嘗試添加 /index.ts;如果失敗,就會拋出一個錯誤。
注入 Node.js 全局變量
最后一步是處理 Node.js 全局變量。首先,我們在項目目錄中創建一個 global .deno.ts 文件。這個文件應該導出包中使用的所有 Node.js 全局變量的兼容版本。
export {Buffer} from "<https://deno.land/std@0.114.0/node/buffer.ts>";
export {process} from "<https://deno.land/std@0.114.0/node/process.ts>";
復制代碼
編譯后的 AST 提供了一組源文件中使用的所有標識符。我們將使用它在任何引用這些全局變量的文件中注入 import 語句。
const sourceDir = "./src";
const destDir = "./edgedb-deno";
const pathRewriteRules = [
{match: /^src/index.node.ts$/, replace: "mod.ts"},
{match: /^src//, replace: "_src/"},
];
+ const injectImports = {
+ imports: ["Buffer", "process"],
+ from: "src/globals.deno.ts",
+ };
// ...
const rewrittenFile: string[] = [];
let cursor = 0;
+ let isFirstNode = true;
parsedSource.forEachChild((node: any) => {
+ if (isFirstNode) { // only run once per file
+ isFirstNode = false;
+
+ const neededImports = injectImports.imports.filter((importName) =>
+ parsedSource.identifiers?.has(importName)
+ );
+
+ if (neededImports.length) {
+ const imports = neededImports.join(", ");
+ const importPath = resolveImportPath(
+ relative(dirname(sourcePath), injectImports.from),
+ sourcePath
+ );
+ const importDecl = `import {${imports}} from "${importPath}";nn`;
+ const injectPos = node.getLeadingTriviaWidth?.(parsedSource) ?? node.pos;
+ rewrittenFile.push(file.slice(cursor, injectPos));
+ rewrittenFile.push(importDecl);
cursor = injectPos;
}
}
復制代碼
寫文件
最后,我們準備將重寫的源文件寫入目標目錄中的新主目錄。首先,我們刪除所有現有的內容,然后依次寫入每個文件。
+ try {
+ await Deno.remove(destDir, {recursive: true});
+ } catch {}
const sourceFilePathMap = new Map<string, string>();
for (const [sourcePath, destPath] of sourceFilePathMap) {
// rewrite file
+ await Deno.writeTextFile(destPath, rewrittenFile.join(""));
}
復制代碼
持續集成
一個常見的模式是為包的 Deno 版本維護一個單獨的自動生成的 repo。在我們的例子中,每當一個新的提交合并到 master 中時,我們就在 GitHub Actions 中生成 edgedb-js 的 Deno 版本。然后,生成的文件被發布到名為 edgedb-deno 的姊妹存儲庫。下面是我們的工作流文件的簡化版本。
# .github/workflows/deno-release.yml
name: Deno Release
on:
push:
branches:
- master
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout edgedb-js
uses: actions/checkout@v2
- name: Checkout edgedb-deno
uses: actions/checkout@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: edgedb/edgedb-deno
path: edgedb-deno
- uses: actions/setup-node@v2
- uses: denoland/setup-deno@v1
- name: Install deps
run: yarn install
- name: Get version from package.json
id: package-version
uses: martinbeentjes/npm-get-version-action@v1.1.0
- name: Write version to file
run: echo "${{ steps.package-version.outputs.current-version}}" > edgedb-deno/version.txt
- name: Compile for Deno
run: deno run --unstable --allow-env --allow-read --allow-write tools/compileForDeno.ts
- name: Push to edgedb-deno
run: cd edgedb-deno && git add . -f && git commit -m "Build from $GITHUB_SHA" && git push
復制代碼
edgedb-deno 內部的另一個工作流會創建一個 GitHub 發布,發布一個新版本到 deno.land/x。這留給讀者作為練習,盡管您可以使用我們的工作流作為起點。
總結
這是一個可廣泛應用的模式,用于將現有的 Node.js 模塊轉換為 Deno 模塊。參考 edgedb-js repo獲得完整的 Deno 編譯腳本,跨工作流。