浏览器之外的WebAssembly:WASI和服务端Wasm
1. 概述
WebAssembly (Wasm) 的诞生是为了在浏览器中运行高性能代码。但它的特性(小巧、沙箱严格、不依赖CPU架构)对服务端也很有吸引力。
在本文中,你将了解WASI是什么,以及为什么server-side Wasm受到关注。你将构建一个Wasm模块,用命令行运行时运行它,将其嵌入Java 21应用程序中并编写测试。最后是不应该使用Wasm的情况。
2. WebAssembly为什么需要WASI?
2.1. 纯Wasm无法与外部世界交互
纯Wasm模块只能对其线性内存(linear memory)进行计算。它无法自己打开文件、读取环境变量、获取系统时间或打开socket。所有与外部的交互都必须通过host(运行模块的环境)提供的函数作为import来进行。
在浏览器中,host是JavaScript。但在浏览器外还没有统一的host。如果每个runtime都自己定义一套API,为某个runtime构建的模块就无法在其他runtime上运行。
2.2. WASI:Wasm的标准系统接口
WASI(WebAssembly System Interface)是用于操作系统风格操作的标准API集:文件、时钟、随机数、环境变量、命令行参数,以及在较新版本中的网络功能。针对WASI编译的模块可以在任何支持WASI的runtime上运行,例如Wasmtime、WasmEdge、Wasmer,或嵌入在你的应用程序中的runtime。
最重要的区别是能力模型。模块默认没有任何权限。Host必须明确授予权限,例如“允许读取目录./data”。缺少权限则调用失败。这与普通进程相反,后者通常继承运行它的用户的所有权限。
2.3. WASI Preview 1、Preview 2和Component Model
WASI经历了许多阶段。你会遇到两个常见的名称:
- Preview 1 (wasip1):C风格的函数API,支持最广泛。
- Preview 2 (wasip2):基于Component Model。接口用名为WIT的IDL语言描述。用不同语言编写的component可以通过更丰富的数据类型(如string、record、list)相互调用,而不仅仅是整数。
支持程度因runtime和toolchain而异。在选择之前,你应该检查你打算使用的runtime的文档。
3. 为什么server-side Wasm受到关注?
有四个主要原因:
- 默认沙箱:模块无法访问其内存区域外的内存,如果没有被授予权限就没有系统权限。这非常适合运行不信任的代码,例如客户的插件或用户上传的脚本。
- 快速启动,小的占用空间:模块通常很轻,启动速度比新容器或新JVM快得多。因此Wasm适合serverless和edge,其中每个请求可能需要一个单独的实例。
- 可移植:一个
.wasm文件可以在x86或ARM、Linux或macOS上运行,只要有runtime。 - 多语言:Rust、C/C++、Go、Zig、AssemblyScript等都可以编译为Wasm。你的Java应用可以加载用其他语言编写的逻辑,而无需JNI。
与容器的快速比较:
| 标准 | 容器 | Wasm + WASI |
|---|---|---|
| 打包单位 | 包含userland OS的镜像 | 单个.wasm文件 |
| 隔离 | 内核的namespace/cgroup | runtime的沙箱+能力 |
| 架构依赖 | 需要按arch的镜像 | 否 |
| 生态系统 | 非常成熟 | 正在发展 |
Wasm不替代容器。两者通常相互补充:你仍然可以在容器内运行Wasm runtime。
4. 构建和运行WASI程序
Rust的Wasm toolchain相当便利,所以以Rust为例。相同的理念适用于其他语言。
4.1. 读取订单文件的程序
// src/main.rs
use std::fs;
fn main() {
match fs::read_to_string("orders.csv") {
Ok(content) => {
let total: f64 = content
.lines()
.filter_map(|line| line.split(',').nth(1))
.filter_map(|amount| amount.trim().parse::<f64>().ok())
.sum();
println!("总订单价值:{total}");
}
Err(e) => eprintln!("无法读取文件:{e}"),
}
}
该程序读取orders.csv,提取第二列(金额)并求和。代码对Wasm没有什么特别的:std::fs在编译时将被映射到WASI调用。
添加target并构建:
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
结果是文件target/wasm32-wasip1/release/order-report.wasm(根据你的package名称)。
4.2. 使用Wasmtime运行并查看能力模型
创建数据文件:
ORD-1,120.5
ORD-2,79.5
ORD-3,300
运行不授予目录权限的情况:
wasmtime run target/wasm32-wasip1/release/order-report.wasm
无法读取文件:failed to find a pre-opened file descriptor through which "orders.csv" could be opened
模块运行成功但看不到任何文件。它没有filesystem的能力。现在授予当前目录的权限:
wasmtime run --dir=. target/wasm32-wasip1/release/order-report.wasm
总订单价值:500
标志--dir为模块“预打开”目录。模块只能看到该目录,看不到/etc或你的home目录。错误消息的内容可能因runtime版本而异,但行为是相同的。
注意: 标志的语法(
--dir、--env、host到guest的目录映射)可能在不同的Wasmtime版本之间变化。请在你的机器上检查wasmtime run --help。
5. 将Wasm嵌入Java应用程序
Server-side Wasm不仅仅是从CLI运行.wasm文件。一种非常实用的方法是将runtime嵌入现有应用程序中以运行第三方提供的插件或逻辑。
对于Java,有两个方向:
- 通过JNI或FFM绑定到native runtime(Wasmtime、WasmEdge)。
- 使用完全用Java编写的runtime,例如Chicory。不需要native库,因此易于打包和在任何有JVM的地方运行。
下面的示例使用Chicory。
5.1. 运费计算模块
假设合作伙伴以Wasm形式提供运费计算逻辑:
// src/lib.rs,Cargo.toml中有:[lib] crate-type = ["cdylib"]
#[no_mangle]
pub extern "C" fn shipping_fee(weight_grams: i32, distance_km: i32) -> i32 {
let base = 15_000;
let by_weight = (weight_grams / 500) * 5_000;
let by_distance = distance_km * 1_000;
base + by_weight + by_distance
}
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/release/shipping.wasm src/main/resources/
该模块不使用WASI。它只进行计算,所以targetwasm32-unknown-unknown足够了,该模块不需要导入任何东西。
5.2. Maven依赖
<properties>
<java.version>21</java.version>
<!-- 检查Maven Central上的最新版本 -->
<chicory.version>REPLACE_WITH_LATEST</chicory.version>
</properties>
<dependencies>
<dependency>
<groupId>com.dylibso.chicory</groupId>
<artifactId>runtime</artifactId>
<version>${chicory.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
如果使用Spring Boot 3.x,JUnit和AssertJ的版本已通过spring-boot-starter-test进行管理。
5.3. 从Java调用Wasm函数
package dev.nonla.shipping;
import com.dylibso.chicory.runtime.ExportFunction;
import com.dylibso.chicory.runtime.Instance;
import com.dylibso.chicory.wasm.Parser;
import java.io.InputStream;
public class WasmShippingCalculator {
private final ExportFunction shippingFee;
public WasmShippingCalculator(String resource) {
try (InputStream wasm = getClass().getResourceAsStream(resource)) {
var module = Parser.parse(wasm);
Instance instance = Instance.builder(module).build();
this.shippingFee = instance.export("shipping_fee");
} catch (Exception e) {
throw new IllegalStateException("无法加载模块" + resource, e);
}
}
public int fee(int weightGrams, int distanceKm) {
long[] result = shippingFee.apply(weightGrams, distanceKm);
return (int) result[0];
}
}
解释:
Parser.parse(...)将Wasm字节码读取为module模型。Instance.builder(module).build()创建一个具有自己内存的实例。模块不导入任何内容,所以不需要提供host函数。instance.export("shipping_fee")获取导出的函数。名称必须与Rust中的#[no_mangle]一致。apply(...)接受并返回long[],因为Wasm的数值类型(i32、i64等)统一表示为long。你需要自己将其转换为int。
试运行:
var calculator = new WasmShippingCalculator("/shipping.wasm");
System.out.println(calculator.fee(1_200, 10));
35000
结果包括15,000的基础费用,加上根据重量的2×5,000(1,200/500=2),加上根据距离的10×1,000。
5.4. 测试行为
package dev.nonla.shipping;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class WasmShippingCalculatorTest {
static WasmShippingCalculator calculator;
@BeforeAll
static void setUp() {
calculator = new WasmShippingCalculator("/shipping.wasm");
}
@Test
void givenLightParcelNearby_whenFee_thenOnlyBaseAndDistance() {
assertThat(calculator.fee(300, 2)).isEqualTo(17_000);
}
@Test
void givenHeavyParcelFarAway_whenFee_thenAllComponentsAdded() {
assertThat(calculator.fee(2_000, 50)).isEqualTo(15_000 + 20_000 + 50_000);
}
@Test
void givenMissingModule_whenCreate_thenFailFast() {
assertThatThrownBy(() -> new WasmShippingCalculator("/not-found.wasm"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not-found.wasm");
}
}
第三个测试确保应用程序在启动时立即报错(如果缺少模块),而不是等到第一个请求时才崩溃。
注意: 一个
Instance有自己的内存和状态。不要假设在多个thread之间共享是安全的。对于纯计算模块,最简单的方法是按request创建实例,或使用instance pool。已解析的module可以重复使用以创建多个instance。
5.5. 当模块需要WASI时
如果插件需要读取文件或打印到stdout,它将导入WASI函数。创建实例时必须提供这些函数。Chicory有一个专用的wasi module来做这个。原则仍然是能力:只提供插件真正需要的目录、环境变量和stdout/stderr。不要为了方便而“继承”host的整个环境。
6. 实际应用模式
- 插件系统:让客户或合作伙伴自定义逻辑(定价、规则检查、数据转换)而无需在JVM中直接运行他们的代码。
- Policy engine:将授权规则打包成Wasm模块,供用不同语言编写的多个服务共享。
- Serverless和edge function:每个request在隔离的实例中运行,启动快速。
- 重用native库:将C/Rust库(解析器、codec、算法)编译为Wasm并从Java调用,避免JNI以及崩溃整个JVM的风险。
- 基础设施扩展:一些proxy、database和message broker允许通过Wasm进行扩展。
7. 何时不应该使用
Server-side Wasm很有吸引力,但不总是正确的选择:
- 逻辑已经用Java编写得很好并且不需要隔离:添加Wasm层只会增加复杂性、数据传输成本和调试难度。
- 工作负载需要繁重的多线程、GPU或深度SIMD:Wasm对线程和硬件功能的支持仍然有限,且不同runtime之间不一致。
- 应用大量依赖于网络和库生态系统:Socket、TLS、通过WASI的数据库驱动程序还不如真实OS上的完整。很多库还无法编译到Wasm。
- Host和guest之间频繁调用并传输大量数据:每次在host和guest之间传递string或数组都必须复制到线性内存中。如果以大payload调用Wasm函数数百万次,这个成本是显著的。将工作分组为大的、不频繁的调用。
- 认为沙箱等同于绝对安全:Wasm隔离内存,但不会自动阻止无限循环或CPU耗尽。你仍然需要超时、内存限制和执行中断机制(如果runtime支持,如fuel、epoch interruption)。
- 团队还没准备好:Wasm的toolchain、debugger和observability仍在发展中。不要仅因为它很“热”就将其引入关键系统。
一些常见的错误:
- 构建错误的target。使用
std::fs或println!的模块需要WASI target。纯计算模块使用wasm32-unknown-unknown。 - 忘记
#[no_mangle]或extern "C",导致函数不出现在导出列表中。 - 混淆类型:Wasm只有原始数字类型。要传递string,你必须在linear memory中自己管理分配,或使用带WIT的Component Model。
- 权限过于宽松(
--dir=/、继承整个env),失去WASI最大的好处。
8. 结论
WASI将WebAssembly从浏览器技术转变为服务端的便携式、轻量级、沙箱严格的执行格式。能力模型(没有被授予则没有权限)是与传统process和container的核心区别。
在本文中,你构建了一个WASI程序并看到了filesystem权限如何被控制。你还将一个Wasm模块嵌入到Java 21应用程序中,使用纯Java runtime,并为其编写了测试。
当你需要运行不信任的代码、需要多语言插件或需要极速启动时,可以考虑使用Wasm。但如果问题只是Java服务中的常规业务逻辑,最简单的解决方案通常仍然是最好的。