原文:
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 编译脚本,跨工作流。