查看原文
其他

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

王舒源 ByteDance Web Infra 2024-03-29

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

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

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

前言

对于 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(https://doc.rust-lang.org/cargo/reference/semver.html

  • NAPI Macro 与 NAPI 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

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

从编译的角度来看,当一个 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 的讨论 (https://tonyarcieri.com/rust-in-2019-security-maturity-stability#sandboxing-for-code-classprettyprintbuildrsco_2)

默认情况下,你可以直接在 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 相关的内容进行控制台的输出官方(https://github.com/rust-lang/cargo/issues/985#issuecomment-64697754)给出的理由是不想制造更多的噪音。因为我们在 build script 中还有一件大事可以做,那就是调用 print 生成 Cargo Instructions

Cargo Instructions 

文档:https://doc.rust-lang.org/cargo/reference/build-scripts.html#outputs-of-the-build-script

这里列举一些常用的 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_DIR,CARGO_FEATURE_XXX 等等,这些都可以通过 std::env::var 获得,如果你希望忽略 UTF-8 的校验,则可以用性能更好的 std::env::var_os 达到几乎相同的效果。

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

Pattern


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

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

libgit2:https://github.com/rust-lang/git2-rs/blob/c5765efabe7dfe5758f875d136ecbf77133d3c95/libgit2-sys/build.rs

Foreign Function Interface (FFI)

foreign function interface (FFI) is a mechanism 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 可以在 https://doc.rust-lang.org/nomicon/ffi.html#foreign-calling-conventions 找到。由于 C 的 ABI 在同一个平台上是兼容的,因此大部分库都是建立在 C ABI 上的。

🤔 ABI 和 C ABI?

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

  • size 和 alignment:https://doc.rust-lang.org/reference/type-layout.html#size-and-alignment

  • x86 calling conventions:https://en.wikipedia.org/wiki/X86_calling_conventions#List_of_x86_calling_conventions

  • 更多:https://web.mit.edu/rhel-doc/3/rhel-gcc-en-3/compatibility.html

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

FFI

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

  • extern "abi":https://doc.rust-lang.org/reference/items/external-blocks.html#abi

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,
}

更多信息:https://doc.rust-lang.org/nomicon/other-reprs.html#reprc

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(https://napi.rs/docs/concepts/external

同样的,我们在 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 (https://github.com/rust-lang/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(https://libsodium.gitbook.io/doc/hashing/generic_hashing#usage)

#![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::*;

测试部分可以参考:https://github.com/h-a-n-a/build-script-ffi-and-napi/blob/7244774dcbe34aa16bd504b1285cedef775aa2e1/crates/libsodium-sys/src/lib.rs

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 的测试。

N-API 文档:https://nodejs.org/api/n-api.html

用到的 FFI :

  • napi_create_string_utf8
(https://nodejs.org/api/n-api.html#napi_create_string_utf8)
napi_status napi_create_string_utf8(napi_env env,
                                    const char* str,
                                    size_t length,
                                    napi_value* result)
  • napi_set_named_property
(https://nodejs.org/api/n-api.html#napi_set_named_property)
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_register_module_v1:现在 Register 的版本号为 1,可以参考:https://fossies.org/dox/node-v16.19.0/node__api_8h.html#abbbc1d8ba3fc88c2143eaaaf841cb1ba 
    (https://fossies.org/dox/node-v16.19.0/node__api_8h.html#adcddab11624d90d09d3ac22fa486a812)
napi_value napi_register_module_v1(napi_env env,
                                   napi_value exports)

用到的返回值:

  • napi_status: NAPI 调用成功与否,0 为成功
(https://nodejs.org/api/n-api.html#napi_status)

我们需要在 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,如:
    (nm:https://www.ibm.com/docs/en/aix/7.2?topic=n-nm-command)
                 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(https://doc.rust-lang.org/cargo/reference/config.html#target)。大致的设置是这样的:

[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

继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存