Zig 0.17 构建系统重构:Maker 与 Configurer 双进程架构

发布: 2026-10-04   上次更新: 2026-10-05   分类: 编程语言   标签: zig

文章目录

在此前的两篇文章 《Zig 构建系统(上):设计理念与最佳实践》 与 《Zig 构建系统(下):编译管线与底层运行机制》 中,我们讨论了 Zig 0.16 时代的构建系统抽象模型以及编译流水线的底层运行机制。

在 Zig 0.17.0 中,构建系统的底层架构经历了重构:

本文结合 Zig 0.17.0 源码,分析这套新架构的设计与底层实现。本文同样在 Antigravity CLI (agy) 协助下完成。


1. 双进程架构演进

1.1 0.16 的单体运行器

在 Zig 0.16 中,执行 zig build 时的核心逻辑是将 build_runner.zig 和用户项目的 build.zig 绑定,动态编译成一个单一的临时二进制程序 build:

  flowchart TD
    subgraph Legacy ["0.16.0 之前的单体架构 (Monolithic Build Runner)"]
        Src["build.zig + build_runner.zig"]
        Bin["编译生成单体 build 运行器"]
        Phase1["阶段一:在内存中 eval build() 构筑 DAG"]
        Phase2["阶段二:同一进程内拓扑排序并调度 Step.make()"]

        Src -- "每次改动 build.zig 均需重新编译" --> Bin
        Bin --> Phase1
        Phase1 --> Phase2
    end

    style Legacy stroke:#495057,stroke-width:2px;
    style Src stroke:#ff9900,stroke-width:2px;
    style Bin stroke:#0066cc,stroke-width:2px;
    style Phase1 stroke:#0066cc,stroke-width:2px;
    style Phase2 stroke:#009900,stroke-width:2px;

这种单体设计主要存在以下问题:

  1. 冷启动与编译耗时:用户哪怕只在 build.zig 中改动了一行配置,整个 build_runner 都需要全量重新编译;
  2. 无法开启 Release 级优化:为了避免冷启动耗时过长,单体运行器只能以 Debug 或无优化模式编译。但在后期的 DAG 调度、哈希计算以及 --watch、--fuzz 等长会话场景下,对执行性能有更高要求;
  3. 缺少配置级缓存:即便命令行参数和外部环境完全没有变化,每次执行 zig build 仍然必须把 build() 函数从头到尾执行一遍;
  4. 第三方生态集成成本高:语言服务器(如 ZLS)为了探测项目的构建图,不得不采取 Hack 方式自定义或 Fork 官方的 build_runner.zig,维护成本高且容易失效。

1.2 Maker 与 Configurer 解耦

