Rust + WebAssembly 全链路解析

第14章 组件模型:可组合的 WASM 架构

作者 杨艺韬 · 19,310 字 · 发布于 · 更新于

"The whole is greater than the sum of its parts." — Aristotle

14.1 为什么需要组件模型

WASM MVP 规范定义的模块(Module)是一个功能封闭的单元:它导入和导出 i32/i64/f32/f64 类型的函数,通过线性内存交换数据。两个不同语言编写的 .wasm 模块要互操作,只能在线性内存中传递字节——和 C ABI 一样原始。

这不是一个抽象的问题——它直接阻止了 WASM 成为"通用软件单元":

Python 编译的 WASM 模块不能调用 Rust 编译的 WASM 模块的 String 参数函数——因为两者的 String 内存布局不同。Go 的 WASM 模块不能直接接收 C++ 的 WASM 模块传来的 std::vector——Go 的 slice 和 C++ 的 vector 是不同的内存表示。JavaScript 的 WASM 模块想把一个 JSON 对象传给 Rust 的 WASM 模块,只能手动序列化成字节——两边各自解析,各自处理对齐和布局。

graph TD
  subgraph "没有组件模型:线性内存交换"
    A[Rust 模块] -->|"i32 指针 + i32 长度"| B[Python 模块]
    C[Go 模块] -->|"i32 指针 + i32 长度"| D[C++ 模块]
    Note1["每种语言有自己的<br/>内存布局约定<br/>手动协商极易出错"]
  end
  subgraph "有组件模型:WIT 接口类型"
    E[Rust 组件] -->|"WIT: string"| F[Python 组件]
    G[Go 组件] -->|"WIT: list&lt;u8&gt;"| H[C++ 组件]
    Note2["Canonical ABI 规范化<br/>内存布局<br/>自动编解码"]
  end
  style A fill:#dc2626,color:#fff
  style B fill:#dc2626,color:#fff
  style C fill:#dc2626,color:#fff
  style D fill:#dc2626,color:#fff
  style E fill:#10b981,color:#333
  style F fill:#10b981,color:#333
  style G fill:#10b981,color:#333
  style H fill:#10b981,color:#333

组件模型的目标:定义一种语言无关的接口类型系统,让不同语言编写的 WASM 组件可以通过高级类型(字符串、列表、记录、变体)互操作,而不需要手动处理内存布局。

这个目标背后是 WASM 的核心愿景——WASM 不只是一个编译目标,而是一个虚拟指令集架构(virtual ISA)。就像 x86_64 上的 C ABI 让 C/Rust/Go/Python 可以通过 FFI 互操作,WASM 的组件模型定义了跨语言的"虚拟 ABI"——Canonical ABI。

14.2 组件模型规范的核心概念

组件与核心模块的关系

组件(Component)是核心模块(Core Module)的包装。核心模块就是 Core Spec 定义的普通 .wasm——它的导入导出只认 i32/i64/f32/f64 这类核心值类型。组件在核心模块外包了一层封装,把低级的 i32 参数/返回值提升(lift)为高级的 WIT 类型(string、list<u8>、result<T, E>),把高级类型降级(lower)回 i32 参数。

flowchart TD
  subgraph "组件(Component)"
    A["导入: wasi:cli/environment<br/>(WIT 类型签名)"]
    B["导出: calculator:calculator/calculator<br/>(WIT 类型签名)"]
    C["Canon Section:<br/>Canonical ABI 适配器"]
    D["Core Module Section:<br/>裸 .wasm 模块"]
  end
  A --> C
  B --> C
  C --> D
  C -->|"lower: string → (ptr, len)"| D
  D -->|"lift: (ptr, len) → string"| C
  style C fill:#f59e0b,color:#333
  style D fill:#6366f1,color:#fff

lift 和 lower 是组件模型的两个核心操作:

  • lower:把 WIT 类型转换为核心 WASM 值。例如 string → (i32 ptr, i32 len),list<u8> → (i32 ptr, i32 len),f64 → f64(值类型直接传递)
  • lift:把核心 WASM 值转换回 WIT 类型。例如 (i32 ptr, i32 len) → string,i32 handle → resource

组件的二进制格式在核心模块的 .wasm 外增加了多个段(section):

Component ::= magic + version(0x0d 0x00) + layer(0x01 0x00)
              section*

// 主要的组件段(完整列表见规范 design/mvp/Binary.md):
// - Core Module Section:   内嵌的裸 .wasm 核心模块
// - Core Instance Section: 实例化内嵌的核心模块
// - Type Section:          组件级类型声明(WIT 编码后的形态)
// - Import Section:        导入的接口(带组件级类型签名)
// - Export Section:        导出的接口(带组件级类型签名)
// - Instance Section:      实例化内嵌的子组件
// - Alias Section:         别名(跨实例引用导出)
// - Canon Section:         canon lift / canon lower 等定义

Canon Section 是组件格式的核心——它声明"把哪个核心函数 lift 成组件函数、把哪个组件函数 lower 成核心函数",并附上 memory、realloc、string-encoding、post-return 这些选项。至于每种类型具体怎么打平、在线性内存里怎么布局,由 Canonical ABI 规范统一规定:string 如何在线性内存中布局、list<T> 如何传递、resource 的句柄如何管理。

14.3 WIT:WebAssembly 接口类型语言

WIT(Wasm Interface Type,规范 design/mvp/WIT.md 的叫法)是组件模型的接口定义语言——它用声明式语法定义组件之间的接口契约。WIT 不是一个编程语言——它不包含实现逻辑,只描述"接口长什么样"。

接口(Interface)

接口是一组类型和函数的声明:

// calculator.wit
interface calculator {
    record expression {
        left: f64,
        operator: operator,
        right: f64,
    }

    enum operator {
        add,
        subtract,
        multiply,
        divide,
    }

    variant error {
        division-by-zero,
        overflow(string),
        invalid-expression(string),
    }

    eval: func(expr: expression) -> result<f64, error>;

    resource history {
        // WIT 没有 void —— 无返回值就直接省略 `->`
        // 参数名不能叫 `result`:它是 WIT 关键字,要用只能写成 `%result`
        add: func(expr: expression, value: f64);
        last: func() -> option<expression>;
        clear: func();
    }
}

WIT 的类型系统比 WASM MVP 的 i32/i64/f32/f64 丰富得多——它提供了现代编程语言常见的类型构造:

WIT 类型 Rust 映射 Python 映射 说明
u8…u64 u8…u64 int 无符号整数
s8…s64 i8…i64 int 有符号整数
f32, f64 f32, f64 float 浮点
bool bool bool 布尔
char char str(单字符) Unicode 标量值
string String str UTF-8 字符串
list<T> Vec<T> list[T] 列表
tuple<T, U> (T, U) tuple[T, U] 元组
record { ... } struct dataclass 记录/结构体
variant { ... } enum(带数据) Union 变体/联合
enum { ... } C-like enum Enum 简单枚举
option<T> Option<T> Optional[T] 可空
result<T, E> Result<T, E> Union[T, E] 结果
resource struct + impl class 有方法和生命周期的类型
flags bitflags IntFlag 位标志集合

注意 variant 和 enum 的区别:enum 是简单枚举(无关联数据),variant 是带关联数据的标签联合——和 Rust 的 enum 等价。WIT 把它们分开是因为很多语言(C、Go)的 enum 不支持关联数据。

世界(World)

世界定义一个组件的完整接口——它导入什么、导出什么。世界是组件的"契约":

// calculator-world.wit
world calculator-app {
  // 导入:需要宿主或另一个组件提供的能力
  import wasi:cli/exit;
  import wasi:filesystem/types;
  import logging:logging/logging;

  // 导出:本组件提供给消费者的能力
  export calculator:calculator/calculator;
  export version: func() -> string;
}

注意 import/export 作用在接口这一级,不是包这一级——跨包引用必须写成 namespace:package/interface 的完整三段式(logging:logging/logging 的最后一段才是接口名),只写 logging:logging 是解析不过的。同包内的接口才可以直接写接口名(import foo;)。

世界是组件实例化的前提——Wasmtime 在实例化组件时,必须为每个 import 提供实现,才能成功实例化。缺少任何一个导入,实例化都会失败。

graph TD
  subgraph "World: calculator-app"
    A["import wasi:cli/exit"] --> C[组件实现]
    B["import logging:logging/logging"] --> C
    D["import wasi:filesystem/types"] --> C
    C --> E["export calculator:calculator/calculator"]
    C --> F["export version() -> string"]
  end
  style C fill:#6366f1,color:#fff
  style A fill:#f59e0b,color:#333
  style B fill:#f59e0b,color:#333
  style D fill:#f59e0b,color:#333
  style E fill:#10b981,color:#333
  style F fill:#10b981,color:#333

世界的设计体现了一个重要原则:依赖即能力声明。组件声明 import wasi:filesystem/types,意味着它需要文件系统能力。宿主可以审查这个声明,决定是否授权——如果组件声称只是计算器却导入了文件系统,这就是一个安全信号。

包(Package)

包是 WIT 的命名空间单元——它把接口和世界组织到一个可版本化的单元中:

package example:calculator@1.0.0;

interface calculator { ... }
world calculator-app { ... }

包名遵循 namespace:name@version 格式——namespace 是组织名,name 是包名,version 是语义化版本。WASI 的所有接口都在 wasi 命名空间下:wasi:cli、wasi:filesystem、wasi:http 等。

包的版本化设计支持渐进演进——example:calculator@1.0.0 和 example:calculator@2.0.0 可以共存,消费者按版本引用。这解决了 WASI Preview 1 的"单体不可版本化"问题——Preview 2 的每个接口包可以独立版本化。

14.4 Canonical ABI:接口类型的内存布局

Canonical ABI 是组件模型的关键规范——它定义了 WIT 类型在线性内存中的精确布局,让不同语言的实现可以互操作。没有 Canonical ABI,每个语言的运行时会按自己的约定编码字符串和列表——互操作不可能。

字符串的 Canonical ABI

string 在线性内存中的布局:

地址      内容
ptr       ┌─────────────────────┐
          │ UTF-8 字节序列        │  ← len 字节
          │ ...                   │
          └─────────────────────┘

作为参数(打平后放得下时):
  参数 1: ptr (i32)   — 字符串起始地址
  参数 2: len (i32)   — 长度(以码元计)

作为返回值(MAX_FLAT_RESULTS = 1,放不下 ptr、len 两个值):
  导出函数(canon lift): 核心函数只返回一个 i32,
                          指向被调方内存里存着 [ptr, len] 的 8 字节返回区
  导入函数(canon lower): 调用方多传一个 retptr 参数,被调方把 [ptr, len] 写进去

字符串通过 (ptr, len) 对传递,len 是码元(code unit)数而不是字符数。编码不是写死的:canon lift/lower 的 string-encoding 选项可以是 utf8、utf16 或 latin1+utf16(CanonicalABI.md),两侧编码不同时由运行时转码;Rust 的 wit-bindgen 产物用的是 utf8,此时 len 就是 UTF-8 字节数。这和 wasm-bindgen 的字符串传递方式很像——但 Canonical ABI 是规范化的、语言无关的,任何语言的绑定生成器都按这套规则编码/解码字符串。

