Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何将Node.js库转换到Deno #32

Open
flytam opened this issue May 28, 2022 · 0 comments
Open

如何将Node.js库转换到Deno #32

flytam opened this issue May 28, 2022 · 0 comments
Labels
deno Good for newcomers

Comments

@flytam
Copy link
Owner

flytam commented May 28, 2022


由于Node和Deno的一些差异,一个库要想同时支持Node和Deno是需要一些改造的

本文翻译自EdgeDb博客:https://www.edgedb.com/blog/how-we-converted-our-node-js-library-to-deno-using-deno
如果需要将Deno项目直接迁移为Node项目可参考笔者另一篇文章deno 初体验,实战记录一个node项目迁移到deno需要做什么

Deno是一个新的JavaScript运行时,无需编译即原生支持TypeScript。它是由Node.js作者Ryan Dahl创建的,为了解决Node的一些基本设计、安全漏洞问题并集成了当前的一些开发实践如ES Module和TypeScript

在EdgeDb中,我们建立和维护了一个官方的npm上的Node.js客户端。然而,Deno使用了一套完全不同的实践来处理依赖,即直接从公共包库(如deno.land/x)import路径。我们将寻找一种简单的方法来Deno化我们的代码库。也就是用最简单的重构从现有的Node.js实现中生成一个Deno兼容的模块。这解决维护和同步两个几乎相同的代码库的重复工作带来的问题

我们采用了一种“运行时适配器”模式。这是一种通用的解决方法对其他希望支持Deno库的作者也会有用

Node.js vs Deno

Node.js和Deno有一些重要的区别

  1. TypeScript支持:
    Deno可以直接执行TypeScript而Node.js只能运行JavaScript代码

  2. 模块解析:
    默认情况下,Node.js使用CommonJS导入模块并使用require/module.exports语法。它也有一个复杂的解析算法,会从node_modules加载像react这样的普通模块名,并在无额外扩展名导入时尝试添加.js.json。如果导入路径是一个目录,则导入index.js文件

Deno模块解析逻辑简化了很多。它使用了ECMAScript模块语法进行导入和导出。该语法也被TypeScript使用。所有导入必须是有显式文件扩展名的相对路径或者是一个URL

这意味着不存在像npm或yarn那样有node_module包管理器。外部模块可以通过URL直接从公开代码库导入,比如deno.land/x或GitHub

  1. 标准库:
    Node.js有一些内置的标准模块fscryptohttp。这些包名由Node.js保留。相比之下Deno标准库是通过https://deno.land/std/URL导入的。Node和Deno标准库的功能也不同,Deno放弃了一些旧的或过时的Node.js api,引入了一个新的标准库(受Go的启发),并统一支持现代JavaScript特性如Promise(而许多Node.js api仍然使用老的回调风格)

  2. 内置全局变量:
    Deno所有的核心api都在全局变量Deno中,其它全局变量则只有标准的web api。和Node.js不同的是,Deno没有Bufferprocess这些全局变量

所以需要如何做才能让我们的Node.js库尽可能容易地在Deno中运行呢?下面将一步一步进行改造

TypeScript和模块语法

幸运的是,我们无需考虑将CommonJS的require/module.exports语法转换到到ESMimport/export。我们使用用TypeScript编写edgedb-js,它已经使用了ESM语法。在编译过程中,tsc将我们的文件转换成普通的=CommonJS语法的JavaScript文件。Node.js可以直接运行编译后的文件

本文下面将讨论如何将TypeScript源文件修改为Deno可以直接使用的格式

依赖

edgedb-js没有任何第三方依赖,所以这里不必担心任何三方库的Deno兼容性问题。但仍需要将所有从Node.js标准库中导入(例如pathfs等)替换为等价的Deno文件

注意:如果你的包确实依赖于外部包,可在deno.land/x中查看是否有Deno版本

由于Deno标准库提供了Node.js兼容模块,这个改造比较简单。Deno的标准库上提供了一个包装器并尽可能和Node的api保持一致

- import * as crypto from "crypto";
+ import * as crypto from "https://deno.land/[email protected]/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};

然后在一个名为adapter.deno.ts的文件中为Deno实现相同的适配器

// adapter.deno.ts
import * as crypto from "https://deno.land/[email protected]/node/crypto.ts";
import path from "https://deno.land/[email protected]/node/path.ts";
import util from "https://deno.land/[email protected]/node/util.ts";

export {path, util, crypto};

当需要使用Node.js的特定功能时,直接从adapter.node.ts导入这些功能。通过这种方式,可以通过简单地将所有adapter.node.ts导入重写为adapter.deno.ts即可使edgedb-js兼容Deno。只要确保这些文件重新导出相同的功能就能符合预期

