浏览器之外的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受到关注?

有四个主要原因:

  1. 默认沙箱:模块无法访问其内存区域外的内存,如果没有被授予权限就没有系统权限。这非常适合运行不信任的代码,例如客户的插件或用户上传的脚本。
  2. 快速启动,小的占用空间:模块通常很轻,启动速度比新容器或新JVM快得多。因此Wasm适合serverless和edge,其中每个请求可能需要一个单独的实例。
  3. 可移植:一个.wasm文件可以在x86或ARM、Linux或macOS上运行,只要有runtime。
  4. 多语言: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服务中的常规业务逻辑,最简单的解决方案通常仍然是最好的。

webassemblywasiwasmjava技术趋势