一个关键细节:字符串的内存所有权。组件的线性内存互不共享,跨边界传字符串一定是复制:参数要写进被调方的内存,所以 canon lift 必须带上被调方的 realloc(wit-bindgen 导出的 cabi_realloc),由运行时调用它在被调方内存里分配空间,这块内存之后归被调方所有、由它自己释放。返回值则反过来——被调方把结果写在自己的内存里,运行时把它 lift 出来(跨组件时再用调用方的 realloc 复制进调用方内存,这份拷贝归调用方),然后调用被调方的 post-return 函数,让它释放原来那块内存。用 Rust 1.96 + wit-bindgen 0.57.1 编一个 fss: func(s: string) -> string 导出,wasm-tools print 看到的正是这套约定:核心函数签名是 (param i32 i32) (result i32),对应的定义是 (canon lift (core func ...) (memory $memory) (realloc $cabi_realloc) string-encoding=utf8 (post-return $cabi_post_...))。

列表的 Canonical ABI

list<T> 在线性内存中的布局:

地址      内容
ptr       ┌─────────────────────┐
          │ T[0]                 │  ← align(T) 对齐
          │ T[1]                 │
          │ ...                   │
          │ T[len-1]             │
          └─────────────────────┘

函数参数传递(lower):
  参数 1: ptr (i32)   — 列表起始地址
  参数 2: len (i32)   — 元素数量

列表的布局和字符串类似——连续的元素序列,通过 (ptr, len) 传递。起始地址按 alignment(T) 对齐,元素之间按 elem_size(T) 步进(elem_size 已经向上取整到对齐)。对于 list<u8>,每个元素 1 字节无对齐要求;对于 list<f64>,每个元素 8 字节、起始地址 8 字节对齐,元素之间没有 padding——padding 只会出现在元素类型内部(例如 record { a: u8, b: u32 } 的 elem_size 是 8)。

记录(record)的 Canonical ABI

record point {
    x: f64,
    y: f64,
}

record user {
    name: string,
    age: u32,
    active: bool,
}
point 在线性内存中的布局:

偏移 0:  x (f64, 8 bytes, align 8)
偏移 8:  y (f64, 8 bytes, align 8)
总计: 16 bytes, 对齐: 8

user 在线性内存中的布局:

偏移  0: name.ptr  (i32, 4 bytes, align 4)
偏移  4: name.len  (i32, 4 bytes)
偏移  8: age       (u32, 4 bytes)
偏移 12: active    (bool, 1 byte)
偏移 13: padding   (3 bytes, 对齐到 4)
总计: 16 bytes, 对齐: 4

字段按声明顺序排列,每个字段按自身对齐要求对齐,末尾填充到整体对齐。和 C 的 struct 布局规则一致——但 Canonical ABI 的对齐规则是规范化的,不依赖编译器的 repr(C) 或 repr(Rust)。

Variant 和 Result 的 Canonical ABI

variant shape {
    circle(f64),
    rectangle(tuple<f64, f64>),
    triangle(list<f64>),
}

result<f64, string>
variant shape 在线性内存中的布局:

偏移 0:  discriminant (u8, 1 byte) — 0=circle, 1=rectangle, 2=triangle
偏移 1:  padding (7 bytes)          — 对齐到 max_case_alignment = 8
偏移 8:  payload                    — 最大变体的 payload 大小(联合体)

  circle:    discriminant=0, payload=f64 (8 bytes, align 8)
  rectangle: discriminant=1, payload=(f64, f64) (16 bytes, align 8)
  triangle:  discriminant=2, payload=(ptr, len) (8 bytes, align 4)

payload 大小 = max(8, 16, 8) = 16 bytes,对齐 = max(8, 8, 4) = 8
总计 = align_to(align_to(1, 8) + 16, 8) = 24 bytes, 对齐 8

result<f64, string> 在线性内存中的布局:

偏移 0:  discriminant (u8, 1 byte) — 0=ok, 1=err
偏移 1:  padding (7 bytes)         — payload 的对齐是 max(align(f64)=8, align(string)=4) = 8
偏移 8:  payload                   — T 或 E,只占用一份空间(联合体)

  OK 情况:  discriminant=0, payload=f64 (8 bytes)
  ERR 情况: discriminant=1, payload=(ptr, len) for string (8 bytes)

总计 = align_to(align_to(1, 8) + 8, 8) = 16 bytes, 对齐 8

注意 discriminant 的宽度**不是固定 4 字节**:规范的 discriminant_type()
取「能装下 case 数的最小整数类型」——不超过 256 个 case 就是 u8,
不超过 65536 是 u16,再往上才是 u32。上面两个例子的 case 数都很少,
所以 discriminant 只占 1 字节,后面那一大段是对齐填充。
payload 的起始偏移 = align_to(discriminant 大小, 所有 case 的 payload 对齐取最大值)。
如果 E 换成 `u8`,payload 对齐仍是 8(因为 T 是 f64),布局不变;
如果 T、E 的对齐都是 4(如 u32、string),payload 就落在偏移 4;
都是 u8 这类 1 字节对齐的类型,就紧挨着落在偏移 1。

注意:result 的 payload 是联合体——OK 和 ERR 共享同一块内存空间。思路上和 Rust 的 Result 类似(tag + 联合体),但 Rust 默认的 repr(Rust) 布局不作保证、还会做 niche 优化;Canonical ABI 的规范化之处在于 discriminant 的位置固定在偏移 0、宽度由 case 数唯一确定(discriminant_type(),见 CanonicalABI.md),而不是留给各语言实现自选。option 和 result 只有两个 case,落在 u8 那一档——wit-bindgen 生成 lower 代码时对这两种类型直接写死 Int::U8(传给 write_variant_arms_to_memory 的参数,crates/core/src/abi.rs:2045 与 2057 行),而 Int::U8 对应的存指令就是单字节的 I32Store8(crates/core/src/abi.rs:2365)。

参数与返回值什么时候走内存

lift/lower 并不总是经过线性内存——参数先被"打平"(flatten)成一串核心值类型,只有打平后太长才退回内存:

  • 参数:打平后的核心值不超过 MAX_FLAT_PARAMS = 16 个时直接当函数参数传;超过就把整个参数元组写进线性内存,只传一个指针。
  • 返回值:MAX_FLAT_RESULTS = 1——只有单个核心值能直接返回。放不下时分两种情况(规范 flatten_functype):canon lift 的导出函数改为返回一个指向自己内存里返回区的指针;canon lower 的导入函数则由调用方多传一个返回区指针(retptr)交给被调方写。

两个常量的规范定义在 component-model 规范仓库的 canonical-abi 定义文件里;wit-bindgen 侧同样把参数上限写成 const MAX_FLAT_PARAMS: usize = 16;(crates/core/src/abi.rs:1006,紧邻的 MAX_FLAT_ASYNC_PARAMS 是 4,用于异步 guest import)。第 15 章讲的"导出函数改为返回指针、返回值含堆内存时再配一个 post-return 负责释放"就是从这里来的。

Canonical ABI 的调用约定汇总

以 §14.3 的 eval 为例:用 Rust 1.96 + wit-bindgen 0.57.1 编成 wasm32-wasip2 组件后,wasm-tools print 显示它的核心导出签名是 (param f64 i32 f64) (result i32),外面那层 canon lift 带着 post-return。

flowchart LR
  subgraph "消费者(调用方)"
    A["WIT: eval(expr: expression) -> result&lt;f64, error&gt;"]
  end
  subgraph "lower(参数进入核心模块)"
    B["expression 打平为 (f64, i32, f64)<br/>3 个核心值 ≤ 16,直接当参数传"]
  end
  subgraph "核心模块(被调用方)"
    C["(param f64 i32 f64) (result i32)<br/>计算结果<br/>把 result&lt;f64, error&gt; 写进自己内存的返回区<br/>返回返回区指针"]
  end
  subgraph "lift(结果回到调用方)"
    D["从返回区读出 result<br/>转换为 WIT 类型 result&lt;f64, error&gt;<br/>再调 post-return 释放"]
  end
  A --> B --> C --> D
  style B fill:#f59e0b,color:#333
  style C fill:#6366f1,color:#fff
  style D fill:#f59e0b,color:#333

与 Serde 的对比

Canonical ABI 和《Serde 元编程》专栏第 2 章的 Data Model、第 12 章的 Serialize 代码生成解决的是同一类问题——"类型到字节序列的规范化转换":

  • Serde:Rust 类型 → Serializer trait → JSON/Bincode/MessagePack 等格式——用于 Rust 内部的序列化
  • Canonical ABI:WIT 类型 → 线性内存布局 ——用于跨语言边界的类型传递

关键差异:Serde 的输出格式是可选择的(JSON 是文本格式、Bincode 是二进制格式),Canonical ABI 的输出格式是规范化的(没有选择余地——每种 WIT 类型只有一种内存布局)。这是因为 Serde 的消费者通常是同语言的(Rust 写入 → Rust 读取),而 Canonical ABI 的消费者可以是任意语言——标准化比灵活性更重要。

另一个差异:Serde 的 Serializer/Deserializer 是 trait——通过泛型分派实现零开销抽象。Canonical ABI 的 lift/lower 在 guest 侧是编译时生成的代码——通过 wit-bindgen 过程宏展开;Wasmtime 宿主侧的类型化接口靠 Lift/Lower trait 的泛型实现;组件与组件之间的调用,则由 Wasmtime 在编译组件时生成"融合适配器"(fused adapter,crates/environ/src/fact.rs 的 FACT 编译器)一次完成 lift + 复制 + lower。

14.5 组件组合:从多个组件构建应用

组件模型的核心价值——多个独立开发的组件可以通过 WIT 接口连接,而不需要知道彼此的实现语言。

组件链接的模型

graph TD
  A["组件 A: 日志服务<br/>export logging:logging/logging"] -->|"logging:logging/logging"| B["组件 B: 计算器<br/>import logging:logging/logging<br/>export calculator:calculator/calculator"]
  B -->|"calculator:calculator/calculator"| C["宿主应用<br/>import calculator:calculator/calculator"]
  style A fill:#6366f1,color:#fff
  style B fill:#22d3ee,color:#333
  style C fill:#10b981,color:#333

组件 B 声明 import logging:logging/logging——它不关心日志服务是 Rust 写的、Python 写的还是 Go 写的。它只关心日志服务导出了 logging:logging/logging 这个接口——有 log(message: string, level: log-level) 函数。组件 B 被实例化之前,这个导入必须有人满足——要么在构建期用组合工具接上组件 A 的导出,要么由宿主在 Wasmtime 的 Linker 里提供实现。

Wasmtime 的组合实例化

组件之间的连接有两条路径,不要把它们混在一起:

路径一:构建期组合(推荐)。用 wac plug 这类组合工具把"计算器组件"和"日志组件"拼成一个组件,组合后的产物对外只剩下计算器的导出,日志导入已经在内部接好了。宿主拿到的就是一个普通组件,正常实例化即可:

use wasmtime::component::{Component, Linker};
use wasmtime::{Engine, Result, Store};

fn run_composed() -> Result<()> {
    let engine = Engine::default();
    let mut linker = Linker::<MyState>::new(&engine);
    wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;

    // composed.wasm 是构建期把 calculator + logger 组合好的单个组件
    let composed = Component::from_file(&engine, "composed.wasm")?;
    let mut store = Store::new(&engine, MyState::new()); // 示意:MyState 的定义与构造方式见 §14.7
    let _instance = linker.instantiate(&mut store, &composed)?;
    Ok(())
}

