Chapter 12Config As Data

配置即数据

概述

配置文件最终会成为内存中的普通数据。通过为这些数据提供丰富的类型——包括默认值、枚举和可选类型——你可以在编译时推理配置错误,以确定性方式验证不变量,并将手动调整的设置传递给下游代码,而无需使用字符串类型的胶水代码(参见11meta.zig)。

本章为基于结构体的配置建立了一个指南:从包含大量默认值的结构体开始,叠加分层覆盖(如环境变量或命令行标志),然后使用显式错误集强制执行防护措施,以便下一个项目中的最终CLI可以信任其输入(参见log.zig)。

学习目标

  • 使用枚举、可选类型和合理的默认值建模嵌套配置结构体,以捕获应用程序意图。
  • 使用反射助手(如std.meta.fields)分层配置文件、环境和运行时覆盖,同时保持合并的类型安全。
  • 使用专用错误集、结构化报告和廉价的诊断来验证配置,以便下游系统可以快速失败。04

结构体作为配置契约

类型化配置反映了你在生产环境中期望的不变量。Zig结构体允许你内联声明默认值,使用枚举编码模式,并分组相关的旋钮,以便调用者不会意外传递格式错误的元组。依赖标准库枚举、日志级别和写入器保持API符合人体工程学,同时遵循v0.15.2中的I/O接口大修。

默认值丰富的结构体定义

基线配置为每个字段提供默认值,包括嵌套结构体。消费者可以使用指定初始化器选择性地覆盖值,而不会丢失其余的默认值。

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

// / Configuration structure for an application with sensible defaults
const AppConfig = struct {
    // / Theme options for the application UI
    pub const Theme = enum { system, light, dark };

    // Default configuration values are specified inline
    host: []const u8 = "127.0.0.1",
    port: u16 = 8080,
    log_level: std.log.Level = .info,
    instrumentation: bool = false,
    theme: Theme = .system,
    timeouts: Timeouts = .{},

    // / Nested configuration for timeout settings
    pub const Timeouts = struct {
        connect_ms: u32 = 200,
        read_ms: u32 = 1200,
    };
};

// / Helper function to print configuration values in a human-readable format
// / writer: any type implementing write() and print() methods
// / label: descriptive text to identify this configuration dump
// / config: the AppConfig instance to display
fn dumpConfig(writer: anytype, label: []const u8, config: AppConfig) !void {
    // Print the label header
    try writer.print("{s}\n", .{label});

    // Print each field with proper formatting
    try writer.print("  host = {s}\n", .{config.host});
    try writer.print("  port = {}\n", .{config.port});

    // Use @tagName to convert enum values to strings
    try writer.print("  log_level = {s}\n", .{@tagName(config.log_level)});
    try writer.print("  instrumentation = {}\n", .{config.instrumentation});
    try writer.print("  theme = {s}\n", .{@tagName(config.theme)});

    // Print nested struct in single line
    try writer.print(
        "  timeouts = .{{ connect_ms = {}, read_ms = {} }}\n",
        .{ config.timeouts.connect_ms, config.timeouts.read_ms },
    );
}

pub fn main() !void {
    // Allocate a fixed buffer for stdout operations
    var stdout_buffer: [2048]u8 = undefined;

    // Create a buffered writer for stdout to reduce syscalls
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &stdout_writer.interface;

    // Create a config using all default values (empty initializer)
    const defaults = AppConfig{};
    try dumpConfig(stdout, "defaults ->", defaults);

    // Create a config with several overridden values
    // Fields not specified here retain their defaults from the struct definition
    const tuned = AppConfig{
        .host = "0.0.0.0", // Bind to all interfaces
        .port = 9090, // Custom port
        .log_level = .debug, // More verbose logging
        .instrumentation = true, // Enable performance monitoring
        .theme = .dark, // Dark theme instead of system default
        .timeouts = .{ // Override nested timeout values
            .connect_ms = 75, // Faster connection timeout
            .read_ms = 1500, // Longer read timeout
        },
    };

    // Add blank line between the two config dumps
    try stdout.writeByte('\n');

    // Display the customized configuration
    try dumpConfig(stdout, "overrides ->", tuned);

    // Flush the buffer to ensure all output is written to stdout
    try stdout.flush();
}
运行
Shell
$ zig run default_config.zig
输出
Shell
defaults ->
  host = 127.0.0.1
  port = 8080
  log_level = info
  instrumentation = false
  theme = system
  timeouts = .{ connect_ms = 200, read_ms = 1200 }

