Chapter 42Project Wasi Build And Run

项目

概览

借助上一章的跨编译机制(见41),我们可使用单个build.zig组装一个完整的 WASI 项目,同时编译到原生与 WebAssembly 目标。本章构建一个小型日志分析 CLI:读取输入、处理并输出摘要统计——这些功能与 WASI 的文件与标准 I/O 能力紧密契合(见wasi.zig)。你将一次性编写应用,然后使用 Wasmtime 或 Wasmer 等运行时生成并测试 Linux 可执行与.wasm模块(见v0.15.2)。

构建系统将定义多个目标,各自拥有制品;你还将接线运行步骤,使其基于目标自动选择正确的运行时(见22)。最终,你将获得一个可用于发布便携命令行工具(原生二进制与 WASI 模块)的工作模板。

学习目标

  • 将 Zig 项目组织为共享源码,干净编译至x86_64-linuxwasm32-wasi(见Target.zig)。
  • build.zig中集成多个addExecutable目标,采用不同优化与命名策略(见Build.zig)。
  • 配置运行步骤,包含运行时检测(原生 vs Wasmtime/Wasmer),并将参数传递至最终二进制(见22)。
  • 在原生与 WASI 环境中测试相同逻辑路径,验证跨平台行为(见#Command-line-flags)。

项目结构

我们将分析器组织为单包工作区,其中src/目录包含入口点与分析逻辑。build.zig将创建两个制品:log-analyzer-nativelog-analyzer-wasi

目录布局

Text
42-log-analyzer/
├── build.zig
├── build.zig.zon
└── src/
    ├── main.zig
    └── analysis.zig

由于无外部依赖,build.zig.zon保持最小化;它作为潜在后续打包的元数据(参见21)。

包元数据

Zig
.{
    // 依赖项和导入中使用的包标识符
    // 必须是有效的 Zig 标识符(无连字符或特殊字符)
    .name = .log_analyzer,
    
    // 此包的语义版本
    // 格式:遵循 semver 约定的 major.minor.patch
    .version = "0.1.0",
    
    // 构建此包所需的最低 Zig 编译器版本
    // 确保与语言特性和构建系统 API 的兼容性
    .minimum_zig_version = "0.15.2",
    
    // 发布或分发包时要包含的路径列表
    // 空字符串包含包目录中的所有文件
    .paths = .{
        "",
    },
    
    // 包管理器生成的唯一标识符,用于完整性验证
    // 用于检测更改并确保包的真实性
    .fingerprint = 0xba0348facfd677ff,
}

.minimum_zig_version字段可避免使用缺少 0.15.2 引入的 WASI 改进的旧编译器进行构建。

构建系统设置

我们的build.zig定义两个共享相同根源文件但面向不同平台的可执行文件。我们还为 WASI 二进制添加自定义运行步骤,检测可用运行时。

多目标构建脚本

Zig
const std = @import("std");

/// 为演示原生和WASI交叉编译的log-analyzer项目构建脚本。
/// 生成两个可执行文件:一个用于原生执行,一个用于WASI运行时。
pub fn build(b: *std.Build) void {
    // 来自命令行标志的标准目标和优化选项
    // 这些允许用户在构建时指定--target和--optimize
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // 原生可执行文件:为宿主系统上的快速运行时性能进行优化
    // 此目标遵循用户指定的目标和优化设置
    const exe_native = b.addExecutable(.{
        .name = "log-analyzer-native",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });
    // 注册原生可执行文件以安装到zig-out/bin
    b.installArtifact(exe_native);

    // WASI可执行文件:交叉编译为WebAssembly并支持WASI
    // 使用ReleaseSmall以最小化二进制大小,便于分发
    const wasi_target = b.resolveTargetQuery(.{
        .cpu_arch = .wasm32,
        .os_tag = .wasi,
    });
    const exe_wasi = b.addExecutable(.{
        .name = "log-analyzer-wasi",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = wasi_target,
            .optimize = .ReleaseSmall, // 优先考虑小二进制大小而不是速度
        }),
    });
    // 注册WASI可执行文件以安装到zig-out/bin
    b.installArtifact(exe_wasi);

    // 为原生目标创建运行步骤,直接执行编译的二进制文件
    const run_native = b.addRunArtifact(exe_native);
    // 确保在尝试运行之前构建并安装二进制文件
    run_native.step.dependOn(b.getInstallStep());
    // 转发在--之后传递的任何命令行参数到可执行文件
    if (b.args) |args| {
        run_native.addArgs(args);
    }
    // 注册运行步骤,以便用户可以使用`zig build run-native`调用它
    const run_native_step = b.step("run-native", "Run the native log analyzer");
    run_native_step.dependOn(&run_native.step);

    // 为WASI目标创建运行步骤,带有自动运行时检测
    // 首先,尝试检测可用的WASI运行时(wasmtime或wasmer)
    const run_wasi = b.addSystemCommand(&.{"echo"});
    const wasi_runtime = detectWasiRuntime(b) orelse {
        // 如果找不到运行时,提供有用的错误消息
        run_wasi.addArg("ERROR: No WASI runtime (wasmtime or wasmer) found in PATH");
        const run_wasi_step = b.step("run-wasi", "Run the WASI log analyzer (requires wasmtime or wasmer)");
        run_wasi_step.dependOn(&run_wasi.step);
        return;
    };

    // 构造使用检测到的运行时运行WASI二进制文件的命令
    const run_wasi_cmd = b.addSystemCommand(&.{wasi_runtime});
    // wasmtime和wasmer都需要'run'子命令
    if (std.mem.eql(u8, wasi_runtime, "wasmtime") or std.mem.eql(u8, wasi_runtime, "wasmer")) {
        run_wasi_cmd.addArg("run");
        // 授予对当前目录的访问权限以进行文件I/O操作
        run_wasi_cmd.addArg("--dir=.");
    }
    // 添加WASI二进制文件作为要执行的目标
    run_wasi_cmd.addArtifactArg(exe_wasi);
    // 转发用户在--分隔符之后的参数到WASI程序
    if (b.args) |args| {
        run_wasi_cmd.addArg("--");
        run_wasi_cmd.addArgs(args);
    }
    // 确保在尝试运行之前构建WASI二进制文件
    run_wasi_cmd.step.dependOn(b.getInstallStep());

    // 注册WASI运行步骤,以便用户可以使用`zig build run-wasi`调用它
    const run_wasi_step = b.step("run-wasi", "Run the WASI log analyzer (requires wasmtime or wasmer)");
    run_wasi_step.dependOn(&run_wasi_cmd.step);
}

