性能文章>NAPI-RS 是怎么工作的: 从 NAPI 到 Build Script & FFI>

NAPI-RS 是怎么工作的: 从 NAPI 到 Build Script & FFI原创

314823

作者:王舒源

本文预计阅读时长约为 20min

本文为公司内部的分享,部分内容是 live coding 现场编写,需要参考代码示例

完整的代码示例可以在这里找到

前言

对于 NAPI-RS 来说,大家一定已经不陌生了。和 Neon,WASM-Bindgen 相同,它们均是用来生成对于某种 Binding 的工具库,前者 Neon 和 NAPI-RS 基本是同类产品,用于生成和 Node 的 Binding。

Binding 是什么?

这里的 binding 等价于 Language binding,摘录一段维基百科中的描述:

In programming and software design, binding is an application programming interface (API) that provides glue code specifically made to allow a programming language to use a foreign library or operating system service (one that is not native to that language). From Wikipedia

大部分同类型的工具的架构都比较类似,对于 NAPI-RS 来说是这样的:

  • NAPI-SYS:NAPI 的 SYS crate,负责和 Node 通信。社区上通常使用 *-sys 命名这些底层调用的库。
  • NAPI: NAPI crate 则是对 NAPI-SYS 库的上层封装。由于 Sys crate 通常是原生的底层 API,因此基本所有原生库都会存在一个对语言友好的封装,进而降低用户的使用成本与代码的准确性。

为什么 Sys crate 通常和 Wrapper crate 分开存在?

对于 Sys crate 来说,它们的工作是和底层的 lib 相绑定,API 的变化通常不会那么频繁,而对于 wrapper 层来说它们是极易产生 breaking change 的。当 Sys 和 Wrapper 放在同一个 crate 中则非常容易产生 breaking change,如此时进行大版本升级,则可能会导致项目中单独使用 Sys crate 的 Dependency(无论是间接,还是直接)们都需要进行升级,因此这是不合理的。详情见 Semver Compatability

  • NAPI MacroNAPI Macro backend:通常为使用 NAPI 的 Rust API 在编译时生成相关模板代码,解放用户的双手。如 NAPI Macro 还做了一些 TS 类型生成的工作。

对于上层的 crate 我们不会在本篇中做过多的介绍,对于它们来说,更多的重心则是放在“怎么让用户降低开发成本”(例如是基于 Macro 的编译时生成模板代码、对 Promise 类型的封装使得它能够对接 Rust Future等)与“怎么让用户的代码变得更加的安全”(例如:对于某些 Opaque 类型的上层封装)上。

本篇,我们会将更多的目光聚焦于 Sys crate 和 Node 的通信上,因为这是 NAPI 的本质。

总结成一句话来说:NAPI-SYS 和 Node 的通信是建立在 C ABI 之上的 FFI 的调用

C ABI ?

看到这里,有些人可能会对这个概念有一些歧义,我们将会在下方做进一步解释。

我们会以一个简单的 NAPI-SYS crate 的实现作为结束。同时为了让整体的衔接不至于太过僵硬,下方会使用另外一个案例进行具体分析。那么,接下来让我们详细展开。

Build Script |文档

从编译的角度来看,当一个 Package 被编译时,Cargo 会首先编译这个 build script,再进行后续的编译操作。你可以认为 Build Script 只是另一个 Package,并在当前的 Package 编译前先进行了编译。事实上确实如此,我们能在 Target 中找到两组产物,其中一组便是 Build script 的产物。

从事务的角度出发,Build Script 对于 Sys crate 来说,一般会做一些源码编译、lib 搜索相关的事务,而对于 Turbopack 来说,则是进行了注册、代码生成相关的事项。总之,尽管它被叫做 build script,而现实世界中,理论上你可以用来对他做任何事情,甚至是发送一个 HTTP 请求或做一些危及计算机安全的事情,Rust 的 Secure code team 还为此发起了相关是否要做 Build-time Sandbox 的*讨论*

默认情况下,你可以直接在 Package 的根目录中生成一个带有 fn main 的 build.rs 来作为 build script:

// build.rs
fn main() {
 
}

如果在 build script 的执行过程中发生了 panic,则不会对该 Package 进行后续的编译流程。

值得注意的是,在 build script 中的一切 print 操作是不会被打印到 stdio 上的

// build.rs
fn main() {
 println!("hello from build.rs"); // 没有用
}

对于 print 来说,你可以在产物文件夹下 output 文件中找到对应的输出,对于 dbg! 的相关输出则可以在产物文件夹中的 stderr 文件中找到(这个 macro 的本质是 stderr 的 output)