在 0.17.0 中,Zig 彻底废除了“Build Runner”概念(PR #35428),将构建生命周期切分为职责正交的两个独立进程:

整个两阶段流转及会话重跑机制如下:

  flowchart TD
    subgraph Entrance ["启动入口与进程自举"]
        User["用户执行 zig build"]
        Jit["src/main.zig: 检查/JIT 编译 Maker"]
        Exec["process.replace (execve 原地替换进程)"]
        User --> Jit --> Exec
    end

    subgraph ConfigPhase ["阶段一:构建图配置探测 (双轨机制)"]
        Check{"配置缓存是否命中且未污染?"}
        FastPath["Fast Path: 直接读取 .zig-cache/c/{digest}"]

        subgraph SubConf ["派生临时子进程 (Slow Path)"]
            Spawn["Maker 派生 configurer 子进程"]
            Eval["configurer: 执行 build.zig 并构建内存图"]
            Dump["configurer: 序列化为紧凑二进制流并退出"]
            Spawn --> Eval --> Dump
        end

        Check -- "命中 (Pure)" --> FastPath
        Check -- "未命中 / 污染" --> Spawn
    end

    subgraph MakePhase ["阶段二:任务调度与构建执行 (Maker 独占)"]
        LoadGraph["Maker: 加载二进制构建图配置"]
        ResolveDeps["Maker: 解析包依赖并执行惰性拉取"]
        ExecSteps["Maker: 多线程 WorkerPool 并发调度 Step"]
        ExitCheck{"是否开启 --watch / --listen 等会话模式?"}
        Term["构建完成,Maker 进程正常退出"]
        Session["持续保持事件循环,监听文件系统变更"]

        LoadGraph --> ResolveDeps --> ExecSteps --> ExitCheck
        ExitCheck -- "否 (普通单次构建)" --> Term
        ExitCheck -- "是 (会话持久模式)" --> Session
    end

    Exec --> Check
    FastPath --> LoadGraph
    Dump -- "管道二进制配置流" --> LoadGraph

    %% 会话持久模式下的事件分流回环
    Session -- "源码变动 (src/*.zig) 增量重跑" --> ExecSteps
    Session -- "配置变动 (build.zig) 重新配置" --> Check

    style Entrance stroke:#495057,stroke-width:2px;
    style ConfigPhase stroke:#0066cc,stroke-width:2px;
    style SubConf stroke:#ff9900,stroke-width:2px;
    style MakePhase stroke:#009900,stroke-width:2px;
    style User stroke:#495057,stroke-width:2px;
    style Jit stroke:#0066cc,stroke-width:2px;
    style Exec stroke:#009900,stroke-width:2px;
    style Check stroke:#ffc107,stroke-width:2px;
    style FastPath stroke:#009900,stroke-width:2px;
    style Spawn stroke:#ff9900,stroke-width:2px;
    style Eval stroke:#ff9900,stroke-width:2px;
    style Dump stroke:#0066cc,stroke-width:2px;
    style LoadGraph stroke:#0066cc,stroke-width:2px;
    style ResolveDeps stroke:#0066cc,stroke-width:2px;
    style ExecSteps stroke:#009900,stroke-width:2px;
    style ExitCheck stroke:#ffc107,stroke-width:2px;
    style Term stroke:#495057,stroke-width:2px;
    style Session stroke:#0066cc,stroke-width:2px;

如上图所示,在底层实现中,Maker 主控会话的核心骨架是由内外两层嵌套的循环构成的(提炼自 lib/compiler/Maker.zig:L737-L1040):

 1// lib/compiler/Maker.zig: Core dual-loop skeleton of the Maker session
 2configure: while (true) {
 3    // Phase 1: Configuration probing (Fast Path via cache, Slow Path via configurer)
 4    var scanned_config = try configure(&graph, ...);
 5
 6    // Initialize Maker with the probed configuration
 7    var maker: Maker = .{
 8        .graph = &graph,
 9        .scanned_config = &scanned_config,
10        // ...
11    };
12    defer maker.deinit();
13
14    rebuild: while (true) {
15        // Phase 2: DAG task scheduling and step execution
16        try maker.makeSteps(main_progress_node, ...);
17
18        // One-shot build: exit immediately once steps finish!
19        if (!maker.watch) return;
20
21        // Session mode (--watch / --listen): wait for file changes or RPC requests
22        switch (try watch.wait(timeout)) {
23            error.MustReconfigure => {
24                // build.zig / zon changed: loop back to Phase 1 to re-run configurer
25                continue :configure;
26            },
27            .timeout => {
28                // Regular source files (src/*.zig) changed: loop back to Phase 2 to rebuild dirty steps
29                markFailedStepsDirty(&maker);
30                continue :rebuild;
31            },
32        }
33    }
34}

从上述核心骨架可以看出 Maker 在监听到不同事件时的分级处理机制:

双进程优势

  1. Maker 一次编译,全局复用:Maker 包含包管理、依赖抓取、缓存管理及任务执行引擎。它完全与用户工程代码解耦,仅依赖 Zig 工具链本身,因此在用户安装或首次使用 Zig 时编译一次即可(存放于全局缓存中),之后无论用户怎么修改 build.zig,Maker 都不需要重新编译;
  2. 优化级别更高:Maker 默认以 -O ReleaseSafe 编译。在包含大量 Step 的大型项目或开启 --watch / --fuzz 会话模式时,高优化级别能有效降低任务拓扑排序、文件哈希比对与进程派生时的开销;
  3. 配置可缓存:若构建配置未发生变更且未被外部环境污染,Maker 会直接跳过编译和运行 configurer,复用已有配置;
  4. 序列化协议标准化:构建图的配置信息被序列化为紧凑二进制格式,可直接作为第三方 IDE 和工具链通信的数据载体。

2. 进程自举:jitCmd

在 0.17.0 中,编译器本体二进制大幅精简。在 src/main.zig:L352-L363 中:

 1.build, .fetch, .init, .libc, .@"cache-cat" => {
 2    return jitCmd(gpa, arena, io, cmd_args, environ_map, .{
 3        .cmd_name = "maker",
 4        .root_src_path = "Maker.zig",
 5        .prepend_cmd = cmd,
 6        .prepend_zig_lib_dir_path = true,
 7        .prepend_global_cache_path = true,
 8        .prepend_zig_exe_path = true,
 9        .prepend_seed = true,
10        .release_mode = .safe,
11    });
12},

所有构建与包管理相关的子命令,都被收敛到一个统一的 JIT 自举入口 jitCmd。

2.1 即时编译逻辑

跟踪到 src/main.zig:L5032-L5191 的 jitCmdInner 函数:

  1. 确定优化级别与目标:
    1const optimize_mode: std.lang.Optimize = if (EnvVar.ZIG_DEBUG_CMD.isSet(environ_map))
    2    .debug
    3else
    4    options.release_mode; // Default is .safe
    
  2. 创建编译实例:Zig 编译器在内存中直接创建 Compilation 实例,将 lib/compiler/Maker.zig 作为根模块进行编译;
  3. 产物落盘至全局缓存: 编译生成的 maker 二进制文件存放在全局缓存中:
    1const exe_path = try dirs.global_cache.join(arena, &.{
    2    "o",
    3    &Cache.binToHex(comp.digest.?),
    4    comp.emit_bin.?,
    5});
    
    只要 Zig 版本与标准库未变动,该编译直接命中缓存;
  4. 移交控制权 (process.replace): 在 src/main.zig:L5215 中:
    1if (process.can_replace and options.capture == null) {
    2    _ = try io.lockStderr(&.{}, .no_color);
    3    const err = process.replace(io, .{ .argv = child_argv.items, .environ_map = environ_map });
    4    ...
    5}
    
    在支持 execve 的操作系统(Linux / macOS)上,Zig 进程直接调用 process.replace 将当前进程镜像替换为编译好的 maker 二进制,无需保留多余的父进程。

2.2 延伸:基于 JIT 机制的热修复

jitCmd 依据源码 Manifest Hash 实施缓存管理,这也带来了一个实用的工程便利:上层构建与网络代码具备了直接热修补的能力。

例如在 Zig 0.17 中,std.http.Client 对 HTTP 代理的支持有问题,导致 zig fetch 报错。我给官方提交了修复 PR #36484,因暂未合并,便顺手做了 zig-maker 来临时热修复。

通过 zig env 找到当前标准库路径直接替换 Client.zig,下次运行 zig fetch 时,jitCmd 会感知到源码哈希变化并自动重新编译 maker 生效,无需重新编译整个 Zig 编译器本体。


3. Configurer 运行机制

当 maker 进程启动后(入口见 lib/compiler/Maker.zig:L184 的 pub fn main),若需要解析项目配置,它会按需编译并派生 configurer 进程。

3.1 编译 Configurer

在 lib/compiler/Maker.zig:L1151-L1182 中,Maker 组装编译 configurer 的参数:

 1try build_configurer_argv.appendSlice(gpa, &.{
 2    graph.zig_exe, "build-exe",
 3    "--cache-dir", graph.local_cache_root.path orelse ".",
 4    "--global-cache-dir", graph.global_cache_root.path orelse ".",
 5    "--build-root", graph.build_root_directory.path orelse ".",
 6    "--zig-lib-dir", graph.zig_lib_directory.path orelse ".",
 7    "--name", configurer_exe_name,
 8    "-fsingle-threaded",
 9    "--dep", "@build",
10    "--dep", "@dependencies",
11    try arena.print("-Mroot={f}", .{configurer_root_src_path}),
12});

这里通过模块系统将:

随后通过 std.zig.buildExeSubprocess 生成可执行文件。

3.2 配置缓存与进程派生

在 Maker 准备执行配置阶段时,并不是每次都派生子进程,而是优先检查配置缓存(Fast Path vs Slow Path):

1. 缓存命中(Fast Path)

在 lib/compiler/Maker.zig:L1452-L1467 中:

 1if (config_man) |man| {
 2    var diagnostic: Cache.Manifest.CheckDiagnostic = undefined;
 3    const status = man.check(&diagnostic, compile_prog_node) catch ...;
 4    if (status == .hit) {
 5        const digest = man.hitDigestHex();
 6        const path: Path = .{
 7            .root_dir = graph.local_cache_root,
 8            .sub_path = try arena.print("c/{s}", .{&digest}),
 9        };
10        options.src_files.* = man.takeFiles();
11        break :cp .{ path, man.toOwnedLock() };
12    }
13}

Maker 会根据 CLI 参数、用户 build.zig、build.zig.zon 以及显式声明的外部文件输入计算签名。若匹配成功,status == .hit,Maker 会直接定位到 .zig-cache/c/{digest},跳过编译和派生 configurer 进程,复用既有配置。

2. 缓存未命中(Slow Path)

只有当配置缓存未命中或被标记为失效时:

  1. Maker 编译并派生 configurer 进程,将标准输出重定向到临时文件 config_tmp_file(见 lib/compiler/Maker.zig:L1492-L1514):
    1var child = process.spawn(io, .{
    2    .argv = configure_argv,
    3    .stdout = .{ .file = config_tmp_file },
    4    .progress_node = child_node,
    5});
    
  2. 在 configurer 内部(见 lib/compiler/configurer.zig:L138-L164):
    • 调用 builder.runPackageScript(root) 执行用户的 build 函数;
    • 收集所有的 Step、Module、Artifact 及编译选项;
    • 调用 builder.serializeConfigurationExiting() 将整张构建图序列化为二进制字节流输出到标准输出,随后退出;
  3. Maker 收到完成信号后,调用反序列化接口加载内存图:
    1var configuration = Configuration.loadFile(arena, io, config_tmp_file);
    
  4. 归档落盘:
    • 若该配置未被外部环境污染(!configuration.poisoned),Maker 会原子性地将其重命名存入 .zig-cache/c/{digest}(lib/compiler/Maker.zig:L1576),作为下一次构建的高速缓存凭据;
    • 若该配置被污染(configuration.poisoned),则仅保存在 tmp/ 中,构建结束后删除,不存入缓存。

3.3 惰性依赖重试

若配置中包含尚未拉取的惰性依赖项(Lazy Dependencies): 在 lib/compiler/Maker.zig:L1530-L1542 中:

1if (configuration.unlazy_deps.len != 0) {
2    for (configuration.unlazy_deps) |hash_string| {
3        log.info("fetching lazy dependency {s}", .{hash});
4        try unlazy_set.put(arena, .fromSlice(hash), {});
5    }
6    // Trigger fetch; Maker retries the configure phase in the outer loop
7}

此时 Maker 会在后台下载所需依赖,并在拉取成功后自动重新触发一次 configurer,无需用户手动干预。


4. 配置缓存污染(Cache Poisoning)

在构建系统中,缓存构建图配置的前提是配置过程本身具备确定性。

4.1 配置期纯函数性

理想状态下,build.zig 应该是一个纯函数(Pure Function):

1Configuration = build(CLI Flags, build.zig.zon)

如果输入参数和项目依赖完全相同,那么构建图的结构也应该完全一致。一旦具备纯函数性,Maker 就可以将 Configuration 的序列化产物直接缓存起来,下次构建时无需重新执行 build.zig。

4.2 外部状态导致的缓存污染

但在实际项目中,构建脚本经常需要与外部系统环境交互。例如寻找系统上的 python 或 git:

1// Typical idiom in 0.16
2const python_path = b.findProgram(&.{"python3", "python"}, &.{});

在 0.17 源码 lib/std/Build.zig:L1621-L1626 中:

1pub fn findProgram(b: *Build, options: FindProgramOptions) ?[]const u8 {
2    const graph = b.graph;
3    // Directly scans host search prefixes and PATH
4    graph.poisonCache();
5    ...
6}

findProgram 会扫描当前机器未受版本控制的 PATH 环境变量和文件目录。这种在**配置阶段(Configure Time)**观察外部不可控环境的行为,导致构建系统无法断定“下次在同参数下执行是否会得到相同结果”。

因此,一旦调用了此类 API,当前配置就会被标记为 Poisoned(被污染)。

4.3 污染处理策略

在 lib/compiler/Maker.zig:L4015-L4024 中:

1fn removePoisonedConfiguration(io: Io, scanned_config: *const ScannedConfig) void {
2    if (scanned_config.configuration.poisoned) {
3        // Poisoned configurations are deleted immediately after use
4        scanned_config.path.deleteFile(io) catch ...;
5    }
6}

一旦配置被标记为污染,Maker 在执行完本次构建后会删除该配置缓存文件。这意味着后续即使输入未变,也需要重新派生并执行 configurer,无法利用配置缓存。

4.4 保持配置纯净的方法

Zig 0.17 提供了以下几种方案来避免缓存污染:

方案 A:推迟查找(findProgramLazy)

如果仅仅是需要一个外部工具的路径给 Run Step 使用,不要在配置期立刻解析它,使用新增的 findProgramLazy:

1// Returns LazyPath, deferring PATH lookup to make phase without poisoning configure cache
2const tool_path = b.findProgramLazy(&.{"clang-format", "clang-format-18"});
3run_cmd.addFileArg(tool_path);

其实现见 lib/std/Build.zig:L1597-L1599:

1pub fn findProgramLazy(b: *Build, options: Step.FindProgram.Options) LazyPath {
2    return .{ .generated = .{ .index = Step.FindProgram.create(b, options).found_path } };
3}

方案 B:显式声明依赖

如果配置逻辑需要根据外部文件或目录的内容来决定构建分支(例如读取 version.txt 决定版本号),可以通过 0.17 新增的四组 API 显式声明:

1// lib/std/Build.zig:L2599-L2686
2b.dependOnFileContents(b.path("version.txt"));
3b.dependOnFileMetadata(b.path("assets/logo.png"));
4b.dependOnDirectoryContents(b.path("plugins/"));
5b.dependOnDirectoryMetadata(b.path("templates/"));

通过显式声明,Maker 会将这些文件或目录的哈希纳入配置缓存凭证。只要它们的内容或元数据未发生改变,配置缓存依然有效并可直接复用。

方案 C:透传参数(addPassthruArgs)

在以往的 build.zig 中,为可执行程序传递运行时命令行参数的标准写法是在配置期读取 b.args:

1// ❌ Legacy pattern in 0.16: runtime args pollute configure phase
2if (b.args) |args| {
3    run_cmd.addArgs(args);
4}

这种做法在 0.17 中被废除。因为若允许配置期直接读取 b.args,用户每次更换运行参数(如 zig build run -- --port 8080 与 zig build run -- --port 9090),都会生成不同参数的 Run 节点,导致配置缓存失效。

在 0.17 中,换用 addPassthruArgs(见 lib/std/Build/Step/Run.zig:L593-L597):

1// ✅ 0.17 standard pattern: record a .passthru placeholder during configure phase
2run_cmd.addPassthruArgs();

在配置阶段,DAG 中仅记录了一个 .passthru 占位标识符。无论命令行如何更换透传参数,构建图本身的哈希签名保持不变,配置缓存继续命中。参数的实际拼接推迟到了 Maker 调度该 Step 的 Make 执行阶段。

命令行控制

用户可通过 --cache-poison 参数严格约束构建行为:

1zig build --cache-poison=disallowed   # Panic if poisonCache is called; ensures pure CI builds
2zig build --cache-poison=pure         # Default; safely downgrade and avoid cache reuse when poisoned
3zig build --cache-poison=ignored      # Force reuse cache and ignore side effects (use with caution)

5. 二进制缓存与 zig cache-cat

在 Zig 0.16 中,缓存记录文件存放在 .zig-cache/h/...txt 中,是普通的 ASCII 文本。每次缓存比对都需要反复解析字符串与拆解行。在 0.17 中,缓存体系被彻底重构(PR #36832):

5.1 二进制缓存格式

查看 lib/std/Build/Cache.zig:L268-L300:

5.2 查看工具 zig cache-cat

由于缓存文件变为了不可直读的二进制格式,0.17 新增了官方反序列化查看工具 zig cache-cat。

其实现位于 lib/compiler/Maker.zig:L1980-L2020 的 cacheCatOne 函数,它会将二进制 Manifest 直接格式化为可读的 ZON 数据结构:

1$ zig cache-cat .zig-cache/h/ac2f2b6c157f36769986aff06a3c3b06

输出示例:

 1.{
 2    .input_hash = "ac2f2b6c157f36769986aff06a3c3b06",
 3    .files = .{
 4        .{
 5            .size = 1420,
 6            .inode = 86241092,
 7            .mtime = 1728045612,
 8            .digest = "d5a8b3...",
 9            .prefix = .cwd,
10            .path = "src/main.zig",
11        },
12        .{
13            .size = 4096,
14            .inode = 86241105,
15            .mtime = 1728045780,
16            .digest = "e9102c...",
17            .directory = true,
18            .prefix = .cwd,
19            .path = "include",
20        },
21    },
22    .discovered_hash = "f12c8b74a...",
23}

当构建出现意外的未命中或重复编译时,可以通过该命令直观检查到底是哪个文件的 mtime、size 或哈希导致了缓存失效。


6. 包管理与 Build Server Protocol (BSP)

6.1 包管理移出编译器

在 0.17 之前,Zig 编译器本体内嵌了 HTTP 客户端、TLS 传输加密、Git 协议解析器以及 gzip/xz/zstd 等归档算法,增大了二进制体积。

0.17 对其进行了拆分:

6.2 Build Server Protocol (BSP)

以往第三方 IDE/编辑器工具(尤其是 ZLS)若想获知当前项目的真实构建选项、包含路径(Include Paths)与模块依赖,只能通过侵入式的自定义 Build Runner(如 zig build --build-runner ...)来获取。这种机制脆弱且容易因编译器升级而失效。

在 0.17 中,Zig 彻底移除了覆写 Build Runner 的能力,并正式推出了官方的 Build Server Protocol:

这意味着 IDE 与构建系统的交互从以往的黑盒猜测与 Hack,转向了官方标准化的协议通信。


7. 新旧架构对比

架构维度Zig 0.16 及之前Zig 0.17.0
进程模型单体 build_runner(包含配置估值与任务执行)双进程解耦:maker(调度与包管理)+ configurer(轻量配置提取)
生命周期机制单次构建随跑随退,无会话复用能力分层生命周期:普通构建随跑随退;会话模式(--watch / --fuzz / --listen)持续存活
运行器复用性每次修改 build.zig 必须重新编译整个 runnermaker 编译一次全局复用,仅轻量编译极小的 configurer
优化级别只能使用无优化模式编译单体 runnermaker 采用 -O ReleaseSafe 优化,加速调度与 --watch / --fuzz
配置复用不支持配置缓存,每次构建必须执行一遍 build()配置二进制缓存:未受污染的配置直接跳过 configurer 零开销复用
缓存污染防范无约束,配置期可随意做外部系统调用并导致隐蔽时序 Bug引入 Configure Cache Poisoning 机制与显式依赖声明 API
外部程序查找仅有配置期即时扫描的 b.findProgram(会污染配置)新增推迟至执行期的 b.findProgramLazy(返回 LazyPath,零污染)
CLI 参数透传配置期直接读取 b.args(破坏配置缓存一致性)配置期通过 addPassthruArgs() 占位,推迟至 Make 阶段动态绑定
增量缓存格式纯文本 ASCII Manifest(.txt)二进制紧凑格式(体积减小 25%,内存直读,提速 5%~10%)
缓存排错工具依靠手动查看文本或无工具新增官方反序列化诊断工具 zig cache-cat
包管理归属内嵌在编译器本体二进制中全面迁入构建系统源码,编译器本体大幅瘦身
包管理可修补性修复内嵌网络栈需重新编译整个编译器(含 LLVM/Clang)支持热替换:修改 lib/ 源码后 maker 自动 JIT 重编
IDE 集成方案脆弱的 Fork / 自定义 --build-runner官方标准 Build Server Protocol (--listen=-)

8. 总结

Zig 0.17 构建系统重构的核心在于职责切分与状态纯化:

  1. 进程级职责分离:将“配置声明”与“任务执行”拆分为独立进程,使负责调度的 Maker 能以 -O ReleaseSafe 高优化级别编译运行,降低任务处理与状态比对开销;
  2. 配置缓存与纯函数化:通过二进制序列化与缓存污染追踪,使构建配置在未受环境影响时能够长期复用,避免不必要的 build.zig 重复执行;
  3. 协议标准化:废弃私有 runner 并引入 Build Server Protocol,为 ZLS 等语言服务器与 IDE 提供了稳定、统一的构建信息交互通道。

评论

欢迎读者通过邮件与我交流,也可以在 Mastodon 或 Twitter 上关注我。