/// 检测系统PATH中的可用WASI运行时。
/// 首先检查wasmtime,然后检查wasmer作为后备。
/// 返回检测到的运行时的名称,如果都未找到则返回null。
fn detectWasiRuntime(b: *std.Build) ?[]const u8 {
    // 尝试使用'which'命令定位wasmtime
    var exit_code: u8 = undefined;
    _ = b.runAllowFail(&.{ "which", "wasmtime" }, &exit_code, .Ignore) catch {
        // 如果未找到wasmtime,尝试wasmer作为后备
        _ = b.runAllowFail(&.{ "which", "wasmer" }, &exit_code, .Ignore) catch {
            // 在PATH中未找到运行时
            return null;
        };
        return "wasmer";
    };
    // 成功定位了wasmtime
    return "wasmtime";
}
构建
Shell
$ zig build
输出
Shell
(no output on success; artifacts installed to zig-out/bin/)

WASI 目标使用-OReleaseSmall以最小化模块大小,本机目标使用-OReleaseFast以提升运行速度——体现按制品粒度控制优化的能力。

分析逻辑

分析器读取完整日志内容、按换行符拆分、统计严重级别关键字(ERROR、WARN、INFO)的出现次数,并打印摘要。解析逻辑被提取到analysis.zig中,以便独立于 I/O 进行单元测试。

核心分析模块

Zig
// 此模块提供用于统计日志文件中严重性级别的日志分析功能。
// 它演示了 Zig 中基本的字符串解析和结构体使用。
const std = @import("std");

// LogStats 存储在分析期间发现的每个日志严重性级别的计数。
// 所有字段默认为零,表示尚未计算任何日志。
pub const LogStats = struct {
    info_count: u32 = 0,
    warn_count: u32 = 0,
    error_count: u32 = 0,
};