路径二:宿主实现导入。如果日志能力由宿主而不是另一个组件提供,就在 Linker 上按接口名注册宿主函数。注意 Linker::instance 的签名是 instance(&mut self, name: &str) -> Result<LinkerInstance<'_, T>>(Wasmtime 44 的 crates/wasmtime/src/runtime/component/linker.rs:159)——它返回的是一个"待填充的实例命名空间",不是"把某个已实例化的组件塞进去";随后挂函数用的 func_wrap 在同文件 419 行:

let mut linker = Linker::<MyState>::new(&engine);
linker
    .instance("logging:logging/logging")?     // 接口的完整名字
    .func_wrap("log", |_store, (msg,): (String,)| {
        eprintln!("{msg}");
        Ok(())
    })?;

Wasmtime 的组件 Linker 里没有"把 A 组件的实例注册给 B 组件用"的一步式 API——跨组件连接要么在构建期由组合工具完成,要么由宿主自己搭桥(在 func_wrap 里回调已实例化的 A)。这就是组件组合:两个独立开发的组件通过接口契约连接,不需要知道彼此的内部实现。

组合的类型安全保证

组件模型保证了组合的类型安全——这和动态语言中的"鸭子类型"接口有本质区别。

无论是组合工具在构建期把组件 A 的导出接到组件 B 的导入上,还是 Wasmtime 实例化时用 Linker 里的定义去满足导入,都会检查:

  1. 接口名匹配:A 导出的接口名必须和 B 导入的接口名一致(包括命名空间;带版本号时,Wasmtime 的 Linker 允许 semver 兼容的版本互相满足,见第 13 章)
  2. 类型签名匹配:函数参数类型和返回类型必须一致——record 连字段名和顺序都要逐个对上(Wasmtime 44 crates/wasmtime/src/runtime/component/func/typed.rs:2393)
  3. 资源类型匹配:如果接口中包含 resource,resource 的方法签名必须一致

任何一个不匹配,实例化都会失败——错误发生在启动时,而不是运行时的某个奇怪调用路径上。这和 Rust 的 trait bound 在编译时检查类型安全是同一个思路——但组件模型的检查发生在组件实例化时(因为组件是二进制级别的组合,不是源码级别的组合)。

14.6 组件注册表:warg

组件组合需要组件共享——需要一个类似 npm/crates.io 的包管理生态。warg(WebAssembly Registry)是字节码联盟开发过的组件注册表协议和参考实现。先说结论:字节码联盟已停止开发 warg——bytecodealliance/registry 仓库已归档,README 顶部写明后续工作转到基于 OCI 的 wasm-pkg-tools(2026-09-24 核对)。下面介绍它的设计,是因为"签名 + 不可变 + 命名空间所有权"这套思路仍然是组件分发要解决的问题。

warg 的设计

warg 的核心概念:

  • 包(Package):一组相关的组件和 WIT 定义,按语义化版本发布
  • 发布(Publish):把组件二进制 + WIT 定义上传到注册表
  • 依赖(Dependency):在 WIT 中引用另一个包的接口
# 安装 warg CLI
cargo install warg-cli

# 先初始化包(创建包日志),再发布一个版本(写法取自 warg README)
warg publish init example:calculator
warg publish release --name example:calculator --version 1.0.0 my-calculator.wasm

# 授权另一把公钥也能发布
warg publish grant --name example:calculator ecdsa-p256:ABC...

warg 的安全模型和 WASI 的能力安全一脉相承:

  • 签名验证:每个包由发布者的私钥签名,消费者验证签名——防止供应链篡改
  • 不可变性:已发布的版本不可修改——example:calculator@1.0.0 一旦发布,内容永远不变
  • 权限控制:包的命名空间有所有者——只有所有者可以发布新版本

warg 与其他包管理器的对比

维度 crates.io npm warg
语言 Rust JavaScript 语言无关(WASM 组件)
包格式 .crate(源码) .tgz(JS 源码) .wasm(组件二进制 + WIT)
类型检查 Rust 编译器 TypeScript(可选) WIT 接口(强制)
发布模型 不可变 不可变 不可变 + 签名验证
组合方式 编译时链接 运行时 require/import 运行时组件实例化

warg 的关键差异:它分发的是编译后的组件二进制——不是源码。这意味着消费者不需要编译组件——只需要下载 .wasm 文件并实例化。这降低了组合的门槛——Python 消费者不需要安装 Rust 工具链来使用 Rust 编写的组件。

由于 warg 已停止开发,本节的命令行只应当作"协议长什么样"的示意,不要据此写进生产流程。今天真实项目更常见的做法是把组件当成普通 OCI 制品推到自己的镜像仓库,字节码联盟这边的工具是 wasm-pkg-tools(其 CLI 叫 wkg)。

14.7 实战:构建一个多语言组件系统

用一个完整的例子演示组件模型的跨语言互操作——Rust 实现核心逻辑,Python 实现业务规则,宿主用 Wasmtime 编排。

第一步:定义 WIT 接口

// wit/math.wit
package example:math@1.0.0;

interface core {
    // result<T, E> 里的 E 必须是本作用域可见的类型,所以先把它定义出来
    variant error {
        overflow(string),
        invalid-input(string),
    }

    factorial: func(n: u64) -> result<u64, error>;
    fibonacci: func(n: u64) -> result<u64, error>;
}

interface business {
    use core.{error};

    process-order: func(order-id: string, quantity: u64) -> result<string, error>;
}

world math-core {
    export core;
}

world business-logic {
    import core;
    export business;
}

math-core 世界导出 core 接口(数学计算)。business-logic 世界导入 core 接口(需要数学计算),导出 business 接口(业务逻辑)。

第二步:Rust 实现核心计算

// crates/math-core/src/lib.rs
use wit_bindgen::generate;
generate!("math-core");

// variant error 生成在接口模块里,要和 Guest 一起导入
use exports::example::math::core::{Error, Guest};

struct MathCore;

impl Guest for MathCore {
    fn factorial(n: u64) -> Result<u64, Error> {
        if n > 20 {
            return Err(Error::Overflow("Input too large".to_string()));
        }
        Ok((1..=n).product())
    }

    fn fibonacci(n: u64) -> Result<u64, Error> {
        if n > 93 {
            return Err(Error::Overflow("Input too large".to_string()));
        }
        let (mut a, mut b) = (0u64, 1u64);
        for _ in 0..n {
            let temp = a;
            a = b;
            b = temp.wrapping_add(b); // n = 93 时最后一轮算的是 fib(94),会溢出但不被返回
        }
        Ok(a)
    }
}

export!(MathCore);

编译:

cd crates/math-core
# Cargo.toml 需 crate-type = ["cdylib"]、依赖 wit-bindgen = "0.57";
# generate! 默认读 crate 根目录下的 wit/,所以要把第一步的 wit 目录放到(或链接到)这里
cargo build --target wasm32-wasip2 --release
# 产物: target/wasm32-wasip2/release/math_core.wasm

第三步:Python 实现业务逻辑

Python 不能直接编译到 WASM 组件——但可以通过 componentize-py 把 CPython 解释器和 Python 代码一起打包成组件:

# crates/business-logic/app.py
# componentize-py 生成的绑定模块默认叫 wit_world,分 imports / exports 两组
from wit_world import exports
from wit_world.imports import core

# 实现类必须和导出接口同名(这里是 Business),否则 componentize 时报
# AttributeError: module 'app' has no attribute 'Business'
class Business(exports.Business):
    # WIT 的 u64 在 Python 侧就是 int;err 分支通过抛异常表达
    def process_order(self, order_id: str, quantity: int) -> str:
        # 调用 Rust 实现的数学函数
        fact = core.factorial(quantity)
        fib = core.fibonacci(quantity)
        return f"Order {order_id}: factorial={fact}, fibonacci={fib}"
# 用 componentize-py 把 Python 代码编译为 WASM 组件
# -d 指 WIT 目录,-w 指 world 名,最后是模块名(不带 .py)
componentize-py -d wit -w business-logic componentize app -o business_logic.wasm

第四步:Wasmtime 宿主编排

先在构建期把两个组件组合成一个——business_logic.wasm 的 import example:math/core@1.0.0 被接到 math_core.wasm 的同名导出上,产物 app.wasm 的业务导出只剩 example:math/business@1.0.0(componentize-py 还会额外导出一个它自己用的 exports 接口,另外保留两个组件各自的 WASI 导入):

# 用 wac plug 把 plug(提供导出的组件)插进 socket(有待满足导入的组件)
wac plug business_logic.wasm --plug math_core.wasm -o app.wasm

# 组合结果自检:导入项里应该已经看不到 example:math/core
wasm-tools component wit app.wasm

以上用 wac-cli 0.11.0、componentize-py 0.25.1、wasm-tools 1.246.1 实跑通过。

宿主这边就只是普通地实例化一个组件:

// src/main.rs
use wasmtime::component::{Component, ComponentType, Lift, Linker, Lower, ResourceTable};
use wasmtime::{Engine, Result, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};

struct MyState { ctx: WasiCtx, table: ResourceTable }

// WIT 的 variant error 在宿主侧要有一个对应的 Rust 类型(bindgen! 会自动生成它)
#[derive(ComponentType, Lift, Lower, Debug)]
#[component(variant)]
enum Error {
    #[component(name = "overflow")]
    Overflow(String),
    #[component(name = "invalid-input")]
    InvalidInput(String),
}

impl WasiView for MyState {
    fn ctx(&mut self) -> WasiCtxView<'_> {
        WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
    }
}

fn main() -> Result<()> {
    let engine = Engine::default();
    let mut linker = Linker::<MyState>::new(&engine);
    wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;

    // 组合后的单个组件
    let component = Component::from_file(&engine, "app.wasm")?;

    let mut builder = WasiCtx::builder();
    builder.inherit_stdout();
    let mut store = Store::new(
        &engine,
        MyState { ctx: builder.build(), table: ResourceTable::new() },
    );

    let instance = linker.instantiate(&mut store, &component)?;

    // 组件导出的是接口,不是顶层函数:先按接口名取实例,再取函数。
    // 参数/返回值是 WIT 类型,用 wasmtime::component::bindgen! 生成的类型化绑定更稳妥
    let idx = instance
        // 导出名带着 package 的版本号;写成 "example:math/business" 会找不到
        .get_export_index(&mut store, None, "example:math/business@1.0.0")
        .expect("导出接口不存在");
    let func_idx = instance
        .get_export_index(&mut store, Some(&idx), "process-order")
        .expect("导出函数不存在");
    let process_order = instance
        .get_typed_func::<(String, u64), (Result<String, Error>,)>(&mut store, func_idx)?;

    let (result,) = process_order.call(&mut store, ("ORD-001".to_string(), 10))?;
    match result {
        Ok(s) => println!("{s}"),
        Err(e) => eprintln!("业务错误: {e:?}"),
    }
    // 输出: Order ORD-001: factorial=3628800, fibonacci=55

    Ok(())
}

几个细节值得记住:组件导出的函数在接口命名空间之下,得先 get_export_index 拿到接口再拿函数,而且接口名要带上 package 的版本号;get_typed_func 要求结果类型是一个元组(Wasmtime 的 ComponentNamedList 约束,crates/wasmtime/src/runtime/component/instance.rs:188),WIT 函数现在最多只有一个返回值,所以就是一元组;WIT 里自定义的 variant 在宿主侧要有一个用 #[derive(ComponentType, Lift, Lower)] 标注的对应类型。另外,Python 里 core.factorial 返回 err 时会抛出 Err 异常,没捕获就一路传到导出函数,被 componentize-py 转成 WIT 层的 err——把数量改成 25,宿主打印的是 业务错误: Overflow("Input too large")。整套宿主代码用 Wasmtime 44.0.0 实编实跑,输出与注释一致。真实项目里应该用 wasmtime::component::bindgen! 从 WIT 生成类型化绑定(连 Error 类型都会自动生成),而不是手写这些索引查找。