对于为什么不对 print 相关的内容进行控制台的输出,*官方*给出的理由是不想制造更多的噪音。因为我们在 build script 中还有一件大事可以做,那就是调用 print 生成 Cargo Instructions

Cargo Instructions | 文档

这里列举一些常用的 Instructions:

  • cargo:rerun-if-changed=PATH — Tells Cargo when to re-run the script.
  • cargo:rustc-link-arg=FLAG — Passes custom flags to a linker for benchmarks, binaries, cdylib crates, examples, and tests.
  • cargo:rustc-link-lib=LIB — Adds a library to link.
  • cargo:rustc-link-search=[KIND=]PATH — Adds to the library search path.
  • cargo:rustc-cfg=KEY[="VALUE"] — Enables compile-time cfg settings.
  • cargo:rustc-cdylib-link-arg=FLAG — Passes custom flags to a linker for cdylib crates.

除此之外,我们通常也会在 build script 中获取相关 env 字段,常见的有 OUT_DIRCARGO_FEATURE_XXX 等等,这些都可以通过 std::env::var 获得,如果你希望忽略 UTF-8 的校验,则可以用性能更好的 std::env::var_os 达到几乎相同的效果。

这些都是日常开发上基本会用到的相关内容,在这之上,对于一个 Sys crate 的编译来说,我们通常会对本机的 lib 进行查找,从而引导 rustc 完成对该 lib 的 linking。

Pattern

暂时无法在飞书文档外展示此内容

通常情况下,我们会在一个版本号区间内查找系统中存在的 lib 包,如果不存在则进行基于源码的构建。

这里我们可以以 libgit2 作为参考,就不在本文中详细展开了。

Foreign Function Interface (FFI)

A foreign function interface (FFI) is a mechani** by which a program written in one programming language can call routines or make use of services written in another. From Wikepedia

FFI 可以让跨语言的程序之间完成相互的调用。就像 IPC(Inter-process communication)一样需要建立一套 protocol,FFI 同样也是一种满足了约定的规则(如:Calling conventions 等, ABI)的调用,Rust 支持的 ABI 可以在这里找到。由于 C 的 ABI 在同一个平台上是兼容的,因此大部分库都是建立在 C ABI 上的。

ABI 和 C ABI?

ABI(Application Binary Interface) 和 API(Application Programming Interface)非常相似,前者描述了 Binary 的兼容性,这其中包括了各种数据类型的 size 和 alignment、内存布局(Layout)以及系统的调用约定(用来描述例如参数是怎么被传递的等等,例如:x86 calling conventions),甚至包括了 Compiler 等等之间的一致性(Conformance) 等等。更多

在 C 的标准中,其实是没有对 C ABI 标准的定义的。但对于同一个平台,这些基本是可以被认为是一致的,因此我们基本可以认为它们是兼容的。而对于不同的平台来说,它们系统之间的调用约定可能是不一致的,因此我们认为它们是不兼容的。所以我们在描述 C ABI 的兼容性时,都包涵了一个隐式约定:同一平台

FFI

在 Rust 中我们可以这样来声明,extern “abi”:如 extern “C”

extern "C" {
  fn napi_create_object(...)
}

我们不需要手动定义 unsafe,因为 FFI 的调用永远是 unsafe 的。

Reverse FFI

同样的,我们也可以定义对应的 fn 给其他支持该 ABI 的语言调用:

#[no_mangle]
pub extern "C" fn napi_register_module_v1(...) {
  // ...
}

需要注意的是,我们需要添加 no_mangle 的标记。否则对应的 symbol name 会被 mangle,而导致调用方无法寻址。你可以使用 nm 命令验证这一点:

$ nm <path/to/generated-binary> | grep napi

macOS 下 symbol 会带有一个下划线,可以看到_napi_register_module_v1 被包含在 Symbol table 中:

0000000000001650 T _napi_register_module_v1

FFI Safety

有了 ABI 的限制,我们可以得到:只有有限的值类型才可以完成跨 FFI 边界的值传递(通信),就像 IPC protocol 也有特定的数据结构的要求,那么,常用的 C ABI 也是一样,简单来说,C 里面无法表达的数据结构,你就不能通过 FFI 这条 Boundary,同样的对于 Rust 的 Error 也是无法通过 FFI 边界的,etc。

要标记一个值为 C ABI Compatible,可以使用 #[repr(C)],这会让 rustc 开启对应的编译时检查,确保这个类型是 FFI Safe 的:

#[repr(C)]
struct some_data_type {
  foo: [u8;0],
  bar: usize
}