/// 分析日志内容,统计严重性关键字。
//  在 LogStats 结构体中返回统计信息。
pub fn analyzeLog(content: []const u8) LogStats {
    // 将所有计数初始化为零
    var stats = LogStats{};

    // 创建一个按换行符分割内容的迭代器
    // 这允许我们逐行处理日志
    var it = std.mem.splitScalar(u8, content, '\n');

    // 处理日志内容中的每一行
    while (it.next()) |line| {
        // 统计严重性关键字的出现次数
        // indexOf 返回一个可选值 - 如果找到,我们增加相应的计数器
        if (std.mem.indexOf(u8, line, "INFO")) |_| {
            stats.info_count += 1;
        }
        if (std.mem.indexOf(u8, line, "WARN")) |_| {
            stats.warn_count += 1;
        }
        if (std.mem.indexOf(u8, line, "ERROR")) |_| {
            stats.error_count += 1;
        }
    }

    return stats;
}

// 测试具有多个严重性级别的基本日志分析
test "analyzeLog basic counting" {
    const input = "INFO startup\nERROR failed\nWARN retry\nINFO success\n";

    const stats = analyzeLog(input);

    // 验证每个严重性级别是否正确计数
    try std.testing.expectEqual(@as(u32, 2), stats.info_count);
    try std.testing.expectEqual(@as(u32, 1), stats.warn_count);
    try std.testing.expectEqual(@as(u32, 1), stats.error_count);
}

// 测试空输入是否为所有严重性级别生成零计数
test "analyzeLog empty input" {
    const input = "";

    const stats = analyzeLog(input);

    // 所有计数应保持其默认零值
    try std.testing.expectEqual(@as(u32, 0), stats.info_count);
    try std.testing.expectEqual(@as(u32, 0), stats.warn_count);
    try std.testing.expectEqual(@as(u32, 0), stats.error_count);
}

通过将内容作为切片接受,analyzeLog保持简单且可测试。main.zig处理文件读取,而函数仅处理文本(见mem.zig)。

主入口

入口负责解析命令行参数、读取整个文件内容(或标准输入)、委托analyzeLog并打印结果。原生与 WASI 构建共享同一代码路径;WASI 通过其虚拟化文件系统或标准输入进行文件访问。

主源文件

Zig
const std = @import("std");
const analysis = @import("analysis.zig");

pub fn main() !void {
    // 初始化通用分配器用于动态内存分配
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // 将命令行参数解析为分配的切片
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    // 检查可选的--input标志以指定文件路径
    var input_path: ?[]const u8 = null;
    var i: usize = 1; // 跳过args[0]处的程序名
    while (i < args.len) : (i += 1) {
        if (std.mem.eql(u8, args[i], "--input")) {
            i += 1;
            if (i < args.len) {
                input_path = args[i];
            } else {
                std.debug.print("ERROR: --input requires a file path\n", .{});
                return error.MissingArgument;
            }
        }
    }

    // 从文件或stdin读取输入内容
    // 使用带标签的代码块在两个分支中统一类型
    const content = if (input_path) |path| blk: {
        std.debug.print("analyzing: {s}\n", .{path});
        // 读取整个文件内容,限制为10MB
        break :blk try std.fs.cwd().readFileAlloc(allocator, path, 10 * 1024 * 1024);
    } else blk: {
        std.debug.print("analyzing: stdin\n", .{});
        // 直接从stdin文件描述符构造文件句柄
        const stdin = std.fs.File{ .handle = std.posix.STDIN_FILENO };
        // 读取所有可用的stdin数据,限制为10MB
        break :blk try stdin.readToEndAlloc(allocator, 10 * 1024 * 1024);
    };
    defer allocator.free(content);

    // 将日志分析委托给analysis模块
    const stats = analysis.analyzeLog(content);

    // 打印摘要统计信息到stderr(std.debug.print)
    std.debug.print("results: INFO={d} WARN={d} ERROR={d}\n", .{
        stats.info_count,
        stats.warn_count,
        stats.error_count,
    });
}

--input参数允许以文件进行测试;省略则从标准输入读取,WASI 运行时可轻松管道传入。注意 WASI 的文件系统访问需要运行时显式授予能力(参见posix.zig)。

构建与运行

源码完成后,我们可以构建两个目标并并行运行它们以确认行为一致。

原生执行

Shell
$ zig build
$ echo -e "INFO startup\nERROR failed\nWARN retry\nINFO success" > sample.log
$ ./zig-out/bin/log-analyzer-native --input sample.log
输出
Shell
analyzing: sample.log
results: INFO=2 WARN=1 ERROR=1

使用 Wasmer 的 WASI 执行(标准输入)