overrides ->
  host = 0.0.0.0
  port = 9090
  log_level = debug
  instrumentation = true
  theme = dark
  timeouts = .{ connect_ms = 75, read_ms = 1500 }

可选类型与哨兵默认值

只有真正需要三态语义的字段才成为可选类型(本章后面用于TLS文件路径的?[]const u8);其他所有内容都坚持具体默认值。将嵌套结构体(此处为Timeouts)与[]const u8字符串结合使用,提供在配置生命周期内保持有效的不可变引用(参见03)。

指定覆盖保持可读性

由于指定初始化器允许你仅覆盖你关心的字段,你可以将配置声明保持在调用站点附近,而不会牺牲可发现性。将结构体字面量视为文档:将相关的覆盖分组在一起,并依赖枚举(如Theme)将魔法字符串排除在你的构建之外。02, enums.zig

从字符串解析枚举值

从JSON、YAML或环境变量加载配置时,你通常需要将字符串转换为枚举值。Zig的std.meta.stringToEnum基于枚举大小使用编译时优化来处理此问题。

graph LR STRINGTOENUM["stringToEnum(T, str)"] subgraph "Small Enums" SMALL["fields.len <= 100"] MAP["StaticStringMap"] STRINGTOENUM --> SMALL SMALL --> MAP end subgraph "Large Enums" LARGE["fields.len > 100"] INLINE["inline for loop"] STRINGTOENUM --> LARGE LARGE --> INLINE end RESULT["?T"] MAP --> RESULT INLINE --> RESULT

For small enums (≤100 fields), stringToEnum builds a compile-time StaticStringMap for O(1) lookups. Larger enums use an inline loop to avoid compilation slowdowns from massive switch statements. The function returns ?T (optional enum value), allowing you to handle invalid strings gracefully:

Zig
const theme_str = "dark";
const theme = std.meta.stringToEnum(Theme, theme_str) orelse .system;

This pattern is essential for config loaders: parse the string, fall back to a sensible default if invalid. The optional return forces you to handle the error case explicitly, preventing silent failures from typos in config files (see meta.zig).

分层和覆盖

实际部署从多个来源拉取配置。通过将每个层表示为可选类型的结构体,你可以确定性地合并它们:反射桥接使得无需为每个旋钮手动编写样板代码就能轻松迭代字段。05

合并分层覆盖

此程序应用配置文件、环境和命令行覆盖(如果存在),否则回退到默认值。合并顺序在apply中变得明确,并且结果结构体保持完全类型化。

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

//  Configuration structure for an application with sensible defaults
const AppConfig = struct {
    //  Theme options for the application UI
    pub const Theme = enum { system, light, dark };

    host: []const u8 = "127.0.0.1",
    port: u16 = 8080,
    log_level: std.log.Level = .info,
    instrumentation: bool = false,
    theme: Theme = .system,
    timeouts: Timeouts = .{},

    //  Nested configuration for timeout settings
    pub const Timeouts = struct {
        connect_ms: u32 = 200,
        read_ms: u32 = 1200,
    };
};

//  Structure representing optional configuration overrides
//  Each field is optional (nullable) to indicate whether it should override the base config
const Overrides = struct {
    host: ?[]const u8 = null,
    port: ?u16 = null,
    log_level: ?std.log.Level = null,
    instrumentation: ?bool = null,
    theme: ?AppConfig.Theme = null,
    timeouts: ?AppConfig.Timeouts = null,
};

//  Merges a single layer of overrides into a base configuration
//  base: the starting configuration to modify
//  overrides: optional values that should replace corresponding base fields
//  Returns: a new AppConfig with overrides applied
fn merge(base: AppConfig, overrides: Overrides) AppConfig {
    // Start with a copy of the base configuration
    var result = base;

    // Iterate over all fields in the Overrides struct at compile time
    inline for (std.meta.fields(Overrides)) |field| {
        // Check if this override field has a non-null value
        if (@field(overrides, field.name)) |value| {
            // If present, replace the corresponding field in result
            @field(result, field.name) = value;
        }
    }

    return result;
}