C的范式还限制了 enum 的传递,但可以用 #[repr(u32, i8, etc..)] and #[repr(C)] 来强制将非 C 范式的 enum 拥有特定的 Memory Layout,因此下面两种类型是可以互相 Interop 的:

#[repr(u8)]
pub enum LineStyle {
    Solid,
    Dotted,
    Dashed,
}
enum class LineStyle: uint8_t {
    Solid,
    Dotted,
    Dashed,
}

更多的信息可以在这里找到

Opaque Type

在 FFI 的交互过程中,有很多值是不希望被访问到其实际内容的。对于熟悉 NAPI 可能了解过 External 类型,它是一个 Opaque Type,这个 Opaque Type 将会通过 FFI 调用获取到,再通过 FFI 作为参数进行传递:

napi_status napi_create_external(napi_env env,
                                 void* data, // 需要包裹的值
                                 napi_finalize finalize_cb,
                                 void* finalize_hint,
                                 napi_value* result) // 生成的 JS 类型 External Type
                                 
napi_status napi_get_value_external(napi_env env,
                                    napi_value value, // 这个 JS ExternalType
                                    void** result) // 获取到这个值之前被包裹的 Data                                

得到这两组定义后,我们可以将 data 包裹成一个值做为标志存储在 JS 侧,而 JS 侧是无法感知到内部的数据结构的,一个实际的例子可以参考 NAPI-RS External Type

同样的,我们在 Rust 中也可以定义相关的 Opaque Type:

#[repr(C)]
struct foo_opaque {
 _data: [u8;0],
 _marker: PhantomData<*mut ()> // 标记这个 struct 为 !Send 和 !Sync 的
}

#[no_mangle]
extern "C" fn some_init_function(foo: *const foo_opaque) {
}

这样一来,上述的例子中,在其他语言调用它的时候,你仅能拿到 foo 的指针。

另一个 Opaque Type 的好处在于可以完成类型的区分,我们知道在 C 中,一切任意 Type 的 pointer 都可以用 void 来定义,这在 Rust 中的表示是这样的:

extern "C" fn some_init_function(foo: *const ::std::os::raw::c_void, 
                                 bar: *const ::std::os::raw::c_void) {
  do_something_with_bar(foo); // 可以编译!                                 
}

但当两个 pointer 均为 c_void 时,则无法区分,也就丢失了 rustc 编译时的类型检查,这是我们希望能够避免的。

写一个 *-sys crate

在这一章节,我们将用 libsodium 作为案例编写一个 libsodium-sys,使其能够完成简单的 hasher 的功能。这个 Demo 中将直接使用 rust-bindgen **完成 binding 的生成。 **由于篇幅的关系,我们将不涉及 vendor 时的“从源码构建”。

准备工作

首先需要安装 libsodium

# 通过 brew 安装
$ brew install libsodium

# 通过其他方式进行安装 https://libsodium.gitbook.io/doc/installation

安装完成后可以通过命令验证是否成功:

$ pkg-config --libs libsodium

新建一个 libsodium-sys

Cargo.toml:

[package]
edition = "2021"
name = "libsodium-sys"
version = "0.1.0"

[build-dependencies]
pkg-config = "0.3.1"
bindgen = "0.63.0"
  1. 我们通过 pkg-config 查找系统依赖,它可以自动设置 rustc 依赖的参数
  2. bindgen 用于基于 libsodium 的 header 生成 FFI Binding

定义 wrapper.h

#include "sodium.h"

我们将需要的 header 文件 sodium.h 添加到 wrapper.h,rust-bindgen 将会编译生成 FFI 声明

编写 build script

fn main() {
  // 通过 pkg_config 查找 syslib
  let lib = pkg_config::Config::new()
        .atleast_version("1.0.18")
        .probe("libsodium")
        .unwrap();
        
  println!("cargo:rerun-if-changed=wrapper.h");
  
  // The bindgen::Builder is the main entry point
  // to bindgen, and lets you build up options for
  // the resulting bindings.
  let bindings = bindgen::Builder::default()
    // The input header we would like to generate
    // bindings for.
    .header("wrapper.h")
    .clang_args(
        lib.include_paths
            .iter()
            .map(|p| format!("-I{}", p.display())),
     )
    .allowlist_function("crypto_generichash")
    .allowlist_function("sodium_init")
    .allowlist_var("crypto_generichash_.*")
    // Tell cargo to invalidate the built crate whenever any of the
    // included header files changed.
    .parse_callbacks(Box::new(bindgen::CargoCallbacks))
    // Finish the builder and generate the bindings.
    .generate()
    // Unwrap the Result and panic on failure.
    .expect("Unable to generate bindings");

  // Write the bindings to the $OUT_DIR/bindings.rs file.
  let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
  bindings
    .write_to_file(out_path.join("bindings.rs"))
    .expect("Couldn't write bindings!");
}
  1. 我们通过 pkg-config 查找并 set rustc flags,将 include_paths 添加到 bindgen 的 clang_args 参数
  2. 同时当 wrapper.h 变化时,我们需要重新执行 build script
  3. allow_list 中添加本次 DEMO 需要用到的 fn, const
  4. 最终的 bindings.rs 我们可以在 OUT_DIR 中找到,它是这样的:

编写 binding 并测试

我们可以通过 libsodium 官网的 FFI 定义了解各个字段的作用:Generic hashing

#![allow(unused)]
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]

mod ffi {
  // 内联 bindings.rs 的 codegen 的结果到 mod ffi
  include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}

pub use ffi::*;

测试部分可以参考

Tips

  • 可以使用 pkg-config crate 进行 libs 的查找,查询成功后会自动添加相应的 cargo instructions,省去了手动添加
  • 使用 Bindgen 生成的代码是一个“大杂烩”,可以限制导出的内容,如:使用 allowlist 等
  • 不建议在 sys crate 中编写除 ffi 声明以外的逻辑,避免 breaking change
  • 可以通过 cargo instructions 暴露相关的 metadata 给依赖方,以保持如全局的 lib 版本统一

写一个简单的 napi-sys

在这一章节,我们将创建一个 dynamic library 并调用 napi 完成简单的注册,添加模块导出等功能,并在 Node 中进行测试。

准备工作

我们将会新建两个 crate,第一个 crate 为 napi-sys 用于声明一些 Node 给我们提供的 FFI,完整的 FFI 列表可以参考 N-API 文档。其次,我们将会创建第二个 crate NAPI 用于编写 binding 的测试。

用到的 FFI :

napi_status napi_create_string_utf8(napi_env env,
                                    const char* str,
                                    size_t length,
                                    napi_value* result)
napi_status napi_set_named_property(napi_env env,
                                    napi_value object,
                                    const char* utf8Name,
                                    napi_value value);

用到的 Reverse FFI :

由于当前插件为 dynamic library,我们需要在 crate NAPI 中导出注册的钩子,用于在运行时完成 Module 的注册:

napi_value napi_register_module_v1(napi_env env,
                                   napi_value exports)

用到的返回值:

我们需要在 Rust 侧创建一个 named export,它的 key 为 foo,值为 bar,最终的效果是这样的:

const foo = require("./binding.node").foo;
console.log(foo) // bar

[live-coding]

完整的代码示例:https://github.com/h-a-n-a/build-script-ffi-and-napi

可能遇到的问题

  • 在 Clang(macOS 默认) 中你需要使用 -undefined, dynamic_lookup 来标记 linker symbol 查找的行为(在 Runtime 中查找,-C 表示 codegen flags),否则会产生找不到 Symbol 的编译报错:
[target.x86_64-apple-darwin]
rustflags = [
  "-C", "link-arg=-undefined",
  "-C", "link-arg=dynamic_lookup",
]

[target.aarch64-apple-darwin]
rustflags = [
  "-C", "link-arg=-undefined",
  "-C", "link-arg=dynamic_lookup",
]

图 1.1: LLVM 架构图 https://blog.gopheracademy.com/advent-2018/llvm-ir-and-go/

图 1.2: Linker **https://en.wikipedia.org/wiki/Linker_(computing)

Linker 有什么用?

编译器的架构:Frontend(C -> Clang) -> LLVM Optimizer -> LLVM Backend(图1.1)

Linker 的作用(图 1.2)

  • 由于我们希望生成的是一个基于 C ABI 的 dynamic library,因此需要在 cargo.toml 中标记:
[lib]
crate-type = ["cdylib"]

Tips

  • 可以用 nm 查看 binary 中的 Symbol,如:
                 U _napi_create_string_utf8
00000000000015b0 T _napi_register_module_v1
                 U _napi_set_named_property

T 代表 Global text symbol

U 代表 Undefined symbol,这正是我们期望的,它将会在宿主环境中提供,例如我们可以简单验证 node 中是否定义了 napi_create_string_utf8:

$ nm $(which node) | grep napi_create_string_utf8

我们便能得到对应的 FFI 定义

0000000100087c00 T _napi_create_string_utf8

FFI 的定义和声明的区别是什么?

在上述例子中,我们的 napi-sys crate 仅仅完成了 FFI 的声明,就好比你直接引用了 napi 的 header file,而只有在对应 Node binary 中定义了这些 FFI 后你才能使用。这也是为什么 FFI 永远是 unsafe 的原因之一

  • 可以用 file 查看文件的类型,如:
file binding.node

我们可以得到这是一个 x86_64-apple-darwin(通过 Apple iMac 3.8 GHz 8-Core Intel Core i7 编译) 的 shared library:

binding.node: Mach-O 64-bit dynamically linked shared library x86_64

交叉编译

通常情况而言,一个平台只能编译出当前平台支持的可执行代码,而交叉编译则是想解决跨平台编译的问题。如在 M1(aarch64-apple-darwin) 上编译出 x86_64-linux-gnu 的代码(每一种 compiler 的 triple 写法都不太一样,这里列举了 Rust 的)。Rust 提供了开箱即用的 cross-compilation 支持,你只需要安装 target 对应的 toolchain 即可:

$ rustup target add x86_64-unknown-linux-gnu

然后使用 Cargo build --target 进行编译:

$ cargo build --target x86_64-unknown-linux-gnu

对于编译一个项目来说,仅仅支持不同 target 的标准库是大概率不够用的,对于不同的 target 你也许需要使用不同的 Linker 等,这些都可以在 .cargo/config.toml 文件中定义,详细内容可以参考 The Cargo Book。大致的设置是这样的:

[target.x86_64-unknown-linux-gnu]
linker = "x86_64-unknown-linux-gnu-gcc"

Linux 的一些 C 标准库

  • GNU (glibc)
  • Musl

对于 gnu 输出的一般是动态链接的 binary,需要在使用方的电脑上安装 glibc。而 musl 则是静态链接(你可以认为就是一个 Tree-shaked 过的 Bundle)的,Bundle 的体积会变大一些,但优势在于它不需要任何的 Dependency。

你会发现,如果我们需要 cross-compile 多个平台,则需要完成多个平台的参数的调优(不同的 Compiler 的参数还不太一样),这让人非常头疼。这个时候,Zig cc 可以非常好地帮助我们解决这一系列问题。

Zig

从语言的角度看,Zig 是一个非常轻量级的静态语言,它没有Macro 等等。除此之外,它提供了非常好的 C Interoperability,你甚至可以直接 include 一个 C 的 header。除此之外它还是一个 C/C++ Compiler,底层调用的是 Clang,令人吃惊的是它竟然兼容了 Clang 和 gcc 的编译参数!从原理上来说,它承担了和 Clang 沟通的角色,截获部分需要的指令,如 --target 等,加以处理后交给 Clang 进行后续的编译流程,如:

$ zig cc -target x86_64-linux-gnu ...
⬇️
$ clang xxx

那么如何将 Zig 应用到我们的工作流上呢?首先在任意位置创建一个 zcc 的文件(Zig cc),如:

#!/bin/sh
zig cc -target x86_64-linux-gnu $@

在对应的 .cargo/config.toml 中完成对 linker 的设置:

[target.x86_64-unknown-linux-gnu]
linker = "path/to/zcc"

调用 Cargo build 即可:

$ cargo build --target x86_64-unknown-linux-gnu

测试

如果需要对 binding 进行测试,建议还是 follow docker。推荐所有大型项目,能不用交叉编译就不用,因为最后它们均要完成在各个平台上的测试,以验证编译后的产物的正确性。

Reference

Build Script

https://doc.rust-lang.org/cargo/reference/build-scripts.html

FFI

https://www.youtube.com/watch?v=pePqWoTnSmQ

https://doc.rust-lang.org/nightly/nomicon/other-reprs.html#reprc

https://doc.rust-lang.org/nightly/nomicon/ffi.html#representing-opaque-structs

http://nickdesaulniers.github.io/blog/2016/08/13/object-files-and-symbols/

交叉编译

https://actually.fyi/posts/zig-makes-rust-cross-compilation-just-work/

https://doc.rust-lang.org/cargo/reference/config.html#target

https://rustc-dev-guide.rust-lang.org/backend/codegen.html

https://andrewkelley.me/post/zig-cc-powerful-drop-in-replacement-gcc-clang.html

http://www.aosabook.org/en/llvm.html

点赞收藏
火山引擎开发者服务

火山引擎应用性能监控全链路版,经字节内部众多APP实践验证、提供APP、Web、小程序、服务端、PC、OS端在内的APM服务,通过先进的数据采集技术,为用户优化应用性能助力。

请先登录,查看2条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
3
2

火山引擎应用性能监控全链路版,经字节内部众多APP实践验证、提供APP、Web、小程序、服务端、PC、OS端在内的APM服务,通过先进的数据采集技术,为用户优化应用性能助力。