Shell
$ zig build
$ echo -e "INFO startup\nERROR failed\nWARN retry\nINFO success" | wasmer run zig-out/bin/log-analyzer-wasi.wasm
输出
Shell
analyzing: stdin
results: INFO=2 WARN=1 ERROR=1

WASI 标准输入管道在各运行时中可靠工作。使用--input的文件访问需要能力授予(--dir--mapdir),这因运行时实现而异,在 preview1 中可能存在限制。

用于对比的原生标准输入测试

Shell
$ echo -e "INFO startup\nERROR failed\nWARN retry\nINFO success" | ./zig-out/bin/log-analyzer-native
输出
Shell
analyzing: stdin
results: INFO=2 WARN=1 ERROR=1

原生与 WASI 从标准输入读取时产生相同输出,展示了命令行工具真正的源码级可移植性。

使用运行步骤

build.zig为两个目标都定义了运行步骤。可直接调用:

Shell
$ zig build run-native -- --input sample.log
输出
Shell
analyzing: sample.log
results: INFO=2 WARN=1 ERROR=1
Shell
$ echo -e "INFO test" | zig build run-wasi
输出
Shell
analyzing: stdin
results: INFO=1 WARN=0 ERROR=0

run-wasi步骤会自动选择已安装的 WASI 运行时(Wasmtime 或 Wasmer),若均不可用则报错。见build.zig中的detectWasiRuntime助手。

二进制体积比较

使用-OReleaseSmall构建的 WASI 模块产生紧凑制品:

Shell
$ ls -lh zig-out/bin/log-analyzer-*
输出
Shell
-rwxrwxr-x 1 user user 7.9M Nov  6 14:29 log-analyzer-native
-rwxr--r-- 1 user user  18K Nov  6 14:29 log-analyzer-wasi.wasm

.wasm模块显著更小(18KB vs 7.9MB),因为它不包含原生 OS 集成,系统调用依赖宿主运行时,非常适合边缘部署或浏览器环境。

扩展项目

此模板可作为面向 WASI 的更复杂 CLI 工具的基础:

  • JSON 输出: 使用std.json.stringify发射结构化结果,使其他工具可进行下游处理(见json.zig)。
  • 标准输入流式处理: 当前实现已通过一次性读取所有内容高效处理标准输入,在当前限制下适用于高达 10MB 的日志(见28)。
  • 多格式支持: 接受不同日志格式(JSON、syslog、自定义)并根据内容模式自动检测它们。
  • HTTP 前端: 打包 WASI 模块用于无服务器函数,通过 POST 接受日志并返回 JSON 摘要(见31)。

注意与警示

  • WASI preview1(当前快照)缺乏网络、线程功能,且文件系统特性有限。标准输入/输出可靠工作,但文件访问需要运行时特定的能力授予。
  • 0.15.2 引入的zig libc工作在 musl 与 wasi-libc 之间共享实现,提升一致性,并使readToEndAlloc等特性可在各平台一致工作。
  • WASI 运行时的权限模型各不相同。Wasmer 的--mapdir在测试中存在问题,而标准输入管道普遍工作。设计 CLI 工具时,面向 WASI 应优先使用标准输入。

练习

  • 添加--format json标志,发射{"info": N, "warn": N, "error": N}而非纯文本摘要,然后通过管道传输至jq验证输出。
  • 扩展analysis.zig,添加单元测试验证大小写不敏感匹配(例如"info"与"INFO"均计数),展示std.ascii.eqlIgnoreCase(见13)。
  • wasm32-freestanding(无 WASI)创建第三个构建目标,将分析器作为可通过@export从 JavaScript 调用的导出函数暴露(见wasm.zig)。
  • 使用大型日志文件(生成 10 万行)对原生与 WASI 执行时间进行基准测试,比较启动开销与吞吐量(见40)。

替代方案与边界情况

  • 若需线程功能,WASI preview2(组件模型)引入实验性并发原语。请查阅上游 WASI 规范获取迁移路径。
  • 对于浏览器目标,切换至wasm32-freestanding并使用 JavaScript 互操作(@export/@extern)而非 WASI 系统调用(见33)。
  • 某些 WASI 运行时(例如 Wasmedge)支持非标准扩展,如套接字或 GPU 访问。为获得最大可移植性请坚持使用 preview1,或清晰记录运行时特定依赖。

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.