//  Applies a chain of override layers in sequence
//  base: the initial configuration
//  chain: slice of Overrides to apply in order (left to right)
//  Returns: final configuration after all layers are merged
fn apply(base: AppConfig, chain: []const Overrides) AppConfig {
    // Start with the base configuration
    var current = base;

    // Apply each override layer in sequence
    // Later layers override earlier ones
    for (chain) |layer| {
        current = merge(current, layer);
    }

    return current;
}

//  Helper function to print configuration values in a human-readable format
//  writer: any type implementing write() and print() methods
//  label: descriptive text to identify this configuration dump
//  config: the AppConfig instance to display
fn printSummary(writer: anytype, label: []const u8, config: AppConfig) !void {
    try writer.print("{s}:\n", .{label});
    try writer.print("  host = {s}\n", .{config.host});
    try writer.print("  port = {}\n", .{config.port});
    try writer.print("  log = {s}\n", .{@tagName(config.log_level)});
    try writer.print("  instrumentation = {}\n", .{config.instrumentation});
    try writer.print("  theme = {s}\n", .{@tagName(config.theme)});
    try writer.print("  timeouts = {any}\n", .{config.timeouts});
}

pub fn main() !void {
    // Create base configuration with all default values
    const defaults = AppConfig{};

    // Define a profile-level override layer (e.g., development profile)
    // This might come from a profile file or environment-specific settings
    const profile = Overrides{
        .host = "0.0.0.0",
        .port = 9000,
        .log_level = .debug,
        .instrumentation = true,
        .theme = .dark,
        .timeouts = AppConfig.Timeouts{
            .connect_ms = 100,
            .read_ms = 1500,
        },
    };

    // Define environment-level overrides (e.g., from environment variables)
    // These override profile settings
    const env = Overrides{
        .host = "config.internal",
        .port = 9443,
        .log_level = .warn,
        .timeouts = AppConfig.Timeouts{
            .connect_ms = 60,
            .read_ms = 1100,
        },
    };

    // Define command-line overrides (highest priority)
    // Only overrides specific fields, leaving others unchanged
    const command_line = Overrides{
        .instrumentation = false,
        .theme = .light,
    };

    // Apply all override layers in precedence order:
    // defaults -> profile -> env -> command_line
    // Later layers take precedence over earlier ones
    const final = apply(defaults, &[_]Overrides{ profile, env, command_line });

    // Set up buffered stdout writer to reduce syscalls
    var stdout_buffer: [2048]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &stdout_writer.interface;

    // Display progression of configuration through each layer
    try printSummary(stdout, "defaults", defaults);
    try printSummary(stdout, "profile", merge(defaults, profile));
    try printSummary(stdout, "env", merge(defaults, env));
    try printSummary(stdout, "command_line", merge(defaults, command_line));

    // Add separator before showing final resolved config
    try stdout.writeByte('\n');

    // Display the final merged configuration after all layers applied
    try printSummary(stdout, "resolved", final);

    // Ensure all buffered output is written
    try stdout.flush();
}
运行
Shell
$ zig run chapters-data/code/12__config-as-data/merge_overrides.zig
输出
Shell
defaults:
  host = 127.0.0.1
  port = 8080
  log = info
  instrumentation = false
  theme = system
  timeouts = .{ .connect_ms = 200, .read_ms = 1200 }
profile:
  host = 0.0.0.0
  port = 9000
  log = debug
  instrumentation = true
  theme = dark
  timeouts = .{ .connect_ms = 100, .read_ms = 1500 }
env:
  host = config.internal
  port = 9443
  log = warn
  instrumentation = false
  theme = system
  timeouts = .{ .connect_ms = 60, .read_ms = 1100 }
command_line:
  host = 127.0.0.1
  port = 8080
  log = info
  instrumentation = false
  theme = light
  timeouts = .{ .connect_ms = 200, .read_ms = 1200 }

resolved:
  host = config.internal
  port = 9443
  log = warn
  instrumentation = false
  theme = light
  timeouts = .{ .connect_ms = 60, .read_ms = 1100 }

参见10了解与分层配置相关的分配器背景知识。

字段迭代的底层工作原理

