Chapter 42Project Wasi Build And Run

项目

概述

借助上一章的跨编译机制(参见第41章),我们现在可以组装一个完整的WASI项目,该项目使用单个build.zig文件编译到本机和WebAssembly目标。本章构建一个小型的日志分析器CLI,它读取输入、处理输入并发出汇总统计信息——这种功能清晰地映射到WASI的文件和stdio功能(参见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
.{
    // Package identifier used in dependencies and imports
    // Must be a valid Zig identifier (no hyphens or special characters)
    .name = .log_analyzer,
    
    // Semantic version of this package
    // Format: major.minor.patch following semver conventions
    .version = "0.1.0",
    
    // Minimum Zig compiler version required to build this package
    // Ensures compatibility with language features and build system APIs
    .minimum_zig_version = "0.15.2",
    
    // List of paths to include when publishing or distributing the package
    // Empty string includes all files in the package directory
    .paths = .{
        "",
    },
    
    // Unique identifier generated by the package manager for integrity verification
    // Used to detect changes and ensure package authenticity
    .fingerprint = 0xba0348facfd677ff,
}

.minimum_zig_version字段可防止使用缺少0.15.2版本中引入的WASI改进的旧编译器进行意外构建。

构建系统设置

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

多目标构建脚本

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

/// Build script for log-analyzer project demonstrating native and WASI cross-compilation.
/// Produces two executables: one for native execution and one for WASI runtimes.
pub fn build(b: *std.Build) void {
    // Standard target and optimization options from command-line flags
    // These allow users to specify --target and --optimize when building
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Native executable: optimized for fast runtime performance on the host system
    // This target respects user-specified target and optimization settings
    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,
        }),
    });
    // Register the native executable for installation to zig-out/bin
    b.installArtifact(exe_native);

    // WASI executable: cross-compiled to WebAssembly with WASI support
    // Uses ReleaseSmall to minimize binary size for portable distribution
    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, // Prioritize small binary size over speed
        }),
    });
    // Register the WASI executable for installation to zig-out/bin
    b.installArtifact(exe_wasi);

    // Create run step for native target that executes the compiled binary directly
    const run_native = b.addRunArtifact(exe_native);
    // Ensure the binary is built and installed before attempting to run it
    run_native.step.dependOn(b.getInstallStep());
    // Forward any command-line arguments passed after -- to the executable
    if (b.args) |args| {
        run_native.addArgs(args);
    }
    // Register the run step so users can invoke it with `zig build run-native`
    const run_native_step = b.step("run-native", "Run the native log analyzer");
    run_native_step.dependOn(&run_native.step);

    // Create run step for WASI target with automatic runtime detection
    // First, attempt to detect an available WASI runtime (wasmtime or wasmer)
    const run_wasi = b.addSystemCommand(&.{"echo"});
    const wasi_runtime = detectWasiRuntime(b) orelse {
        // If no runtime is found, provide a helpful error message
        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;
    };

    // Construct the command to run the WASI binary with the detected runtime
    const run_wasi_cmd = b.addSystemCommand(&.{wasi_runtime});
    // Both wasmtime and wasmer require the 'run' subcommand
    if (std.mem.eql(u8, wasi_runtime, "wasmtime") or std.mem.eql(u8, wasi_runtime, "wasmer")) {
        run_wasi_cmd.addArg("run");
        // Grant access to the current directory for file I/O operations
        run_wasi_cmd.addArg("--dir=.");
    }
    // Add the WASI binary as the target to execute
    run_wasi_cmd.addArtifactArg(exe_wasi);
    // Forward user arguments after the -- separator to the WASI program
    if (b.args) |args| {
        run_wasi_cmd.addArg("--");
        run_wasi_cmd.addArgs(args);
    }
    // Ensure the WASI binary is built before attempting to run it
    run_wasi_cmd.step.dependOn(b.getInstallStep());

    // Register the WASI run step so users can invoke it with `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);
}

/// Detect available WASI runtime in the system PATH.
/// Checks for wasmtime first, then wasmer as a fallback.
/// Returns the name of the detected runtime, or null if neither is found.
fn detectWasiRuntime(b: *std.Build) ?[]const u8 {
    // Attempt to locate wasmtime using the 'which' command
    var exit_code: u8 = undefined;
    _ = b.runAllowFail(&.{ "which", "wasmtime" }, &exit_code, .Ignore) catch {
        // If wasmtime is not found, try wasmer as a fallback
        _ = b.runAllowFail(&.{ "which", "wasmer" }, &exit_code, .Ignore) catch {
            // Neither runtime was found in PATH
            return null;
        };
        return "wasmer";
    };
    // wasmtime was successfully located
    return "wasmtime";
}
构建
Shell
$ zig build
输出
Shell
(成功时无输出;产物安装到zig-out/bin/)