flowchart TD
  subgraph "宿主 (Wasmtime)"
    A["main.rs<br/>实例化组合后的组件"]
  end
  subgraph "组件 A: math-core (Rust)"
    B["factorial(10) → 3628800"]
    C["fibonacci(10) → 55"]
  end
  subgraph "组件 B: business-logic (Python)"
    D["process_order('ORD-001', 10)"]
    D -->|"import core"| B
    D -->|"import core"| C
    D --> E["'Order ORD-001: factorial=3628800, fibonacci=55'"]
  end
  A -->|"实例化 app.wasm<br/>(两个组件已在构建期由 wac 接好)"| D
  A -->|"调用 process-order"| D
  style A fill:#10b981,color:#333
  style B fill:#6366f1,color:#fff
  style C fill:#6366f1,color:#fff
  style D fill:#22d3ee,color:#333
  style E fill:#10b981,color:#333

这个例子展示了组件模型的核心承诺——语言无关的互操作。Rust 组件提供高性能的数学计算,Python 组件提供灵活的业务逻辑,宿主用 Wasmtime 编排——三者通过 WIT 接口契约连接,不需要知道彼此的实现语言。

14.8 与 wasm-bindgen 的关系

组件模型和 wasm-bindgen 是两种不同的互操作方案——它们解决不同场景的问题:

维度 wasm-bindgen 组件模型
互操作对象 Rust ↔ JavaScript 任意语言 WASM ↔ 任意语言 WASM
类型系统 Rust 类型 → JS 类型 WIT 类型 → 多语言类型
内存管理 Rust 管理线性内存,JS 管理 GC 堆 Canonical ABI 统一管理
适配代码 JS 胶水代码 Canonical ABI + wit-bindgen
适用场景 浏览器 服务器/边缘/嵌入式
标准化 社区事实标准 W3C WebAssembly CG 的 Phase 1 提案(尚非 W3C 标准)
类型检查 运行时(JS 是动态类型) 实例化时(WIT 是静态类型)

组件模型不会取代 wasm-bindgen——至少短期内不会。浏览器场景中,JS 仍然是宿主,而浏览器引擎并不能直接实例化组件——组件要先用 jco 的 transpile 转译成核心模块 + ES 模块胶水才能跑,比直接用为 JS 量身定做的 wasm-bindgen 多绕一层。但组件模型为"非浏览器"场景提供了 wasm-bindgen 无法提供的语言无关互操作。

组件模型要在浏览器里成为主流,需要浏览器引擎原生支持组件实例化和 Canonical ABI——而组件模型在 CG 里还停在 Phase 1(§14.11),离浏览器引擎落地还远;WASI Preview 3 带来的是原生 async,并不是浏览器绑定。短期内,浏览器场景继续用 wasm-bindgen,服务器/边缘场景用组件模型,是最务实的选择。

14.9 资源类型的生命周期管理

WIT 的 resource 类型是组件模型中最复杂的概念——它代表有状态的、有生命周期的对象。理解资源的生命周期对于正确使用组件模型至关重要。

资源的本质

资源不是数据——它是句柄。一个 resource history 在 WIT 层面是一个不透明的句柄——消费者不知道它的内部表示,只能通过它的方法操作它。这和面向对象编程中的"对象"概念一致——但资源的跨语言传递需要规范化的句柄协议。

在 Canonical ABI 中,资源表示为一个 i32 句柄值——它是组件运行时为每个组件实例维护的句柄表(handle table)里的下标。表项里记的是资源类型和实现方交给运行时的 rep(一个 i32,Rust 的 wit-bindgen 用它存指向实现对象的指针),由此找到真正的实现对象。规范里下标 0 是保留的(Table.__init__ 里 self.array = [None]),所以有效句柄从 1 开始:

flowchart TD
  subgraph "消费者组件"
    A["let h: History = History::new()"]
    B["h.add(expr, result)"]
    C["h.last()"]
    D["h.drop()"]
  end
  subgraph "句柄表(组件运行时)"
    E["handle 1 → rep → MyHistory { entries: [...] }"]
    F["handle 2 → rep → MyHistory { entries: [...] }"]
    G["handle 3 → TcpSocket(宿主资源)"]
  end
  subgraph "实现组件(Rust)"
    H["MyHistory::new() → Self"]
    I["MyHistory::add(&self, ...)"]
    J["MyHistory::last(&self) → Option"]
    K["Drop for MyHistory"]
  end
  A -->|"分配句柄"| E
  B -->|"handle 1"| I
  C -->|"handle 1"| J
  D -->|"释放句柄"| K
  style E fill:#f59e0b,color:#333
  style F fill:#f59e0b,color:#333
  style G fill:#f59e0b,color:#333

句柄表的安全保证

句柄表有几个关键的安全保证:

首先,句柄是不可伪造的。句柄只是本实例句柄表里的下标,消费者组件只能通过函数调用拿到合法句柄——随便编一个 i32 传进去,要么查不到表项、要么资源类型对不上,运行时都会直接 trap(规范 Table.get 里的 trap_if)。

其次,句柄是作用域化的。每个组件实例有独立的句柄表——组件 A 的句柄 1 和组件 B 的句柄 1 指向不同的对象。跨组件传递资源时,组件运行时会在两个句柄表之间做映射:传 own<T> 是所有权转移——源组件的句柄被移除,目标组件分配新句柄;传 borrow<T> 则是临时借用——目标组件只拿到一个在本次调用结束前必须归还的借用句柄。

再次,句柄的生命周期是确定的。当消费者丢弃自己持有的 own 句柄时(wit-bindgen 生成的 Rust 类型在 Drop 里调用 resource.drop),组件运行时从句柄表移除条目,并调用实现方的析构函数——Rust 实现侧就是 Drop::drop。但别指望 trap 之后还有清理:组件 trap 后该实例不能再被进入,实现方的析构函数也就不会再跑;宿主侧的资源随 Store 一起销毁。

资源与 wasm-bindgen 的 externref 表对比

wasm-bindgen 在 0.2.118 里用一张 WASM externref 表(__wbindgen_externrefs,槽位由 __externref_table_alloc / __externref_table_dealloc 管理,详见第 11 章 §11.4.1)来托管 JsValue;组件模型用句柄表托管 resource。两者解决的是同一个问题——跨 WASM 边界管理有生命周期的对象。但有本质区别:

  • externref 表是 wasm-bindgen 的实现细节——槽位分配协议不在任何规范中,只有 wasm-bindgen 的 Rust 生成器和 JS 生成器能正确互操作
  • 句柄表是组件模型规范的一部分——它的协议在 Canonical ABI 中定义,任何语言的 wit-bindgen 生成器都能正确互操作

这意味着:用 wasm-bindgen 创建的 Rust 对象只能被 JS 消费者使用;用组件模型创建的 Rust 资源可以被 Python/C/Go 等任何语言的消费者使用——只要它们通过 WIT 接口访问。

14.10 组件模型的工具链生态

组件模型的实用性取决于工具链的成熟度。以下是当前可用的关键工具。

wasm-tools

wasm-tools 是字节码联盟维护的 WASM 工具集——包含组件模型的二进制操作工具:

# 验证组件格式
wasm-tools validate my_component.wasm

# 把核心模块转换为组件
wasm-tools component new my_module.wasm -o my_component.wasm

# 从组件中提取 WIT 定义
wasm-tools component wit my_component.wasm

# 组合两个组件:交给独立的 wac 工具(见 §14.15.2)
# `wasm-tools compose` 子命令在 1.246.1 里还在,但已标记弃用,
# 一运行就提示 "`wasm-tools compose` has been deprecated. Please use `wac` instead."

wasm-tools component new 是最常用的命令——它把一个 Rust/C/Go 编译器产出的裸 .wasm 模块包装成组件。WIT 信息不从命令行传,而是读核心模块里的 component-type 自定义段(由 wit-bindgen 生成的代码或 wasm-tools component embed 写入);如果模块用的是 WASI P1 导入,还要用 --adapt 传入 wasi_snapshot_preview1 适配器模块。wasm-tools 据此生成组件外壳:类型、导入导出以及 canon lift/canon lower 定义。

wasm-component-ld

wasm-component-ld 是组件模型专用的链接器封装——它先调用 wasm-ld 链出核心模块,再把它封装成组件(包括嵌入 WASI 适配)。当 cargo build --target wasm32-wasip2 时,Rust 编译器用的链接器就是它(Rust 1.96 compiler/rustc_target/src/spec/targets/wasm32_wasip2.rs:28:options.linker = Some("wasm-component-ld".into())),所以产物直接就是组件。

cargo-component

cargo-component 是 Rust 的 Cargo 子命令——它简化了 Rust 组件的开发流程:

# 安装
cargo install cargo-component

# 创建新组件项目
cargo component new my-calculator --lib

# 构建(自动处理 WIT 绑定和组件打包)
cargo component build

# 运行(使用 Wasmtime)
cargo component run

cargo component 的核心价值:它自动管理 WIT 依赖和绑定代码的生成。开发者只需要在 src/lib.rs 中实现 WIT 定义的 trait——cargo component 在构建时把绑定生成到 src/bindings.rs(运行时支持 crate 是 wit-bindgen-rt),默认按 wasm32-wasip1 编译,再配合 P1 适配器把产物封装成组件。它的 README 自称仍是实验性工具;自 Rust 1.82 有了上游的 wasm32-wasip2 目标后,只用 WASI 接口的项目用普通 cargo build --target wasm32-wasip2 就够了(本章 §14.7 就是这么做的),有自定义 WIT 依赖管理需求时再用 cargo component。

14.11 组件模型的现状与未来

谈"组件模型到哪一步了",要把三件经常被混为一谈的事分开(以下均于 2026-09-24 核对一手来源):

  1. 在 W3C WebAssembly 社区组(CG)提案流程里的阶段:WebAssembly/proposals 的提案表把 Component Model 列在 Phase 1(Feature Proposal)。也就是说,它是 CG 里的一个提案,不是 W3C 正式标准,在 0–5 阶段的提案流程里还处在前段(阶段编号的含义见 §2.17.5 / §9.14.5)。组件二进制格式的 version 字段至今是预标准的 0x0d,规范 Binary.md 明说标准定稿时才会改成 0x1。
  2. 随 WASI 发布的"开发者预览"里程碑:组件模型仓库 README 说明,组件模型是跟着 WASI 的 Developer Preview 版本增量交付的,被纳入某个预览版的特性由生产方和消费方工具保持稳定,可以在浏览器外用于生产。目前列出的有三个:0.2.0(第一个基于组件模型的版本:shared-nothing / shared-everything 链接、高级值类型、带句柄的 resource、WIT)、0.3.0(组件模型与 WIT 原生并发:async 函数、stream、future 及配套的 ABI 内建函数)、0.3.1(map<K,V> 类型与 implements、external-id 注解)。按 WebAssembly/WASI 仓库的 GitHub Releases:0.2.0 于 2024 年 1 月投票通过(Release 页面 2024-04-24 才补发,见第 13 章),v0.3.0 正式版于 2026-06-11 发布,v0.3.1 于 2026-08-11 发布。
  3. 本专栏 pin 的实现版本:Wasmtime 44.0.0(2026-04-20 发布,早于 WASI 0.3.0 正式版)对 WASI 0.2 的支持是完整、稳定的,内置 0.2.6 的 WIT(第 13 章);它的 P3 实现停在候选版 0.3.0-rc-2026-03-15(crates/wasi/src/p3/wit/deps/cli.wit:1),模块文档自称"Experimental, unstable and incomplete"(crates/wasi/src/p3/mod.rs:1);组件模型的 async 支持由 component-model-async feature 提供(在 crates/wasmtime/Cargo.toml 的默认 feature 里)。