apply函数使用std.meta.fields在编译时迭代结构体字段。Zig的反射API提供了一组丰富的内省功能,使得无需为每个字段手动编写样板代码就能实现通用配置合并。

graph TB subgraph "Container Introspection" FIELDS["fields(T)"] FIELDINFO["fieldInfo(T, field)"] FIELDNAMES["fieldNames(T)"] TAGS["tags(T)"] FIELDENUM["FieldEnum(T)"] end subgraph "Declaration Introspection" DECLARATIONS["declarations(T)"] DECLINFO["declarationInfo(T, name)"] DECLENUM["DeclEnum(T)"] end subgraph "Applicable Types" STRUCT["struct"] UNION["union"] ENUMP["enum"] ERRORSET["error_set"] end STRUCT --> FIELDS UNION --> FIELDS ENUMP --> FIELDS ERRORSET --> FIELDS STRUCT --> DECLARATIONS UNION --> DECLARATIONS ENUMP --> DECLARATIONS FIELDS --> FIELDINFO FIELDS --> FIELDNAMES FIELDS --> FIELDENUM ENUMP --> TAGS

内省API提供:

  • fields(T):返回任何结构体、联合体、枚举或错误集的编译时字段信息
  • fieldInfo(T, field):获取特定字段的详细信息(名称、类型、默认值、对齐方式)
  • FieldEnum(T):为每个字段名称创建一个枚举变体,用于字段的switch语句
  • declarations(T):返回类型中函数和常量的编译时声明信息

当你在合并逻辑中看到inline for (std.meta.fields(Config))时,Zig在编译时展开此循环,为每个字段生成专门的代码。这消除了运行时开销,同时保持类型安全——编译器验证所有字段类型在层之间匹配(参见meta.zig)。

明确优先级

因为apply在每次迭代时复制合并的结构体,切片字面量的顺序读取为从上到下的优先级:后面的条目获胜。如果你需要惰性求值或短路合并,将apply替换为一旦字段设置就停止的版本——只需记住保持默认值不可变,以便较早的层不会意外改变共享状态。07

使用std.meta.eql进行深度结构相等性比较

对于高级配置场景,如检测是否需要重新加载,std.meta.eql(a, b)执行深度结构比较。此函数递归处理嵌套结构体、联合体、错误联合体和可选类型:

graph TB subgraph "Type Comparison" EQL["eql(a, b)"] STRUCT_EQL["Struct comparison"] UNION_EQL["Union comparison"] ERRORUNION_EQL["Error union comparison"] OPTIONAL_EQL["Optional comparison"] EQL --> STRUCT_EQL EQL --> UNION_EQL EQL --> ERRORUNION_EQL EQL --> OPTIONAL_EQL end

eql(a, b)函数执行深度结构相等性比较,递归处理嵌套结构体、联合体和错误联合体。这对于检测"无操作"配置更新很有用:

Zig
const old_config = loadedConfig;
const new_config = parseConfigFile("app.conf");

if (std.meta.eql(old_config, new_config)) {
    // 跳过重新加载,没有变化
    return;
}
// 应用新配置

比较对结构体逐字段工作(包括嵌套的Timeouts),比较联合体的标签和有效载荷,并正确处理错误联合体和可选类型(参见meta.zig)。

验证和防护

一旦你保护了它们的不变量,类型化配置就变得可信。Zig的错误集将验证失败转化为可操作的诊断,辅助函数保持报告一致性,无论你是记录日志还是向CLI提供反馈(参见04debug.zig)。

使用错误集编码不变量

此验证器检查端口范围、TLS先决条件和超时顺序。每个失败都映射到专用的错误标签,以便调用者可以相应地做出反应。

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

//  Environment mode for the application
//  应用程序的环境模式
//  Determines security requirements and runtime behavior
//  确定安全要求和运行时行为
const Mode = enum { development, staging, production };

//  Main application configuration structure with nested settings
//  带嵌套设置的主应用程序配置结构
const AppConfig = struct {
    host: []const u8 = "127.0.0.1",
    port: u16 = 8080,
    mode: Mode = .development,
    tls: Tls = .{},
    timeouts: Timeouts = .{},

    //  TLS/SSL configuration for secure connections
    //  用于安全连接的 TLS/SSL 配置
    pub const Tls = struct {
        enabled: bool = false,
        cert_path: ?[]const u8 = null,
        key_path: ?[]const u8 = null,
    };

    //  Timeout settings for network operations
    //  网络操作的超时设置
    pub const Timeouts = struct {
        connect_ms: u32 = 200,
        read_ms: u32 = 1200,
    };
};