WASI目标设置-OReleaseSmall以最小化模块大小,而本机目标使用-OReleaseFast以提高运行时速度——展示了每个产物的优化控制。

分析逻辑

分析器读取整个日志内容,按行拆分,计算严重性关键字(ERROR, WARN, INFO)的出现次数,并打印摘要。我们将解析部分拆分到analysis.zig中,以便可以独立于I/O进行单元测试。

核心分析模块

Zig

// This module provides log analysis functionality for counting severity levels in log files.
// It demonstrates basic string parsing and struct usage in Zig.
const std = @import("std");

// LogStats holds the count of each log severity level found during analysis.
// All fields are initialized to zero by default, representing no logs counted yet.
pub const LogStats = struct {
    info_count: u32 = 0,
    warn_count: u32 = 0,
    error_count: u32 = 0,
};

/// Analyze log content, counting severity keywords.
/// Returns statistics in a LogStats struct.
pub fn analyzeLog(content: []const u8) LogStats {
    // Initialize stats with all counts at zero
    var stats = LogStats{};
    
    // Create an iterator that splits the content by newline characters
    // This allows us to process the log line by line
    var it = std.mem.splitScalar(u8, content, '\n');

    // Process each line in the log content
    while (it.next()) |line| {
        // Count occurrences of severity keywords
        // indexOf returns an optional - if found, we increment the corresponding counter
        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 basic log analysis with multiple severity levels
test "analyzeLog basic counting" {
    const input = "INFO startup\nERROR failed\nWARN retry\nINFO success\n";

    const stats = analyzeLog(input);
    
    // Verify each severity level was counted correctly
    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 that empty input produces zero counts for all severity levels
test "analyzeLog empty input" {
    const input = "";

    const stats = analyzeLog(input);
    
    // All counts should remain at their default zero value
    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 {
    // Initialize general-purpose allocator for dynamic memory allocation
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Parse command-line arguments into an allocated slice
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    // Check for optional --input flag to specify a file path
    var input_path: ?[]const u8 = null;
    var i: usize = 1; // Skip program name at 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;
            }
        }
    }

    // Read input content from either file or stdin
    // Using labeled blocks to unify type across both branches
    const content = if (input_path) |path| blk: {
        std.debug.print("analyzing: {s}\n", .{path});
        // Read entire file content with 10MB limit
        break :blk try std.fs.cwd().readFileAlloc(allocator, path, 10 * 1024 * 1024);
    } else blk: {
        std.debug.print("analyzing: stdin\n", .{});
        // Construct File handle directly from stdin file descriptor
        const stdin = std.fs.File{ .handle = std.posix.STDIN_FILENO };
        // Read all available stdin data with same 10MB limit
        break :blk try stdin.readToEndAlloc(allocator, 10 * 1024 * 1024);
    };
    defer allocator.free(content);

    // Delegate log analysis to the analysis module
    const stats = analysis.analyzeLog(content);
    
    // Print summary statistics to 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),因为它省略了本机操作系统集成,并依赖宿主运行时进行系统调用,使其成为边缘部署或浏览器环境的理想选择。

扩展项目

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

  • JSON输出:使用std.json.stringify发出结构化结果,启用其他工具的下游处理(参见json.zig)。
  • 从stdin流式传输:当前的实现已经通过一次性读取所有内容高效地处理stdin,适合当前限制下最多10MB的日志(参见第28章)。
  • 多格式支持:接受不同的日志格式(JSON、syslog、自定义)并基于内容模式自动检测它们。
  • HTTP前端:将WASI模块打包用于无服务器函数,该函数通过POST接受日志并返回JSON摘要(参见第31章)。

注意与警告

  • WASI preview1(当前快照)缺少网络、线程,并且文件系统功能有限。Stdin/stdout可靠工作,但文件访问需要运行时特定的功能授权。
  • 0.15.2版本中引入的zig libc在musl和wasi-libc之间共享实现,提高了跨平台的一致性,并使readToEndAlloc等函数能够在不同平台上以相同方式工作。
  • WASI运行时的权限模型各不相同。Wasmer的--mapdir在测试中存在问题,而stdin管道则普遍适用。在为WASI设计CLI工具时,应优先考虑stdin。

练习

  • 添加一个--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.