但实际上应该如何重写这些导入呢。这里我们需要开发一个简单的codemod脚本。下面将使用Deno来开发这个脚本

开发Deno-ifier

在开发之前,列举下需要做的事情:

  • 将Node.js风格的导入重写为更显式的Deno风格。包括添加.ts扩展名和目录导入添加/index.ts

  • adapter.node.ts的导入替换成从adapter.deno.ts的导入

  • 注入Node.js全局变量(如processBuffer)到Deno的代码中。虽然可以简单地从适配器导出这些变量,但我们必须重构Node.js文件以显式地导入它们。为了简化处理,将检测代码中使用了Node.js全局变量的时候注入一个导入

  • src目录重命名为_src,表示它只被edgedb-js内部使用不应该被外部直接导入使用

  • 将主入口文件src/index.ts移动到项目根目录并重命名为mod.ts。这是Deno中的习惯用法(这里index.node.ts的命名并不表明它是只能给Node.js使用而是用来区别于index.browser.tsindex.browser.ts导出的是edgedb-js中浏览器兼容的部分代码)

获取所有文件列表

第一步先获取出源文件。Deno原生fs模块提供了walk函数可以实现:

import {walk} from "https://deno.land/[email protected]/fs/mod.ts";

const sourceDir = "./src";
for await (const entry of walk(sourceDir, {includeDirs: false})) {
  // 遍历源文件
}

注意:这里使用的是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;
  // 使用重写规则
  for (const rule of pathRewriteRules) {
    destPath = destPath.replace(rule.match, rule.replace);
  }
  return join(destDir, destPath);
}

这非常简单,下面开始开发修改源代码部分

重写import和export

要重写导入路径需要知道它们在文件中的位置。我们将使用TypeScript的Compiler API来将源文件解析为抽象语法树并找到导入语句

为了实现这个功能我们需要用到typescript NPM包的compile API。Deno的兼容模块提供了一个直接从CommonJS模块导入的方式。需要在执行Deno代码的时候使用--unstable标识,对于构建阶段这不是什么问题

import {createRequire} from "https://deno.land/[email protected]/node/module.ts";

const require = createRequire(import.meta.url);
const ts = require("typescript");

下面遍历文件并依次解析

import {walk, ensureDir} from "https://deno.land/[email protected]/fs/mod.ts";
import {createRequire} from "https://deno.land/[email protected]/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));

  // 如果文件以'.deno.ts'结尾则直接复制无需修改
  if (destPath.endsWith(".deno.ts")) return Deno.writeTextFile(destPath, file);
  // 如果文件以`.node.ts`结尾则跳过
  if (destPath.endsWith(".node.ts")) return;

  // 使用typescript Compiler API解析源文件
  const parsedSource = ts.createSourceFile(
    basename(sourcePath),
    file,
    ts.ScriptTarget.Latest,
    false,
    ts.ScriptKind.TS
  );
}

对于每个AST,我们通过遍历顶层节点找出importexport语句。这里无需深层查找,因为import/export只会出现在顶级作用域(也无需处理动态import(),因为edgedb-js中也没有使用)

从这些节点中,获取源文件中export/import路径的开始和结束偏移量。然后可以通过切片取代路径内容并插入修改后的路径来重写导入

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;

    // 使用Deno版本的导入替换导入适配文件
    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.js风格的引入转化为Deno风格的导入。首先检查路径是否对应于实际文件;如果失败了会尝试添加.ts;如果再失败则尝试添加/index.ts;如果再失败则抛出一个错误。

注入Node.js全局变量

最后一步是处理Node.js全局变量。首先在创建一个global.deno.ts文件。这个文件应该导出包中使用的所有Node.js全局变量的Deno兼容版本

// global.deno.ts
export {Buffer} from "https://deno.land/[email protected]/node/buffer.ts";
export {process} from "https://deno.land/[email protected]/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) {  // 每个文件只执行一次
    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}";\n\n`;

      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) {
  // 重写文件
  await Deno.writeTextFile(destPath, rewrittenFile.join(""));
}

持续集成

一个常见的做法是为包的Deno版本维护一个单独的自动生成的仓库。在我们的例子中,每当一个新的提交合并到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/[email protected]
      - 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 release并发布到deno.land/x。可参考

封装

这就是将现存Node.js模块转换到Deno的通常方法。具体可参考Deno编译脚本workflow

@flytam flytam added the deno Good for newcomers label May 28, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
deno Good for newcomers
Projects
None yet
Development

No branches or pull requests

1 participant