//  Explicit error set for all configuration validation failures
//  所有配置验证失败的显式错误集合
//  Each variant represents a specific invariant violation
//  每个变体代表一个特定的不变式违反
const ConfigError = error{
    InvalidPort,
    InsecureProduction,
    MissingTlsMaterial,
    TimeoutOrdering,
};

//  Validates configuration invariants and business rules
//  验证配置不变式和业务规则
//  config: the configuration to validate
//  config: 要验证的配置
//  Returns: ConfigError if any validation rule is violated
//  返回:如果违反任何验证规则则返回 ConfigError
fn validate(config: AppConfig) ConfigError!void {
    // Port 0 is reserved and invalid for network binding
    // 端口0是保留的,用于网络绑定无效
    if (config.port == 0) return error.InvalidPort;

    // Ports below 1024 require elevated privileges (except standard HTTPS)
    // 1024以下的端口需要提升权限(标准HTTPS除外)
    // Reject them to avoid privilege escalation requirements
    // 拒绝它们以避免权限提升要求
    if (config.port < 1024 and config.port != 443) return error.InvalidPort;

    // Production environments must enforce TLS to protect data in transit
    // 生产环境必须强制使用TLS以保护传输中的数据
    if (config.mode == .production and !config.tls.enabled) {
        return error.InsecureProduction;
    }

    // When TLS is enabled, both certificate and private key must be provided
    // 当启用TLS时,必须同时提供证书和私钥
    if (config.tls.enabled) {
        if (config.tls.cert_path == null or config.tls.key_path == null) {
            return error.MissingTlsMaterial;
        }
    }

    // Read timeout must exceed connect timeout to allow data transfer
    // 读取超时必须超过连接超时以允许数据传输
    // Otherwise connections would time out immediately after establishment
    // 否则连接会在建立后立即超时
    if (config.timeouts.read_ms < config.timeouts.connect_ms) {
        return error.TimeoutOrdering;
    }
}

// / Reports validation result in human-readable format
// / 以人类可读格式报告验证结果
// / writer: output destination for the report
// / writer: 报告的输出目标
// / label: descriptive name for this configuration test case
// / label: 此配置测试用例的描述性名称
// / config: the configuration to validate and report on
// / config: 要验证和报告的配置
fn report(writer: anytype, label: []const u8, config: AppConfig) !void {
    try writer.print("{s}: ", .{label});

    // Attempt validation and catch any errors
    // 尝试 validation 和 捕获 any 错误
    validate(config) catch |err| {
        // If validation fails, report the error name and return
        // 如果 validation fails, report 错误 name 和 返回
        return try writer.print("error {s}\n", .{@errorName(err)});
    };

    // If validation succeeded, report success
    // 如果 validation succeeded, report 成功
    try writer.print("ok\n", .{});
}

