NodeJS与Rust在打包、发布和依赖上比较

以一个经典的 "Hello world!"示例:展示 Node 和 Rust 之间的一个重要区别:
console.log('Hello world!');

现在用 Node 运行该文件:
$ node hello-word.js
Hello world!
你会在控制台看到 Hello world!

现在,我们将对 Rust 进行同样的处理。创建内容如下的 hello_world.rs:

fn main() {
    println!("Hello world!");
}


Rust 实际上需要一个特殊的入口点来执行代码。这就是我们的 main 函数,正如你所看到的,Rust 中的函数声明与 JavaScript 中的函数声明一样,只是使用了 fn 关键字,而不是 function。

调用函数 main 非常重要,否则 Rust 编译器会出错。

在 Rust 中,我们通常使用 4 个空格来缩进函数内部的代码,而在 JavaScript 项目中通常使用 2 个空格。

Rust 还推荐使用 snake_case 命名方式来命名目录和文件,而我认为 kebab-case 在 JavaScript 项目中最为常见。

  • 现在,请将宏看作是在编译时转换成其他代码的一些代码。最后但并非最不重要的一点是,你需要用双引号""而不是单引号来封装字符串。在 JavaScript 中,创建字符串时使用双引号和单引号并无区别(。
  • 在 Rust 中,双引号创建一个字符串字面量,而单引号创建一个字符字面量,这意味着它只接受一个字符。你可以写 println!("H"); 或 println!('H'); ,两者都能编译,但 println!('Hello World!'); 会出错。

现在用以下命令编译我们的代码:
$ rustc hello_world.rs

你会看到......控制台上什么也没有。取而代之的是在 hello_world.rs 旁边创建了一个名为 hello_world 的新文件。该文件包含我们已编译的代码。你可以像这样运行它:
$ ./hello_world
Hello world!

现在你将在控制台中看到 Hello world!这显示了 JavaScript/Node 与 Rust 之间的根本区别。

Rust 需要编译后才能执行程序。JavaScript 则不需要这一额外步骤,因此 JavaScript 的开发周期有时会更快。

不过,编译步骤会在执行程序前捕获大量错误。这一点非常有用,您可能希望在 JavaScript 中引入类似的正确性检查,例如使用 TypeScript。还有一个很大的好处:我们可以轻松地与其他开发人员共享编译后的 hello_world 程序,而无需他们安装 Rust。

这在 Node 脚本中是不可能实现的。每个想要运行我们的 hello-world.js 的人都需要安装 Node,而且版本必须是我们的脚本所支持的。


包管理器
Node 和 Rust 都与包管理器一起安装。

  • Node 的包管理器称为npm,其包称为Node 模块,其官方网站是npmjs.com
  • Rust 的包管理器称为Cargo,其包称为crates,其官方网站是crates.io

清单文件
包含项目元数据的文件,如名称、版本、依赖项等
package.json在 Node 世界和Cargo.tomlRust 中被调用。
现在,我们将清单文件添加到我们之前创建的“Hello World”示例中。

让我们来看看不带依赖关系的典型 package.json:

{
  "name": "hello-world",
 
"version": "0.1.0",
 
"author": "John Doe <john.doe@email.com> (https://github.com/john.doe)",
 
"contributors": [
   
"Jane Doe <jane.doe@email.com> (https://github.com/jane.doe)"
  ],
 
"private": true,
 
"description": "This is just a demo.",
 
"license": "MIT OR Apache-2.0",
 
"keywords": ["demo", "test"],
 
"homepage": "https://github.com/john.doe/hello-world",
 
"repository": {
   
"type": "git",
   
"url": "https://github.com/john.doe/hello-world"
  },
 
"bugs": "https://github.com/john.doe/hello-world/issues"
}

Cargo.toml 看起来非常相似(除了是 .toml 而不是 .json):

[package]
name = "hello-world"
version =
"0.1.0"
authors = [
"John Doe <john.doe@email.com>",
           
"Jane Doe <jane.doe@email.com>"]
publish = false
description =
"This is just a demo."
license =
"MIT OR Apache-2.0"
keywords = [
"demo", "test"]
homepage =
"https://github.com/john.doe/hello-world"
repository =
"https://github.com/john.doe/hello-world"
documentation =
"https://github.com/john.doe/hello-world"

那么,我们这里有什么呢?两种清单格式的名称和版本字段都是必填字段。

  • npm 假定每个软件包都有一个主要作者和多个贡献者,
  • 而 Cargo 只需填写一个作者数组。对于 Cargo 而言,作者字段是必须填写的。

作为值:

  • 在 npm 中使用名称为 <email> (url) 的字符串,
  • 而在 Cargo 中则使用名称为 <email> 的字符串。(也许将来会添加 (url),但目前 Cargo 中没有人使用它)。

请注意,<email> 和 (url) 是可选的,而且名字不一定是个人。您也可以使用公司名称或类似“我的酷团队”的名称。

如果你不想意外地将模块发布到公共仓库,可以在 npm 中使用 "private":true 或在 Cargo 中使用 publish = false 来实现。你可以选择添加一个描述字段来描述你的项目。(虽然从技术上讲,您可以在描述中使用 MarkDown,但这两个生态系统对 MarkDown 的支持都很不完善,而且大多数时候都无法正确呈现)。

要添加单一许可证,只需写入 "license "即可:在 npm 中为 "MIT",在 Cargo 中为 license ="MIT"。在这两种情况下,值都必须是 SPDX 许可证标识符。如果使用多个许可证,可以使用 SPDX 许可证表达式,如 "license":npm 中为 "MIT OR Apache-2.0",Cargo 中为 license = "MIT OR Apache-2.0"。

您还可以选择添加多个关键字,以便在官方软件源中更容易找到您的软件包。

你可以在两个文件中添加主页和版本库的链接(版本库的格式略有不同)。npm 允许你添加报告 bug 的链接,而 Cargo 允许你添加查找文档的链接。

构建工具
Cargo 可用于构建 Rust 项目,您还可以在 npm 中添加自定义构建脚本。(请记住,在 Node 生态系统中,你并不需要构建步骤,但如果你依赖于 TypeScript 之类的东西,那就需要了。当我在 Node 项目中介绍 TypeScript 时,我会更深入地说明这一点)。

目前,我只是在 package.json 中添加了 main 和 scripts.start 字段:

{
  // ...your previous code
 
"main": "src/index.js",
 
"scripts": {
   
"start": "node src/index.js"
  }
}

主字段指向软件包的入口文件。scripts.start(脚本.启动)是一个约定,如果你想通过调用 $ npm start 来运行你的软件包,它指向的是应该加载的文件:

$ npm start

> hello-world@0.1.0 start /Users/pipo/workspace/rust-for-node-developers/package-manager/meta-data/node
> node src

Hello world!

要忽略 npm 输出,使用 -s(表示 --silent):
$ npm -s start
Hello world!

在本例中,main 中指定的软件包入口文件和调用 $ npm start 时应运行的文件指向同一个文件,但情况并非必须如此。此外,你还可以在名为 bin 的字段中指定多个可执行文件。

另一方面,Cargo 会查找 src/main.rs 文件来构建和/或运行,如果找到了 src/lib.rs 文件,它就会构建一个库,而不同的 crate 都需要这个库。

你可以这样用 Cargo 运行你的 Rust 项目:

$ cargo run
   Compiling hello-world v0.1.0 (/Users/pipo/workspace/rust-for-node-developers/package-manager/meta-data/rust)
    Finished dev [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/hello-world`
Hello world!

要忽略货物输出,使用 -q(表示 --quiet):
$ cargo -q run
Hello world!

你会看到 Cargo 在你的目录中创建了一个新文件:Cargo.lock(它还把你编译好的程序放到了目标目录中)。(Cargo.lock 文件的作用基本上类似于 Node 世界中的 package-lock.json(或者 yarn.lock,如果你使用 yarn 而不是 npm),但也会在构建过程中生成。为了完整起见,让我们也生成一个 package-lock.json:

$ npm install
npm notice created a lockfile as package-lock.json. You should commit this file.
up to date in 0.924s
found 0 vulnerabilities


这应该是你的 package-lock.json:

{
  "name": "hello-world",
 
"version": "0.1.0",
 
"lockfileVersion": 1
}

这应该就是你的 Cargo.lock:

[[package]]
name = "hello-world"
version =
"0.1.0"

如果在项目中使用依赖关系,以确保每个人在任何时候都使用相同的依赖关系(以及依赖关系的依赖关系),那么这两个文件就会变得更加有趣。

在继续之前,让我们对 Cargo.toml 稍作调整,添加 edition = "2018 "一行。这将为我们的软件包添加对 Rust 2018 的支持。版本是一种功能,它允许我们在 Rust 中进行向后不兼容的更改,而无需引入新的主要版本。基本上,你可以选择在每个软件包中加入新的语言特性,你的依赖项可以是不同版本的混合。目前有两种不同的版本可供选择:Rust 2015(默认版本)和 Rust 2018(最新版本)。

发布
在学习如何安装和使用依赖关系之前,我们将实际发布一个软件包,之后我们就可以需要它了。它只会导出一个 Hello world! 字符串。我将这两个包都称为 rfnd-hello-world(rfnd 是 "Rust for Node developers "的缩写)。如果我使用了该功能,我们的模块可能会是这样的:@rfnd/hello-world.Cargo 不支持名称间距,这也是我们有意为之的限制。顺便说一下......即使 Rust 中的文件和目录首选蛇形大小写,Cargo 中的模块名也应按照惯例使用 kebab 大小写。在 npm 世界中,这可能也是最常用的。

在这一步中,我将为我们的 Node 模块引入 TypeScript。虽然目前没有必要,但在接下来的章节中,当我使用类型或 ES2015 模块等现代语言特性时,它将简化 Node 和 Rust 之间的比较。首先,我们需要将 TypeScript 安装为 devDependency,这是一种仅在开发时才需要的依赖,而在运行时则不需要:

$ npm install --save-dev typescript

为了构建项目,我们需要在 package.json 的脚本对象中添加一个新的构建字段,从而调用 TypeScript 编译器 (tsc)。我们还添加了一个 prepublishOnly 条目,它总是在我们发布模块之前运行编译过程:

{
  "scripts": {
   
"build": "tsc --build src",
   
"prepublishOnly": "npm run build"
  }
}

通过使用 --build src,TypeScript 将在 src/ 目录中查找 tsconfig.json,它配置了实际输出。它看起来像这样

{
  "compilerOptions": {
   
"module": "commonjs",
   
"declaration": true,
   
"outDir": "../dist",
   
"sourceMap": true,
   
"declarationMap": true
  }
}

请注意,我们将生成 CommonJS 模块(因为这是一个 Node 项目),它将生成声明文件(以便其他 TypeScript 项目在使用生成的 JavaScript 文件时也能知道我们的类型和接口),所有 JavaScript 和声明文件都将放在一个 dist 文件夹中,最后我们将生成源映射,以便将生成的 JavaScript 映射回原始 TypeScript 代码(对调试很有用)。

这也意味着 package.json 中的 main 字段现在指向 dist/index.js--我们编译过的 JavaScript 代码。我们还添加了一个 typings 字段,用于显示其他模块中存储我们生成的声明的位置。

{
  "main": "dist/index.js",
 
"typings": "dist/index.d.ts"
}

请注意,我们不想将 node_modules 和 dist 提交到版本库,因为这些目录包含外部代码或生成的代码。但请注意!如果你把这些目录放在 .gitignore 中,npm 将不会把它们包含在我们发布的软件包中。这对于 node_modules(无论如何都不会包含)来说没有问题,但没有 dist 的软件包就毫无意义了。实际上,你需要添加一个空的 .npmignore,这样 npm 就会忽略 .gitignore。(你可以使用 .npmignore 来忽略已提交到版本库但不应包含在发布的软件包中的文件和目录。在我们的例子中,包含所有文件就可以了。另外,你也可以在 package.json 的 files 字段中明确列出所有应包含的文件。

有了这些设置,这就是我们在 index.ts 下的实际软件包:
export const HELLO_WORLD = 'Hello world!';

我们导出一个值为 "Hello world!"的常量。这是 ES2015 模块语法,我们用 UPPER_CASES 书写导出的变量名,这是常量的通用约定。调用 $ npm run build 来构建项目。

这就是我们生成的 dist/index.js 的样子:
'use strict';
exports.__esModule = true;
exports.HELLO_WORLD = 'Hello world!';
//# sourceMappingURL=index.js.map

没什么特别的。基本上是用不同的模块语法编写相同的代码。第二行告诉其他工具,它原本是 ES2015 模块。最后一行将我们的文件链接到相应的源代码映射。

生成的声明文件 dist/index.d.ts 也值得一看:

export declare const HELLO_WORLD = 'Hello world!';
//# sourceMappingURL=index.d.ts.map

我们可以看到,TypeScript 可以将 HELLO_WORLD 类型推断为 "Hello world!"。这是一种值类型,在本例中是字符串类型的一种特殊变体,其具体值为 "Hello world!"。

我们不需要明确地告诉 TypeScript 类型,但我们可以这样做。它看起来会是这样的
export const HELLO_WORLD: 'Hello world!' = 'Hello world!';

或者像这样,如果我们只想告诉别人这是一个字符串:
export const HELLO_WORLD: string = 'Hello world!';

这就是我们的模块。

现在需要发布它。你需要在 npmjs.com 创建一个账户。如果你已经创建了账户,就会得到一个类似这样的配置文件。现在调用 $ npm login 并输入新账户的凭据。之后就可以调用 $ npm publish 了;

$ npm publish

> rfnd-hello-world@1.0.1 prepublishOnly .
> npm run build


> rfnd-hello-world@1.0.1 build /Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/node
> tsc --build src

# some output from npm notice...

+ rfnd-hello-world@1.0.1


恭喜您!您成功创建了一个软件包,可以在此查看。

是时候使用 Rust 了!我们首先创建一个 .gitignore,内容如下:
Cargo.lock
target

如前所述,Cargo.lock 的行为类似于 package-lock.json,但 package-lock.json 可以随时提交到版本控制中,而 Cargo.lock 只应提交给二进制项目,而不是库。Npm 会忽略库中的 package-lock.json,但 Cargo 不会这样做。

目标目录会包含生成的代码,因此也会被忽略。

其实这就是我们需要的所有设置。现在进入我们的软件包(位于 src/lib.rs,因为这将是一个库):
pub const HELLO_WORLD: &str = "Hello world!";

让我们逐字逐句地看一遍 Rust 的那行代码:

pub 使我们的变量成为公共变量,这与 JavaScript 中的 export 非常相似,因此它可以被其他包使用。

const :不过,Rust 中的 const 与 JavaScript 中的 const 不同。在 Rust 中,这是一个真正的常量--一个无法更改的值。
在 JavaScript 中,它是一个常量绑定,这意味着我们不能为相同的名称分配另一个值(在本例中,我们的变量名是 HELLO_WORLD)。但如果是非原始值,值本身是可以更改的。(例如,可以使用 const a = { b: 1 }; a.b = 2;)。

与 TypeScript 不同的是,我们需要通过添加 &str 来声明 HELLO_WORLD 的类型,否则会导致编译器出错。Rust 也支持类型推断,但 const 总是需要显式类型注解:

  • &str 读作字符串片段(提醒一下,"Hello world!"读作字符串字面)。
  • 实际上,Rust 还有另一种字符串类型,叫做 String。一个 &str 有固定大小,不能更改,而一个 String 是堆分配的,有动态大小。使用 to_string 方法可以很容易地将 &str 转换为 String,如下所示:"Hello world!".to_string();.我们将在后面的示例中看到更多,但你已经可以看到方法的调用方式与我们在 JavaScript 中使用的方式相同,而且内置类型也带有一些内置方法(例如 JavaScript 中的'hello'.toUpperCase())。

我们现在只需发布我们的新 crate。为此,你需要用 GitHub 账户登录 crates.io/。如果已经登录,请访问账户设置获取 API 密钥。然后调用 cargo login 并传递 API 密钥:
$ cargo login <api-key>

您可以像这样在本地打包您的 Crate,以检查将要发布的内容:
$ cargo package

就像 npm Cargo 会忽略 .gitignore 中的所有目录和文件一样。没关系。在这种情况下,我们不需要忽略更多的文件(或更少)。(如果需要修改,可以按照文档中的说明修改 Cargo.toml)。

现在,我们只需要像这样发布 crate:

$ cargo publish
    Updating crates.io index
   Packaging rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust)
   Verifying rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust)
   Compiling rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust/target/package/rfnd-hello-world-1.0.1)
    Finished dev [unoptimized + debuginfo] target(s) in 1.48s
   Uploading rfnd-hello-world v1.0.1 (/Users/pipo/workspace/rust-for-node-developers/package-manager/publishing/rust)

您的crate 现已发布,可在此处here.查看。

请记住,同一版本的软件包只能发布一次。Cargo 和 npm 也是如此。要再次发布更改后的软件包,还需要更改版本。不引入额外工具的最快方法就是手动更改 package.json 或 Cargo.toml 中的版本值。这两个社区都遵循 SemVer 风格的版本控制(或多或少)。

这可能是你开始发布自己的软件包所需的最基本知识,但我只是触及了皮毛。要了解更多信息,请查看 npm 文档和 Cargo 文档。

现在我们已经发布了两个软件包,可以尝试在其他项目中将它们作为依赖项。

依赖关系
让我们再次从 Node 开始,向您展示如何使用依赖关系。老实说......我们已经使用过依赖关系了,对吗?TypeScript。我们已将其添加到 devDependencies 中,并在每个示例中使用:
$ npm install --save-dev typescript

devDependencies 仅在我们开发 Node 应用程序时需要,运行时不需要。我们将最近发布的软件包作为真正的依赖项。像这样安装
$ npm install --save rfnd-hello-world

您应该在 package.json 中看到以下依赖项:

{
  "devDependencies": {
   
"typescript": "^3.2.2"
  },
 
"dependencies": {
   
"rfnd-hello-world": "^1.0.1"
  }
}

我们还应该修改启动脚本,使其行为类似于 $ cargo run--构建项目并运行它:

{
  "scripts": {
   
"start": "npm run build && node dist",
   
"build": "tsc --build src"
  }
}

最终的 package.json 看起来和我们之前的例子差不多,只是元数据少了一些。我把它展示给你看,这样我们就在同一起跑线上了:

{
  "name": "use-hello-world",
 
"version": "0.1.0",
 
"private": true,
 
"main": "dist/index.js",
 
"typings": "dist/index.d.ts",
 
"scripts": {
   
"start": "npm run build && node dist",
   
"build": "tsc --build src"
  },
 
"devDependencies": {
   
"typescript": "^3.2.2"
  },
 
"dependencies": {
   
"rfnd-hello-world": "^1.0.1"
  }
}

tsconfig.json 只是复制粘贴,未作任何修改。

我们安装了依赖项,现在可以这样使用它们了:
import { HELLO_WORLD } from 'rfnd-hello-world';

console.log(`Required "${HELLO_WORLD}".`);

让我们运行一下示例:

$ npm start

> use-hello-world@0.1.0 start /Users/pipo/workspace/rust-for-node-developers/package-manager/dependencies/node
> npm run build && node dist


> use-hello-world@0.1.0 build /Users/pipo/workspace/rust-for-node-developers/package-manager/dependencies/node
> tsc --build src

Required "Hello world!".

很好。现在我们切换到 Rust。如果没有额外的工具,我们就无法在 Cargo 项目中添加依赖项。因此,我们需要手动将其添加到 Cargo.toml 中名为 [dependencies] 的部分。(关于添加 $ cargo add <package-name> 命令,其作用与 $ npm install --save <package-name> 类似,你可以查看本期内容)。
[dependencies]
rfnd-hello-world = "1.0.1"

一旦我们编译程序,crate 就会自动获取。请注意,使用 1.0.1 实际上就是 ^1.0.1!如果你想要一个非常具体的版本,你应该使用 =1.0.1。

这就是我们的 src/main.rs 的样子:

use rfnd_hello_world::HELLO_WORLD;

fn main() {
    println!("Required: {}.", HELLO_WORLD);
}

请注意,尽管我们的外部板块名为 rfnd-hello-world,但我们使用 rfnd_hello_world 访问它。除了使用 use 关键字进行导入外,你还可以通过 println!() 宏看到字符串插值是如何工作的,其中 {} 是一个占位符,我们将值作为第二个参数传递。(向终端打印实际上相当复杂。阅读本文了解更多信息)。如果你不知道:Node 中的 console.log() 也有类似的行为。我们可以将 console.log(`Required "${HELLO_WORLD}".`); 改写为 console.log('Required "%s".', HELLO_WORLD);。试试看

由于我们只使用了一次 HELLO_WORLD,我们也可以这样编写示例:

fn main() {
    println!("Required: {}.", rfnd_hello_world::HELLO_WORLD);
}

如果 rfnd_hello_world 会暴露多个常量,我们可以使用类似 ES2015 的析构语法。

use rfnd_hello_world::{HELLO_WORLD, SOME_OTHER_VALUE};

fn main() {
    println!("Required: {}.", HELLO_WORLD);
    println!(
"Also: {}.", SOME_OTHER_VALUE);
}

不错。现在测试你的程序:

$ cargo run
    Updating registry `https://github.com/rust-lang/crates.io-index`
   Compiling use-hello-world v0.1.0 (file:
///Users/donaldpipowitch/Workspace/rust-for-node-developers/package-manager/dependencies/rust)
     Running `target/debug/use-hello-world`
Required
"Hello world!".

总结一下:
使用 rfnd_hello_world::HELLO_WORLD;(或使用 rfnd_hello_world::{HELLO_WORLD};进行多次导入)与从 'rfnd-hello-world' 中导入 { HELLO_WORLD } 的效果类似,

但我们也可以将 "import "内联为 println!("Required: {}.", rfnd_hello_world::HELLO_WORLD); 与 console.log(`Required "${require('rfnd-hello-world').HELLO_WORLD}".`); 非常相似。