已经可以依赖的部分

  • 二进制格式、WIT、Canonical ABI:规范文本在 WebAssembly/component-model 的 design/mvp/ 下(Binary.md、WIT.md、CanonicalABI.md),WASI 0.2 所用的那部分已由各工具保持稳定。组件值的文本表示由独立的 wasm-wave crate 提供,Wasmtime 以可选的 wave feature 接入。
  • wit-bindgen:仓库内的 Guest 生成器覆盖 Rust / C / C++ / C# / Go / MoonBit(外加一个生成文档的 markdown 后端);Python、JavaScript 走 componentize-py、ComponentizeJS 等独立项目,Java 支持已被移除(第 15 章 §15.1)。
  • Wasmtime:完整的组件实例化和执行,包括 WASI 0.2 的全部接口。
  • 组合工具:wac(wac plug / wac compose);wasm-tools compose 已弃用。

正在推进的部分

  • 原生异步:WIT 的 async 函数与 stream/future 已随 WASI 0.3.0 发布,但工具链要跟上还需要时间——本专栏 pin 的 Wasmtime 44 实现的是 rc 版本,Rust 1.96 的 wasm32-wasip3 仍是 Tier 3(第 12、13 章)。
  • 包分发:warg 已停止开发,基于 OCI 的 wasm-pkg-tools(wkg)接手(§14.6)。
  • 浏览器:目前靠 jco 把组件转译成核心模块 + JS 胶水运行,浏览器引擎原生支持组件要等规范在 CG 里继续推进(§14.8)。
  • 后续门控特性:组件模型 README 提到后续预览版会纳入更多用 emoji 门控的特性,例如协作式线程(🧵)。
timeline
  title 组件模型相关里程碑(WASI 发布日期取自 WebAssembly/WASI 仓库)
  2019 : WASI 发布<br/>wasi_snapshot_preview1(fd 风格 API)
  2024 : 1 月 WASI 0.2.0 投票通过<br/>第一个基于组件模型的版本
  2026 : 4 月 Wasmtime 44.0.0(本专栏 pin,P3 为 rc 实现)<br/>6 月 WASI 0.3.0(原生 async、stream、future)<br/>8 月 WASI 0.3.1(map 类型)
  至今 : Component Model 在 CG 提案表中仍为 Phase 1

14.12 组件版本演化:WIT 接口的兼容性管理

组件模型的"可组合"承诺有一个关键前提——接口契约稳定。如果 WIT 定义随意变更,组件之间的互操作就会破裂。WIT 借鉴了 Protobuf/gRPC 的演化经验,但在 WASM 场景下有独特的工程要求。

14.12.1 WIT 的语义版本规则

WIT 接口遵循语义化版本(SemVer)——版本号附在 package 声明上:

package example:math@1.2.3;

interface calculator {
    add: func(a: f64, b: f64) -> f64;
    subtract: func(a: f64, b: f64) -> f64;
}

兼容性规则("minor"指的是对使用方兼容:导入旧版接口的组件,接到提供新版接口的实现上仍能工作;反过来,实现方要导出新版接口就必须补上新增的函数):

变更类型 版本影响 示例
添加新 interface minor++ 1.0.0 → 1.1.0
添加 interface 中的新函数 minor++ 加 multiply
给 record 加字段 major++ WIT 没有"可选字段",加在末尾也改变类型
改函数签名(增删参数) major++ add(a, b) → add(a, b, c)
改 record 字段类型 major++ f64 → f32
删除 interface/函数 major++ 移除 subtract
改 enum 变体顺序 / 加 enum 变体 major++ 类型检查逐个比对变体

14.12.2 record 字段顺序的隐式契约

WIT 的 record 字段顺序在 Canonical ABI 中有效——和 C struct 一样,字段顺序决定内存布局。改顺序是破坏性变更:

// v1.0.0
record point {
    x: f64,
    y: f64,
}

// v1.1.0:调换顺序
record point {
    y: f64,  // 原本是 x 的位置
    x: f64,  // 原本是 y 的位置
}

二进制层面这是 major 变更——如果没有检查,v1.0.0 的消费者读 v1.1.0 的 point 会把 x 当成 y。好在组件的类型检查是按位置逐个比对字段名的:组合或实例化时就会报类型不匹配,不会悄悄读错。更好的做法是在发版前就拦下来——wasm-tools component semver-check 专门检查一个 world 是否是另一个的 semver 兼容演进,用 wasm-tools 1.246.1 实测,调换字段顺序报 expected field name `y`, found `x` ,末尾加字段报 expected 3 fields, found 2,加 enum 变体报 mismatch in enum elements。把它放进 CI:检查不通过却只升了 minor 的变更一律拒绝。

14.12.3 多版本共存与适配器

组件 A 导入 math@1.0.0,组件 B 导出 math@2.0.0 —— 直接连接会失败。组件模型的解决方案是 适配器组件:

graph LR
  A["组件 A<br/>imports: math@1.0.0"] --> AD["适配器组件<br/>exports: math@1.0.0<br/>imports: math@2.0.0"]
  AD --> B["组件 B<br/>exports: math@2.0.0"]
  style AD fill:#f59e0b,color:#333

适配器组件用 wit-bindgen 生成两套绑定,手写转换逻辑。这和微服务架构中的 API 网关层级类似——把版本兼容的复杂度集中在一处。

14.12.4 实战:演化策略清单

flowchart TD
  A["WIT 接口要变更"] --> B{"破坏性变更?"}
  B -->|否| C["minor++ 直接发布"]
  B -->|是| D{"消费者数量?"}
  D -->|< 3| E["协调升级<br/>所有消费者一起升 major"]
  D -->|>= 3| F["双版本共存策略"]
  F --> G["1. 在 v1 旁发布 v2"]
  G --> H["2. 写 v1→v2 适配器组件"]
  H --> I["3. 给消费者迁移期(3-6 月)"]
  I --> J["4. 监控 v1 调用量"]
  J --> K{"v1 调用 < 1%?"}
  K -->|否| I
  K -->|是| L["下线 v1"]
  style L fill:#10b981,color:#333

图中的阈值(3 个消费者、3-6 个月、1%)只是示意,按团队规模自行定。要点是:强制立即迁移会破坏信任——给足够的迁移期,监控调用量下降到可下线水平再下线。

14.13 组件调用的性能开销

理论上"组件互操作"是免费的——但 Canonical ABI 的编解码不可能零成本。理解开销来源有助于设计高性能的组件接口。

14.13.1 Canonical ABI 编解码的耗时分解

每次跨组件调用包含:

注意方向别写反(§14.2 已定义):lower 是"WIT 值 → 核心值/线性内存",lift 是"核心值/线性内存 → WIT 值"。所以调用方出去的那一步是 lower,被调方进来的那一步是 lift。

阶段 操作 开销性质
1. lower 调用方把参数从本语言类型降级成 canonical 表示 随类型复杂度增长
2. 拷贝 canonical 表示写进被调方的线性内存 与数据字节数成正比
3. lift 被调方把 canonical 表示提升成本语言类型 随类型复杂度增长
4. 函数执行 实际业务逻辑 视实现
5. 返回值 lower/copy/lift 同上反向 同上

简单类型(数值、bool)打平后就是几个核心值,不经过线性内存(见 §14.4 的 MAX_FLAT_PARAMS),开销几乎全在函数调用本身。复杂类型(string、list、record)的开销主要来自 lift/lower 与那次拷贝。

14.13.2 类型选择对性能的影响

下表是实测数据,但要看清测的是什么:宿主(Rust)调用组件导出函数的单次耗时,不是两个组件之间的调用(后者走 Wasmtime 编译出的融合适配器,路径不同)。环境:Wasmtime 44.0.0(release 构建,bindgen! 生成的类型化绑定)、guest 用 Rust 1.96 + wit-bindgen 0.57.1 编成 wasm32-wasip2 组件、Intel Xeon Gold 6240,每项预热 1000 次后循环 20 万次取均值,重复 3 轮波动在 ±3% 内。guest 函数体都只做最简单的事(返回常量、求长度、原样返回、求和),测到的几乎全是跨边界开销:

函数 单次耗时 说明
核心模块 func() -> i32(对照) 约 30 ns 普通 wasmtime::TypedFunc 调用,没有组件层
func() -> u32 约 190 ns 组件调用基线:参数/返回值都打平,不碰线性内存
func(a: u32, b: u32) -> u32 约 195 ns 与基线同级,仍在 flat 范围内
func(s: string) -> u32,16 字节 约 290 ns 要调 guest 的 cabi_realloc 在它的内存里分配,再复制
func(s: string) -> u32,1 KB 约 305 ns 字节数涨 64 倍,耗时只多约 5%——固定开销占大头
func(s: string) -> string,16 字节 约 380 ns 来回各复制一次,外加 post-return
func(items: list<u32>) -> u32,1000 个元素 约 675 ns 4 KB 整块复制
func(u: user) -> u32,user 内嵌 address record 约 480 ns 共 3 个 string
func(u: user-flat) -> u32,同样 3 个 string 平铺 约 480 ns 与嵌套版无差别

几个结论:组件调用本身就比核心模块调用多出一截固定开销(这组数据里约 6 倍);小字符串、小列表的成本主要是"每个值一次分配 + 复制"的固定部分,而不是字节数;record 嵌套与否不影响开销——嵌套 record 打平后的核心值、内存布局与平铺版完全一样,真正决定成本的是里面有几个 string/list 要分配和复制。换一台机器、换一个 Wasmtime 版本,绝对值都会变,要拿自己项目的数,照这个方法在目标机器上测。

14.13.3 性能优化的接口设计原则

原则一:批量传递大块数据。每次跨组件调用都有一份固定开销(调用本身 + lower/lift 的框架部分),调用次数一多就被放大。把循环放进被调用方而不是调用方:

// 反模式:每个元素一次跨组件调用
interface bad {
    process-one: func(item: u32) -> u32;
}

// 优化:一次调用处理整批
interface good {
    process-batch: func(items: list<u32>) -> list<u32>;
}

原则二:少传需要分配的值,而不是纠结嵌套。上面的实测里,嵌套 record 与平铺 record 耗时一样;贵的是每个 string/list 都要在对方内存里分配一次、复制一次。所以真正该省的是这些值的个数——例如把一组经常一起变的小字符串合成一个,或者让不需要跨边界的字段干脆留在对方那边(见原则三):

// 嵌套写法和平铺写法开销相同(注意 WIT 不支持匿名内联 record,嵌套要先单独定义)
interface types {
    record address {
        street: string,
        city: string,
    }
    record user {
        name: string,
        address: address,
    }
}

原则三:用 resource 句柄替代复杂结构。当数据量大且不需要每次都跨边界时,用 resource 让数据留在被调用方:

interface optimized {
    resource processor {
        constructor(config: string);
        process: func(input: list<u8>) -> list<u8>;
    }
}

processor 实例的内部状态留在被调用方,调用方只持有句柄——避免每次调用都重传 config 等元数据。

14.13.4 何时组件模型不该用

组件模型的开销使其不适合所有场景:

场景 推荐 理由
进程内部多语言互操作 组件模型 目前最成体系的方案(仍是 CG Phase 1 提案)
高频微调用(每秒百万次量级) 不推荐 固定开销 × 调用次数会吃掉可观的一段 CPU 时间
简单的同语言调用 不推荐 直接 import 更快、更简单
服务边界(跨进程) 不推荐 gRPC/REST 更成熟
浏览器中的 Rust ↔ JS 不推荐(暂时) 用 wasm-bindgen,组件浏览器支持未成熟

组件模型的最佳定位:多语言、单进程、中频调用——例如插件系统、polyglot 微服务、跨语言库分发。

14.14 组件的安全模型与能力授权

组件模型的"可组合"如果没有"可信任"做基础就是空中楼阁——不同来源的组件被组装在一起,每个组件能做什么、不能做什么必须有清晰边界。组件模型从设计阶段就把安全嵌入了类型系统。

14.14.1 默认 deny-all 的能力模型

graph TD
  A["组件被实例化"] --> B{"宿主显式提供能力?"}
  B -->|否| C["组件无法访问任何外部资源"]
  B -->|是| D["仅能调用宿主提供的接口"]
  D --> E["wasi:filesystem<br/>受限路径"]
  D --> F["wasi:http/outgoing<br/>限定 host/port"]
  D --> G["自定义 host 接口<br/>业务能力"]
  style C fill:#10b981,color:#333
  style D fill:#6366f1,color:#fff

每个组件的导入项是它的能力清单——wasi:filesystem/types 在导入项中表示它"想要文件系统能力",宿主有权拒绝这次导入:

// 宿主代码:精细控制能力
let mut linker = Linker::new(&engine);

// 加入 WASI P2 的全套接口;文件系统具体能碰哪些目录,
// 由 Store 里 WasiCtx 的 preopen 决定(见 §14.14.2)
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;

// 不额外调用 wasmtime_wasi_http 的 add_to_linker
// → 组件的 wasi:http/outgoing-handler 导入无人满足,实例化直接失败

let component = Component::from_file(&engine, "untrusted.wasm")?;
let instance = linker.instantiate_async(&mut store, &component).await?;

如果组件导入了宿主根本没提供的接口,实例化阶段就报错——不是运行时才发现。接口提供了、但权限收窄的部分(比如只读目录),则由宿主在调用时拦截(§14.14.2)。先按导入清单把关、再在调用时细化,比只靠运行时拦截的传统沙箱多了一道静态关口。

14.14.2 接口级粒度的授权

组件模型的能力粒度细到接口级——同一个 wasi: 包,可以只授权部分接口:

粒度 示例
包级 全部 wasi:filesystem/*
接口级 只 wasi:filesystem/types,不给 wasi:filesystem/preopens
函数级 允许 descriptor.read,拒绝 descriptor.write(靠 DirPerms/FilePerms 在调用时拦截)
资源级 resource 句柄表的访问限制

实战:

// 只授权读,不授权写
// 签名是 preopened_dir(host_path, guest_path, dir_perms, file_perms)
// —— Wasmtime 44 的 crates/wasi/src/ctx.rs:291
let wasi_ctx = WasiCtxBuilder::new()
    .preopened_dir(
        "/data",
        "/",
        DirPerms::READ,   // ← 只读;写需要 DirPerms::MUTATE(crates/wasi/src/filesystem.rs:95、99)
        FilePerms::READ,
    )?
    .build();

组件即使包含写文件的代码也写不进去:以写方式打开文件时,目录没有 DirPerms::MUTATE 就返回 ErrorCode::NotPermitted(crates/wasi/src/filesystem.rs:939-945);对没有 FilePerms::WRITE 的描述符调用 write,同样返回 NotPermitted(crates/wasi/src/p2/host/filesystem.rs:149-150)。

14.14.3 自定义 host 接口的安全设计

业务自定义的 host 接口必须遵循同样的能力安全原则:

// WIT 定义(示意:rows、value、error 的类型定义从略)
interface untrusted-api {
    // 反模式:传完整数据库连接给组件
    record db-config { url: string, user: string, password: string }
    init: func(config: db-config);
    query: func(sql: string) -> result<rows, error>;

    // 推荐:组件只看到 query 接口,连接由宿主管理
    resource query-context {
        constructor();
        execute: func(sql: string, params: list<value>) -> result<rows, error>;
    }
}

反模式的问题:组件拿到 db-config 后可以做任何 SQL 操作——DROP TABLE、读取其他用户数据。推荐方案:宿主创建 query-context 时已绑定特定数据库 + 特定用户权限,组件只能在这个上下文里查询。

graph LR
  A["组件代码"] --> B["query-context.execute<br/>受宿主限制"]
  B --> C["宿主拦截层<br/>SQL 解析 + 白名单"]
  C --> D["实际数据库"]
  style C fill:#f59e0b,color:#333

宿主的拦截层可以做:

  • SQL 类型检查(SELECT 允许、DROP 禁止)
  • 表白名单(只准查询 users / orders)
  • 参数化查询强制(防 SQL 注入)
  • 速率限制(每组件每分钟最多 100 次)

这套机制让"不信任的组件 + 安全的能力授权"成为现实——组件即使是恶意代码,能造成的损害也被限制在宿主授权范围内。

14.14.4 信任链:从签名到运行时验证

生产部署中,组件必须经过完整的信任链验证:

flowchart LR
  A["组件源码"] --> B["可重现编译"]
  B --> C["sigstore 签名"]
  C --> D["上传到注册表"]
  D --> E["运行时拉取"]
  E --> F["验证签名"]
  F --> G{"签名有效?"}
  G -->|是| H["实例化"]
  G -->|否| I["拒绝加载"]
  style F fill:#6366f1,color:#fff
  style H fill:#10b981,color:#333
  style I fill:#dc2626,color:#fff

具体思路(用 cosign 签名,宿主在交给 Wasmtime 编译之前先验签;下面是示意代码,fetch、SigstoreVerifier 是占位名,不是某个真实 crate 的 API):

async fn load_signed_component(engine: &Engine, url: &str, expected_signer: &str) -> Result<Component> {
    // 1. 拉取组件 + 签名
    let component_bytes = fetch(url).await?;
    let signature = fetch(format!("{url}.sig")).await?;

    // 2. 验证签名
    let verifier = SigstoreVerifier::new(expected_signer);
    verifier.verify(&component_bytes, &signature)?;

    // 3. 通过验证后才实例化
    Component::from_binary(engine, &component_bytes)
}

这套链路保证:组件来自可信发布者、未被中间人篡改、版本与签名匹配。配合 §5.11 介绍的可重现编译,整套供应链防护完整。

14.15 跨语言组件的工程实战模式

§14.7 给出了一个简单的多语言组件示例。生产中"多语言组件系统"远比示例复杂——团队协作、版本对齐、性能调优、调试排错都有自己的模式。下面整理几条工程上的做法。

14.15.1 接口契约的版本管理

跨团队的 WIT 接口必须有严格的版本管理:

// 主仓库(platform-team 维护)
package platform:storage@1.2.0;

interface kv {
    variant error {
        not-found,
        io(string),
    }

    get: func(key: string) -> result<list<u8>, error>;
    set: func(key: string, value: list<u8>) -> result<_, error>;
}

业务团队的组件依赖:

# 业务组件 Cargo.toml(cargo-component 的写法)
[dependencies]
# 绑定所需的运行时 crate wit-bindgen-rt 由 cargo component new 自动加入,版本随 cargo-component

[package.metadata.component]
package = "myteam:my-app"

# 本组件 world 所在的 WIT 目录,以及它依赖的 WIT 包
[package.metadata.component.target]
path = "wit"

[package.metadata.component.target.dependencies]
"platform:storage" = { path = "../wit/storage" }

注意两张表的区别:[package.metadata.component.target.dependencies] 是WIT 包依赖(给 world 里的 import/use 找定义);[package.metadata.component.dependencies] 则是对其他组件的依赖。本地依赖写 path 就不能再写 version(cargo-component 的 manifest 解析代码会报 "cannot specify both");从注册表取才写 version。

工程纪律:

  • 接口仓库独立:WIT 文件单独 git repo,不在任何业务仓库中
  • PR 流程:接口变更必须经过平台团队 review
  • CI 验证:业务组件的 CI 必须能从最新接口仓库拉版本

14.15.2 组件链接的工程模式

flowchart LR
  A["开发期"] --> B["每个组件独立开发"]
  B --> C["wac 工具链接"]
  C --> D["集成测试"]
  D --> E["部署"]
  F["组件链接策略"] --> F1["构建期组合<br/>wac 合并成一个组件"]
  F --> F2["运行时组合<br/>宿主在 Linker 里搭桥"]
  style C fill:#6366f1,color:#fff

wac(WebAssembly Composition)是字节码联盟维护的组件组合工具:

# 编译各组件
cargo component build --release  # → my-component.wasm

# 最简单的形式:把 plug(提供导出的组件)插进 socket(有待满足导入的组件)
wac plug plugin.wasm --plug storage.wasm --plug logger.wasm -o final.wasm

# 复杂拓扑(多层依赖、显式重命名)用 .wac 脚本 + `wac compose`
# 子命令与参数请以 `wac --help` 为准,这套 CLI 仍在演进

链接后的 final.wasm 是一个组合组件——所有依赖打包,部署时一个文件搞定。

14.15.3 调试多语言组件

跨语言组件的调试是最难的部分——错误可能发生在 Rust 组件、Python 组件、还是它们之间的接口。诊断流程:

flowchart TD
  A["报错"] --> B{"错误类型?"}
  B -->|Canonical ABI 错| C["检查 record 字段顺序"]
  B -->|资源句柄无效| D["检查跨组件 resource 转移"]
  B -->|trap| E["看哪个组件 trap"]
  C --> C1["wasm-tools component wit<br/>对比双方接口定义"]
  D --> D1["确认句柄管理规则<br/>不能跨实例"]
  E --> E1["每个组件单独跑<br/>定位问题组件"]
  style C1 fill:#10b981,color:#333
  style D1 fill:#10b981,color:#333
  style E1 fill:#10b981,color:#333

通用调试技巧:

  • 分而治之:先把每个组件单独跑通,再链接调试
  • WIT 对照:互相连接的组件,接口定义必须一致(用 wasm-tools component wit 提取对比)
  • trace 跨组件调用:用 host 包装 + log 记录跨组件调用的输入/输出

14.15.4 多语言组件的性能调优

不同语言的组件性能特征不同:

语言 启动开销 执行性能 内存占用
Rust 低 最佳(接近原生) 低(无 GC)
C/C++ 低 最佳 低
Go 中等 良好 中等(GC)
Python 高(解释器) 慢(CPython 解释执行) 高
AssemblyScript 低 中等 中等

工程策略:

  • 核心热路径:用 Rust/C/C++ 编译的组件
  • 业务逻辑:用熟悉的语言(Go/Python)
  • Python 慎用:CPython on WASM 体积大、慢,仅适合非热路径

14.15.5 组件目录结构

component-project/
├── wit/                        # WIT 接口定义
│   ├── deps/
│   │   ├── platform-storage/   # 引入的接口
│   │   └── platform-logger/
│   └── world.wit               # 本组件的 world
├── src/
│   └── lib.rs                  # 组件实现
├── Cargo.toml
├── Cargo-component.lock        # 依赖版本锁定(cargo-component 生成)
└── README.md

Cargo-component.lock(文件名是 cargo-component 源码里的常量 LOCK_FILE_NAME)类似 Cargo.lock——锁定从注册表解析到的组件与 WIT 依赖的精确版本。这是多团队协作的关键——确保任何机器构建出的组件依赖完全一致。

14.16 组件 vs 模块:何时升级到组件

WASM 有两层抽象——核心模块(core module,规范定义的 .wasm)和组件(component,组件模型规范定义)。两者都是 WASM,但工程含义截然不同。理解何时选择哪个是组件模型最常见的工程困惑。

14.16.1 概念区别

graph TD
  A["WASM 抽象层级"] --> B["核心模块<br/>core module"]
  A --> C["组件<br/>component"]
  B --> B1["规范:WebAssembly Core Spec"]
  B --> B2["类型:i32/i64/f32/f64"]
  B --> B3["接口:底层 ABI"]
  B --> B4["互操作:手动协议"]
  C --> C1["规范:Component Model"]
  C --> C2["类型:record/variant/list/string..."]
  C --> C3["接口:WIT 定义"]
  C --> C4["互操作:Canonical ABI 自动"]
  style B fill:#6366f1,color:#fff
  style C fill:#10b981,color:#333

类比:核心模块像 C 二进制(裸机协议),组件像 .NET assembly(带元数据和类型)。

14.16.2 何时用核心模块

flowchart TD
  A["选择核心模块"] --> B["1. 浏览器交付<br/>组件浏览器支持差"]
  A --> C["2. 极致性能<br/>避免 Canonical ABI 开销"]
  A --> D["3. 嵌入式<br/>WAMR 解释器更好支持"]
  A --> E["4. 简单场景<br/>纯计算 + JS 边界"]
  style A fill:#6366f1,color:#fff

核心模块的优势:

  • 生态成熟:所有 WASM 运行时都支持
  • 性能极致:没有 Canonical ABI 编解码
  • 体积小:没有组件元数据
  • 工具链稳定:wasm-pack / wasm-bindgen 全套成熟

14.16.3 何时升级到组件

flowchart TD
  A["升级到组件"] --> B["1. 多语言互操作<br/>Rust + Python + Go"]
  A --> C["2. 服务端 / 边缘<br/>Spin/wasmCloud 平台"]
  A --> D["3. 第三方插件<br/>需要稳定接口契约"]
  A --> E["4. 长期演进<br/>跟随 CG 标准化进程"]
  style A fill:#10b981,color:#333

组件的优势:

  • 类型安全跨语言:WIT 接口契约
  • 可组合:wac 工具链接多组件
  • 能力安全:deny-all 默认 + WASI 接口
  • 未来兼容:处在 W3C WebAssembly CG 的标准化流程中(目前 Phase 1,见 §14.11)

14.16.4 决策矩阵

flowchart TD
  A["决策起点"] --> B{"目标平台"}
  B --> C["浏览器"]
  B --> D["服务器"]
  B --> E["嵌入式"]
  C --> F{"2026 现实<br/>组件浏览器支持?"}
  F -->|否| G["核心模块 + wasm-bindgen"]
  D --> H{"多语言?"}
  H -->|是| I["组件"]
  H -->|否| J{"插件系统?"}
  J -->|是| I
  J -->|否| K["核心模块"]
  E --> L{"WAMR / 资源?"}
  L -->|< 1MB 设备| M["核心模块"]
  L -->|资源充足| N["组件可选"]
  style G fill:#10b981,color:#333
  style I fill:#6366f1,color:#fff
  style K fill:#10b981,color:#333
  style M fill:#10b981,color:#333

实践上的大方向:浏览器 WASM 项目基本用核心模块(wasm-bindgen),多语言服务端项目优先考虑组件。

14.16.5 组件 vs 核心模块的开销

单次调用一项有 §14.13.2 的实测数据,其余几项只给方向(绝对值随运行时版本、CPU、接口形状变化很大,必须自己在目标环境测)。

维度 组件相对核心模块
二进制体积 略大——多出组件外壳与接口类型元数据,是固定的一小块,不随业务代码线性增长
实例化时间 略慢——多一层组件解码与适配器实例化
单次调用 差距最大的一项——§14.13.2 的宿主调用实测:无参函数核心模块约 30 ns、组件约 190 ns;带 3 个字符串的 record 参数约 480 ns,约为核心调用的 16 倍
编译时间 略慢——多一步组件封装

要点是各项开销的性质不同:体积和实例化是一次性的小额固定成本,单次调用开销才是随调用频率放大的那一项。所以决策依据是调用频率和参数复杂度,而不是笼统的"组件更慢"。换来的是类型安全的跨语言互操作——对多语言场景值得,对单语言场景是浪费。

14.16.6 渐进迁移:从模块到组件

如果未来想从模块升级到组件,可以渐进进行:

flowchart LR
  A["现有 wasm-bindgen 模块"] --> B["阶段 1<br/>定义 WIT 接口"]
  B --> C["阶段 2<br/>编译出组件<br/>(wasm32-wasip2 或 cargo-component)"]
  C --> D["阶段 3<br/>双产物(模块 + 组件)"]
  D --> E["阶段 4<br/>消费者迁移"]
  E --> F["阶段 5<br/>下线模块版本"]
  style F fill:#10b981,color:#333

迁移工作量主要取决于跨边界接口的数量和类型复杂度:要把 #[wasm_bindgen] 暴露的函数与类型逐一改写成 WIT 定义,再按生成的 trait 重接实现。迁移期间双产物共存——消费者按自己节奏切换,避免 big bang 升级风险。

14.16.7 何时不应该升级

并不是所有项目都该升级到组件。明确不该升级的场景:

graph TD
  A["不应该升级到组件"] --> B["1. 浏览器项目<br/>组件支持未成熟"]
  A --> C["2. 性能临界<br/>每微秒都珍贵"]
  A --> D["3. 团队新手<br/>组件工具链学习曲线"]
  A --> E["4. 极小项目<br/>组件元数据相对体积大"]
  A --> F["5. 短期项目<br/>组件 ROI 看不到"]
  style A fill:#dc2626,color:#fff

每条都对应特定场景的成本-收益失衡。强制升级会让团队在错误的时机投入资源。

14.17 组件的部署与运维实战

组件模型的优势在 dev 时显著——但部署到生产时面临独特挑战:组件链接、版本管理、监控。这一节展开生产级组件运维的关键模式。

14.17.1 组件的部署单位

graph LR
  A["部署单位选择"] --> B["独立组件"]
  A --> C["组合组件"]
  B --> B1["每个 .wasm 一个部署单元<br/>运行时由宿主搭桥"]
  C --> C1["wac 组合后整体部署<br/>构建期接好"]
  style B fill:#6366f1,color:#fff
  style C fill:#10b981,color:#333
维度 独立组件 组合组件
部署粒度 细 粗
升级灵活 高(单组件升级) 低(整体替换)
启动复杂度 高(需要 link 时间) 低(直接 instantiate)
调试 复杂(多组件追踪) 简单

多数场景应选组合组件——简单可靠(Wasmtime 的组件 Linker 本来就没有"把一个组件实例接给另一个组件"的一步式 API,见 §14.5)。只在多团队独立维护组件、需要独立升级时才用独立组件。

14.17.2 组件版本管理

flowchart TD
  A["组件版本管理"] --> B["1. 语义版本"]
  A --> C["2. 内容寻址"]
  A --> D["3. 版本注册表"]
  A --> E["4. 部署元数据"]
  B --> B1["semver 规则"]
  C --> C1["SHA-256 验证"]
  D --> D1["OCI 仓库(wkg)/ 自建 registry"]
  E --> E1["componentRef + sha + version"]
  style A fill:#6366f1,color:#fff

实战 metadata 示例:

{
  "componentRef": "myorg/storage@1.2.3",
  "sha256": "a3f2b1c8...",
  "deployedAt": "2026-04-26T10:00:00Z",
  "deployedBy": "alice",
  "rollbackVersion": "1.2.2"
}

每次部署记录这些元数据——出问题时能快速回滚到上个版本。

14.17.3 组件的故障隔离

多组件系统中,一个组件故障不应影响其他:

graph TD
  A["故障隔离机制"] --> B["1. 独立 Store"]
  A --> C["2. fuel/timeout"]
  A --> D["3. 异常包装"]
  A --> E["4. 熔断"]
  B --> B1["错误不传染"]
  C --> C1["挂死自动终止"]
  D --> D1["component A 异常不让 B 失败"]
  E --> E1["失败率高时停止调用"]
  style A fill:#6366f1,color:#fff

实战熔断模式:

struct ComponentCallable {
    name: String,
    circuit_breaker: CircuitBreaker,
}

impl ComponentCallable {
    async fn call(&self, args: Args) -> Result<Output, Error> {
        if self.circuit_breaker.is_open() {
            return Err(Error::CircuitOpen);
        }
        match invoke_component(&self.name, args).await {
            Ok(r) => {
                self.circuit_breaker.record_success();
                Ok(r)
            }
            Err(e) => {
                self.circuit_breaker.record_failure();
                Err(e)
            }
        }
    }
}

熔断让"一个组件故障"不再级联到其他组件——保护整体系统稳定。

14.17.4 多组件部署的协调

sequenceDiagram
    participant D as 部署系统
    participant A as 组件 A
    participant B as 组件 B
    participant C as 组件 C
    Note over A,C: 多组件协同升级
    D->>D: 检测依赖关系(A 依赖 B 依赖 C)
    D->>C: 1. 部署 C 新版(兼容旧 API)
    D->>B: 2. 部署 B 新版(用 C 新 API)
    D->>A: 3. 部署 A 新版
    Note over A,C: 出问题时反向回滚(A→B→C)

多组件的部署顺序很关键:依赖底层先升级,被依赖上层再升。回滚顺序相反:上层先回滚,底层后回滚——避免依赖未满足的中间态。

14.18 组件的热替换与运行时升级

传统服务升级需要停机或滚动重启——WASM 组件的特性让"运行中替换组件"成为可能。这是 WASM 在长生命周期服务中的独特价值。

14.18.1 热替换的核心挑战

graph TD
  A["热替换挑战"] --> B["状态保留"]
  A --> C["版本兼容"]
  A --> D["原子切换"]
  A --> E["回滚能力"]
  B --> B1["组件内 state 如何保留"]
  C --> C1["新旧组件接口必须兼容"]
  D --> D1["切换瞬间不丢请求"]
  E --> E1["新组件出问题秒级回滚"]
  style A fill:#f59e0b,color:#333

每个挑战需要专门设计——简单的"卸载旧组件、加载新组件"不行,会丢请求和状态。

14.18.2 模式一:双实例并行

sequenceDiagram
    participant R as 路由器
    participant A as 旧实例 v1
    participant B as 新实例 v2
    Note over R,B: 状态:v1 在线
    R->>A: 处理请求
    Note over R,B: 切换开始
    R->>B: 加载 v2
    R->>R: 切换路由
    Note over R,B: 状态:v1 + v2 并行
    R->>B: 新请求路由到 v2
    A->>A: 完成处理中的请求
    Note over R,B: 状态:v2 在线,v1 退出

实战代码(Wasmtime 44 实编通过;MyState 同 §14.7)。思路是"每个请求一个 Store":宿主手里只保存当前版本的 InstancePre,切换版本就是换掉它:

use std::sync::{Arc, RwLock};
use wasmtime::component::{Component, InstancePre, Linker};
use wasmtime::{Engine, Result};

struct ComponentManager {
    engine: Engine,
    linker: Linker<MyState>,
    // 当前版本的"预实例化"结果:每个请求拿它在自己的 Store 里快速实例化
    current: Arc<RwLock<InstancePre<MyState>>>,
}

impl ComponentManager {
    fn hot_swap(&self, component_bytes: &[u8]) -> Result<()> {
        // 1. 编译新版本,并在 Linker 上完成导入的类型检查——不兼容的新版本在这里就失败,旧版本不受影响
        let component = Component::new(&self.engine, component_bytes)?;
        let pre = self.linker.instantiate_pre(&component)?;

        // 2. 原子地换掉"当前版本":之后到达的请求用 v2 实例化
        *self.current.write().unwrap() = pre;

        // 3. 正在处理中的请求各自持有 v1 的 Store 和实例,处理完随 Store 一起释放——这就是"排空"
        Ok(())
    }

    fn instance_for_request(&self) -> InstancePre<MyState> {
        self.current.read().unwrap().clone()
    }
}

14.18.3 模式二:状态外置

graph LR
  A["请求"] --> B["组件实例(无状态)"]
  B --> C["外部状态存储<br/>Redis/DB"]
  D["新组件加载"] --> B
  D --> C
  style B fill:#10b981,color:#333
  style C fill:#6366f1,color:#fff

如果组件无内部状态——所有状态在 Redis/DB 中——热替换简单:直接换组件实例,新实例从外部存储读状态。

// 无状态组件设计(示意;组件模型下由 wit-bindgen 生成的 Guest trait 承接)
impl Handler {
    pub async fn handle(&self, request: &str, state_key: &str) -> String {
        // 1. 从外部读状态
        let state = redis::get(state_key).await;

        // 2. 处理(无内部状态)
        let new_state = process(state, request);

        // 3. 写回外部状态
        redis::set(state_key, new_state).await;

        format!("processed")
    }
}

14.18.4 模式三:状态迁移

如果组件有内部状态需要保留:

flowchart LR
  A["旧实例 v1"] --> B["导出状态<br/>serialize"]
  B --> C["序列化数据"]
  C --> D["新实例 v2 import<br/>deserialize"]
  D --> E["v2 继续服务"]
  style B fill:#6366f1,color:#fff
  style D fill:#10b981,color:#333

需要在 WIT 中定义状态迁移接口:

interface stateful {
    export-state: func() -> list<u8>;
    import-state: func(state: list<u8>) -> result<_, string>;
}

新旧版本的 state 格式必须兼容——版本演化的 schema 变化要谨慎。

14.18.5 版本兼容性检查

flowchart TD
  A["热替换前检查"] --> B["1. WIT 接口签名"]
  A --> C["2. ABI 兼容性"]
  A --> D["3. State schema"]
  A --> E["4. 配置兼容"]
  B --> B1["wasm-tools component wit 对比"]
  C --> C1["Canonical ABI 不变"]
  D --> D1["state 字段加减是否兼容"]
  E --> E1["新版本配置在旧位置可读"]
  style A fill:#6366f1,color:#fff

不兼容的更新不能热替换——必须传统重启。判断兼容性:

# 工具:对比两版本的 WIT
diff <(wasm-tools component wit v1.wasm) \
     <(wasm-tools component wit v2.wasm)

# 通过则可热替换;更严格的是用 semver-check 判断 v2 是否是 v1 的兼容演进(§14.12.2),
# 它需要两个 world 放在同一套 WIT 里

14.18.6 流量切换策略

flowchart LR
  A["流量切换"] --> B["1% canary"]
  B --> C{"指标 OK?"}
  C -->|是| D["10% rollout"]
  C -->|否| E["回滚"]
  D --> F{"指标 OK?"}
  F -->|是| G["100%"]
  F -->|否| E
  style G fill:#10b981,color:#333
  style E fill:#dc2626,color:#fff

每阶段的等待时间(让 P95 数据稳定):

  • 1% → 10%:1-2 小时
  • 10% → 50%:4-8 小时
  • 50% → 100%:1 天

14.18.7 回滚能力

struct ComponentRollback {
    versions: Vec<(String, Vec<u8>)>, // 保留最近 N 个版本的 (版本号, 组件字节)
}

impl ComponentRollback {
    fn rollback_to_previous(&self, mgr: &ComponentManager) -> Result<()> {
        let Some((_, prev)) = self.versions.iter().rev().nth(1) else {
            wasmtime::bail!("no previous version");
        };
        mgr.hot_swap(prev)
    }
}

至少保留上一个版本——回滚通过相同的热替换机制实现。

14.18.8 适用场景

flowchart TD
  A["何时该用热替换"] --> B{"系统特征?"}
  B --> C["长连接服务<br/>WebSocket"]
  B --> D["大量并发请求"]
  B --> E["不允许停机"]
  B --> F["短任务批处理"]
  C --> G["热替换价值大"]
  D --> G
  E --> G
  F --> H["传统重启即可"]
  style G fill:#10b981,color:#333
  style H fill:#6366f1,color:#fff

热替换有工程复杂度——不是所有场景都值得。短生命周期、低 SLA 的服务用传统部署即可。

14.19 组件的可观测性与调用链追踪

多组件系统的可观测性比单体更复杂——一个用户请求可能经过 5-10 个组件。如果没有调用链追踪,定位问题几乎不可能。

14.19.1 多组件系统的诊断挑战

graph TD
  A["用户请求"] --> B["API 网关组件"]
  B --> C["认证组件"]
  C --> D["业务组件"]
  D --> E["数据组件"]
  D --> F["缓存组件"]
  E --> G["DB"]
  H["用户报错"] -.诊断.-> A
  H -.诊断.-> B
  H -.诊断.-> C
  H -.诊断.-> D
  H -.诊断.-> E
  style H fill:#dc2626,color:#fff

错误可能出在任意一个组件——没有追踪等于盲人摸象。

14.19.2 OpenTelemetry 在组件模型中的应用

// 每个组件接收 trace context(这里是 wit-bindgen 生成的 Guest trait 实现里的逻辑,
// 组件模型下不涉及 wasm-bindgen)
fn handle(request: Request) -> Response {
    let span = create_span_from_request(&request);
    let _guard = span.enter();

    // 业务逻辑
    let result = process(&request);

    // 调用下游组件,传递 context
    let downstream_response = call_downstream(&result, &request.trace_context);

    Response::new(downstream_response)
}

每个组件创建自己的 span——通过 trace_context 串成完整调用链。

14.19.3 trace context 的传递

WIT 接口需要显式定义 context 传递:

package observability:trace@1.0.0;

// WIT 的类型必须定义在 interface(或 world)内部,不能悬在 package 顶层
interface types {
    record trace-context {
        trace-id: string,
        span-id: string,
        // 不能叫 flags:它是 WIT 关键字
        trace-flags: u8,
    }
}

interface tracer {
    use types.{trace-context};

    create-span: func(name: string, parent: option<trace-context>) -> trace-context;
    end-span: func(ctx: trace-context);
}

每个组件接受 trace-context 作为参数——保证跨组件的关联性。

14.19.4 自动注入 vs 手动传递

graph TD
  A["trace context 传递方式"] --> B["手动传递"]
  A --> C["自动注入"]
  B --> B1["每个 fn 参数加 ctx<br/>显式但繁琐"]
  C --> C1["host 自动注入<br/>简洁但黑箱"]
  style B fill:#6366f1,color:#fff
  style C fill:#10b981,color:#333

一种常见做法是 host 自动注入——在 host 实现中给每个组件调用自动加 context(示意代码):

// host 端
async fn invoke_component(&self, args: Args, parent_ctx: Option<TraceContext>) -> Result<Output> {
    let span = tracer.start_span("component_call", parent_ctx);
    let result = self.component.invoke(args).await;
    span.end();
    result
}

业务代码无感知——但所有调用都被自动追踪。

14.19.5 组件调用的关键指标

flowchart TD
  A["组件调用指标"] --> B["调用次数"]
  A --> C["延迟"]
  A --> D["错误率"]
  A --> E["资源消耗"]
  B --> B1["按组件 / 按方法<br/>QPS"]
  C --> C1["P50/P95/P99"]
  D --> D1["按错误类型分聚"]
  E --> E1["fuel / 内存"]
  style A fill:#6366f1,color:#fff

每个组件都应该暴露这 4 类指标——构成完整的可观测性。

14.19.6 调用链可视化

gantt
    title 一次请求的组件调用链
    dateFormat X
    axisFormat %s
    section 网关
    认证   :a, 0, 5
    路由   :b, 5, 8
    section 业务
    主流程  :c, 8, 50
    section 数据
    DB 查询 :d, 12, 28
    缓存读   :e, 30, 32
    section 业务
    后处理  :f, 50, 60

调用链可视化让"哪个环节慢"一眼可见——是 P99 调优的基础。

14.19.7 工具集成

graph TD
  A["组件可观测性工具"] --> B["Jaeger"]
  A --> C["Tempo"]
  A --> D["Honeycomb"]
  A --> E["Datadog APM"]
  B --> B1["开源 tracing"]
  C --> C1["Grafana 生态"]
  D --> D1["商业,强大"]
  E --> E1["全栈商业"]
  style A fill:#6366f1,color:#fff

每个工具支持 OpenTelemetry——只要组件正确实施 OTel,监控工具可换。

14.19.8 监控数据的采样

// 智能采样:错误 100%,慢请求 100%,正常请求 1%
fn should_sample(&self, span: &Span) -> bool {
    if span.has_error() { return true; }
    if span.duration() > Duration::from_secs(1) { return true; }
    rand::random::<f64>() < 0.01
}

100% 采样的成本太高——智能采样保留高价值数据。

14.20 跨专栏关联:组件模型与微前端

组件模型的"可组合"承诺和《微前端源码精讲》专栏第 1 章的"独立开发、独立部署、运行时组合"理念完全对齐:

  • 微前端:不同团队开发的 JS 应用在浏览器中组合成统一的用户体验——通过路由分发、JavaScript 沙箱、CSS 隔离实现
  • 组件模型:不同语言开发的 WASM 组件在运行时组合成统一的应用——通过 WIT 接口契约、Canonical ABI、能力安全实现

两者的共同挑战:版本兼容性。微前端中,子应用 A 依赖 React 17,子应用 B 依赖 React 18——运行时可能冲突。组件模型中,组件 A 导出 example:math@1.0.0,组件 B 导入 example:math@2.0.0——接口不兼容。两者都需要版本协商机制——微前端用 module federation 的共享依赖,组件模型用 WIT 的语义化版本。

区别在于粒度:微前端是"应用级"组合(整个 SPA 子应用),组件模型是"库/服务级"组合(单个函数/接口)。两者互补——微前端管理应用间的路由和状态,组件模型管理组件间的接口调用和数据传递。一个可能的未来架构:微前端负责页面级编排,每个微前端内部用组件模型组合 Rust/Python/Go 的 WASM 组件实现计算密集型逻辑。

下一章看 wit-bindgen——如何从 WIT 定义自动生成 Rust 绑定代码,消除手写 Canonical ABI 编解码的样板代码。