pub fn main() !void {
    // Test case 1: Valid production configuration
    // All security requirements met: TLS enabled with credentials
    // 所有 security requirements met: TLS enabled 使用 credentials
    const production = AppConfig{
        .host = "example.com",
        .port = 8443,
        .mode = .production,
        .tls = .{
            .enabled = true,
            .cert_path = "certs/app.pem",
            .key_path = "certs/app.key",
        },
        .timeouts = .{
            .connect_ms = 250,
            .read_ms = 1800,
        },
    };

    // Test case 2: Invalid - production mode without TLS
    // Test case 2: 无效 - production 模式 without TLS
    // Should trigger InsecureProduction error
    // Should trigger InsecureProduction 错误
    const insecure = AppConfig{
        .mode = .production,
        .tls = .{ .enabled = false },
    };

    // Test case 3: Invalid - read timeout less than connect timeout
    // Test case 3: 无效 - 读取 timeout less than connect timeout
    // Should trigger TimeoutOrdering error
    // Should trigger TimeoutOrdering 错误
    const misordered = AppConfig{
        .timeouts = .{
            .connect_ms = 700,
            .read_ms = 500,
        },
    };

    // Test case 4: Invalid - TLS enabled but missing certificate
    // Test case 4: 无效 - TLS enabled but 缺失 certificate
    // Should trigger MissingTlsMaterial error
    // Should trigger MissingTlsMaterial 错误
    const missing_tls_material = AppConfig{
        .mode = .staging,
        .tls = .{
            .enabled = true,
            .cert_path = null,
            .key_path = "certs/dev.key",
        },
    };

    // Set up buffered stdout writer to reduce syscalls
    // 设置缓冲stdout写入器以减少系统调用
    var stdout_buffer: [1024]u8 = undefined;
    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
    const stdout = &stdout_writer.interface;

    // Run validation reports for all test cases
    // Run validation reports 用于 所有 test 情况
    // Each report will validate the config and print the result
    // 每个 report will 验证 config 和 打印 result
    try report(stdout, "production", production);
    try report(stdout, "insecure", insecure);
    try report(stdout, "misordered", misordered);
    try report(stdout, "missing_tls_material", missing_tls_material);

    // Ensure all buffered output is written to stdout
    // 确保所有缓冲输出写入stdout
    try stdout.flush();
}
运行
Shell
$ zig run chapters-data/code/12__config-as-data/validate_config.zig
输出
Shell
production: ok
insecure: error InsecureProduction
misordered: error TimeoutOrdering
missing_tls_material: error MissingTlsMaterial

04__errors-resource-cleanup.xml

报告有用的诊断信息

在打印验证错误时使用@errorName(或用于更丰富数据的结构化枚举),以便操作员看到确切失败的不变量。将其与共享的报告助手(如示例中的report)配对,以统一测试、日志记录和CLI反馈的格式(参见03Writer.zig)。

错误消息格式标准

对于生产级诊断,遵循编译器的错误消息格式以提供一致、可解析的输出。标准格式符合用户对Zig工具的期望:

组件格式描述
位置:line:col:行号和列号(1索引)
严重性error:note:消息严重性级别
消息文本实际的错误或注释消息

示例错误消息:

config.toml:12:8: error: port must be between 1024 and 65535
config.toml:15:1: error: TLS enabled but cert_file not specified
config.toml:15:1: note: set cert_file and key_file when tls = true

冒号分隔的格式允许工具解析错误位置以进行IDE集成,严重性级别(error: vs note:)帮助用户区分问题和有用的上下文。在验证配置文件时,包括文件名、行号(如果从解析器可用)以及对不变量违反的清晰描述。这种一致性使你的配置错误感觉像是Zig生态系统原生的。

用于模式漂移的编译时助手

对于较大的系统,考虑将你的配置结构体包装在一个编译时函数中,该函数使用@hasField验证字段存在性或从默认值生成文档。这使运行时代码保持小巧,同时保证演进的模式与生成的配置文件保持同步(参见15)。

注意事项

  • 为字符串设置保留不可变的[]const u8切片,以便它们可以安全地别名编译时常量而无需额外副本(参见mem.zig)。
  • 在发出配置诊断后记得刷新缓冲写入器,特别是在将stdout与进程管道混合使用时。
  • 在分层覆盖时,在突变之前克隆可变子结构体(如分配器支持的列表),以避免跨层别名。10

练习

  • 使用可选的遥测端点(?[]const u8)扩展AppConfig,并更新验证器以确保在启用检测时设置它。
  • 实现一个fromArgs助手,将键值命令行对解析为覆盖结构体,重用分层函数来应用它们。05
  • 通过在编译时迭代std.meta.fields(AppConfig)并将行写入缓冲写入器来生成总结默认值的Markdown表格。11

替代方案与边缘情况

  • 对于大型配置,将JSON/YAML数据流式传输到竞技场支持的结构体中,而不是在栈上构建所有内容,以避免耗尽临时缓冲区(参见10)。
  • 如果需要动态键,将基于结构体的配置与std.StringHashMap查找配对,这样你可以在保持类型化默认值的同时仍然尊重用户提供的额外内容(参见hash_map.zig)。
  • 在验证通过网络上传的文件时,考虑使用std.io.Reader管道;这让你可以在具体化整个配置之前短路(参见28)。

Help make this chapter better.

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