本章给只会基本编程的读者补齐运行这个仓库所需的工程背景。重点不是记住命令,而是知道:命令在哪个目录执行、输入从哪里来、结果写到哪里、失败由谁报告,以及一次测试究竟检查了什么。
本项目把运行在 Wasm(WebAssembly,一种可移植的机器可读模块格式)里的 Python 解释器称作 Guest,把负责加载它、注册工具并收取结果的 Go 程序称作 Host。这两个名字像“被接待的客人”和“提供服务的主机”,但它们不是两台必然分开的电脑:当前实现可以在同一个操作系统进程(正在运行的程序及其资源)里运行。
本章区分只读观察、执行程序、写文件和联网操作。预期结果帮助检查理解;判断成功仍要看实际输出、退出码和断言,而不是只看命令写得像对的。
1. 终端、目录、路径和进程
终端是输入命令、查看输出的界面,里面通常运行着 shell,例如 zsh 或 bash。shell 读取命令、解释参数并执行操作。当前工作目录属于 shell/进程,平时说“终端的当前目录”只是简说:命令中没有写完整位置的文件名,通常就从这里找。pwd 显示当前目录,cd path/to/pysolate-spine 把当前目录切换到仓库根目录。相对路径 ./cmd/demo 从当前目录开始;绝对路径从文件系统根开始。路径不是文件内容,只是定位文件的方法;在不同机器上应使用自己的仓库路径,不要复制别人的本机路径。
命令可以是要启动的程序名,也可以是 shell 自己的内建操作;例如 cd 改变当前 shell 的目录,不是另起程序搬动文件。命令后面的参数是给它的输入或选项。例如:
# 只读:查看当前位置和 Git 工作区状态
pwd
git status --short
# 执行本地 Go 程序;-source 和 -inputs 是传给它的参数
go run ./cmd/demo -source examples/order.py \
-inputs '{"item":"book","quantity":2,"delivery":false}'
行尾反斜杠把一条命令续到下一行,后面不要再加空格。JSON 外的单引号让 shell 把里面文字作为一个参数,单引号本身不进入 JSON。. 是当前目录,.. 是上一级,~ 通常表示用户主目录。go run 会编译并运行 Go 程序;它不是把 examples/order.py 编译进 Wasm,而是把这个文件作为本轮 Guest 输入。按当前例子,预期结果是 JSON {"item":"book","total":47}。这是当前固定查价例子的功能结果,不是速度或安全性的证明。
正在运行的命令叫一个进程。进程通常继承三个标准流:stdin 是输入(编号 0),可来自键盘、文件或另一程序;stdout (编号 1)是正常输出,例如结果和 print;stderr(编号 2)是诊断、警告和错误,例如 CLI 失败消息。底层流能传字节,不只限文字;这里主要按文本显示。stdout/stderr 经常都显示在同一终端,但仍是两条不同的流,可以分别保存:
go run ./cmd/demo -source examples/order.py > result.txt 2> diagnose.txt
> 把 stdout 写入文件,2> 把 stderr 写入文件;shell 在启动程序前准备重定向,目标文件可能被创建或覆盖,即使随后程序失败也可能留下改动,因此不是纯观察。2>&1 才表示把 stderr 合并到 stdout。runner_test.go 也专门检查 print('hello') 出现在 Output.Stdout,而 result = 42 是另一份 JSON 结果。
退出码是进程结束时交给父进程的一个小整数。通常 0 表示成功,非零表示失败;它只表示“这次命令是否按自己的约定完成”,不等于结果内容正确。于是 go test ... && go vet ./... 只会在前一步退出码为 0 时继续。测试失败、缺少 dist/spine.wasm 或构建错误通常会给出非零退出码。
环境变量是进程启动时继承的一组“外部设置”,例如 PORT=4328 告诉本地预览服务器监听哪个端口。PORT=4328 make read 只为这次命令及其子进程设置它,不会写进 Go 源码。PATH 是一个常见环境变量,列出 shell 查找可执行程序的目录。这里的 PORT 是服务入口编号,Web 章会解释端口。环境变量可能包含路径、开关或凭据;凭据只能由用户在本机安全地提供,不能贴到命令记录、文档或聊天中。
2. 源代码、构建产物与 Git
源代码是人可以修改、编译器或解释器可以处理的材料;本项目的 Go、Python、C 和 shell(命令解释程序)文件属于源代码。**构建产物(artifact)**是构建过程生成、供运行时直接使用的文件。dist/spine.wasm 是打包了 CPython(Python 的解释器实现)、C bridge(C 写的连接层)和 Guest Python 文件的二进制 Wasm,不是 guest/bootstrap.py 的自动镜像。运行时读取的是现有 artifact,因此只改 guest/bootstrap.py 而不重建,可能仍在运行旧内容。
对照当前边界:换 -source examples/order.py 只是换本轮输入,不需要重建 Wasm;改 Go Host 后重新 go run 或 go test 即可;改 guest/bootstrap.py、guest/runtime.c 或链接配置,则要在具备工具链的 Linux x86_64(常见的 64 位 CPU 架构)环境重建,成功后再用真实 Guest 测试。SPINE_BUILD_INPUTS=/path/to/extracted-build-inputs bash build-guest.sh 是预期用法,不是本次实测命令;占位路径必须替换,脚本不会凭空下载并编译完整 CPython。构建过程可能联网或使用预先准备的输入,不能当作日常离线命令。
Git 仓库保存源代码的版本历史和协作元数据。一次 commit(提交) 是某一时刻文件树的记录;branch(分支) 是指向某个提交的可移动名称,可以沿父提交关系追溯历史;worktree(工作树) 是当前签出的文件目录,Git 也允许为另一个分支建立第二个 worktree。它们不是同一个概念:go.mod 的 module 名也不是 GitHub 仓库名,Git 仓库有没有 remote(Git 记录的远程仓库地址) 也不由 module 名决定。
可以用以下只读命令观察它们:
git status --short # 当前工作树有哪些未提交变化
git diff -- README.md # 查看已跟踪文件的未提交差异
git branch --show-current
git log -1 --oneline # 最近一个 commit 的摘要
git worktree list # 已登记的工作树
编辑文件后,工作树会变化;常见流程是 git add 把选定内容放入暂存区,再由 git commit 记录提交。暂存区保存“准备放进下一次记录的内容”,并不发送到远端;git push 才把已有提交发送到远程仓库。.gitignore 只是告诉 Git 默认不关注哪些未跟踪路径,例如根目录的 dist/、build/ 和本地临时状态;它不是访问控制,也不会把已经提交的文件从历史中抹掉。源码仓库、构建产物和未来的发布站点应该分别检查边界。
3. Go module、依赖版本与校验
依赖是程序借用的外部代码;本项目直接使用 wazero(用来加载和运行 Wasm 的 Go 库),自己不实现整个 Wasm runtime(负责执行模块的运行时)。Go module 是一个 Go 工程的模块边界,go.mod 是描述它及其依赖的文件:module pysolate-spine 是本地模块路径,go 1.25.0 是语言/工具链基线,github.com/tetratelabs/wazero v1.11.0 是直接依赖,golang.org/x/sys v0.38.0 提供 Linux COW 使用的系统接口,也是直接依赖。版本像“使用哪一版教材”:同名库的不同版本可能有不同 API(供其他代码调用的接口)或行为。
**校验和(checksum)**是根据内容计算的摘要,像文件的指纹。哈希函数把输入按确定规则算成固定长度表示;相同输入应得到相同摘要,不同输入仍可能碰撞,因此不能叫数学上绝对唯一。校验能发现与已知摘要不一致,却不等于加密,也不能单独证明来源可信:恶意文件一样可以附上自己的正确哈希。
go.sum 记录模块压缩包和 go.mod 的校验值,用来发现下载内容与已记录内容不一致。它不等同于锁文件:Go 会依据 go.mod 和自己的版本选择规则解析依赖;go.sum 主要保证内容可核验,而不是单独写出所有最终选择。
站点使用 Node.js(在浏览器外运行 JavaScript 的环境)及其包管理器 pnpm,所以有另一套依赖。site/pnpm-lock.yaml 记录解析后的具体版本;这里的“锁文件”不是并发章节中防止同时访问资源的互斥锁。pnpm install --frozen-lockfile 才是按站点锁文件安装;两套依赖管理不能混为一谈。
第一次运行 Go 或 pnpm 命令,如果缓存中没有依赖,工具可能联网下载并修改本机缓存;“仓库代码没改”不代表命令没有外部作用。已经准备好依赖时,命令可能完全使用本地缓存。要做离线检查,必须显式关闭 Go 和 npm 的下载路径,并提前准备缓存,不能靠猜测网络现在是否可用。
4. JSON、Unicode 与跨边界数据
JSON是用文字表示对象、数组、字符串、数字、布尔值和 null 的数据格式。例如 {"text":"你好","answer":42,"missing":null} 可以作为 Go、Python 之间共同理解的消息。JSON 对象是字符串键到值的集合,数组有顺序;字符串用双引号,true/false/null 用小写,所以 Python 的 True/None 不是可以直接复制过去的 JSON 文本。把内存中的对象转换成 JSON 字节叫序列化;把字节解析回对象叫反序列化。序列化不是“把任何对象原样保存”:Python 的 set、函数、文件句柄等没有默认的 JSON 表示,本项目的非 JSON result 测试预期失败。
一个 bit 只有 0/1 两种值,8 个 bit 是一个 byte;ASCII 是较早的一组字符编码,主要覆盖英文字母、数字和常见符号,不能表示所有汉字。Unicode给世界各地的文字和符号分配统一编号;UTF-8是把这些编号编码成字节的常用方式。JSON 文本通常以 UTF-8 传输,所以“你好”不是两个 ASCII 字节,而是多字节序列。程序不能只按字节数猜字符数,跨语言协议还要明确编码、长度和转义规则。
当前项目让 Guest 和 Host 通过 JSON bytes 交换请求与结果。Go 的 json.RawMessage 可以暂时保留一段 JSON 原文,避免库层立即替业务决定结构;它仍然需要合法 JSON,也不自动把这些字节变成不可变数据,或让每次赋值都深复制它们。null 也要看位置:工具成功返回 Go 的 nil 时可以交付 JSON null;响应中是否存在 error 字段则决定它是错误,不能只看错误消息是不是空字符串。
不要把 JSON 的 number 说成所有语言都能无损保存的整数。JSON 只有一种数字语法,具体语言可能用浮点数、定宽整数或任意精度整数解析;浮点数用有限位数表示有效数字及其缩放,能覆盖较大范围,却不能精确表达每个整数或小数;float64 的 64 也不是“64 位十进制有效数字”。例如 Go 默认将 JSON 数字解码进 any 时常得到 float64,超过其精确表示范围的大整数在往返中可能改变。这里的 42 没有这个问题,但跨 Go/Python/JavaScript 传金额、ID 或大整数时,应规定范围、字符串表示或专用编码,再写测试。
5. 测试是在什么范围内建立信心
**断言(assertion)**是把预期写成可检查的条件,例如“工具调用次数必须是 1”或“错误必须包含 RuntimeError”。条件不满足,测试进程报告失败。单元测试尽量只检查一个函数或小模块;当前 PLM(whole-program 的提前读取优化)的 AST(抽象语法树:把源码拆成语句和表达式节点)转换和 Python 小逻辑有这类测试。集成测试把多个模块接起来检查协议,例如 Go bridge、Guest 和工具注册表的交互。真实路径测试则使用项目实际构建的 dist/spine.wasm 走完整执行链;TestRealGuest 缺少 artifact 时直接 Fatal,不换成本机 Python。
单元/集成描述测试连接了多少部分,“真实路径”描述是否使用真实 artifact,分类不互斥。测试替身(stub/mock)是受控的替代角色,例如固定返回一个值的工具;它适合检查局部逻辑,但不能冒充真实 Wasm 证据。
一个可复制的检查序列如下:
# 只读观察,不运行程序
git status --short
# 执行真实 Guest 的一个子测试;需要已准备好的 dist/spine.wasm
go test -run '^TestRealGuest/python$' -count=1
# 仓库约定的综合检查:Go、Guest Python 单测和 go vet
make check
这些命令分别用于观察工作树、检查单个真实用例和执行综合检查;判断结果要读取实际命令输出,不能只因文件里列了命令就算验证完成。若 artifact 缺失,第二条应失败而不是 SKIP。回归测试是为曾经出现或明确要保持的行为留下的自动检查,防止后续改动把它改坏;它仍然只覆盖写入的场景。测试不是数学证明,也不是完整安全审计:一组用例通过,只能说明这些用例在当时的环境满足断言,不能推出任意 Python、任意输入都正确。wasm-tools validate 只能检查 Wasm 结构有效,也不能证明 Host 工具语义正确。
6. 离线前提与子进程沙箱
make offline 不是“自动准备一台没有网络的机器”。它先要求 Go、Python、Node、pnpm、sandbox-exec(macOS 用来限制子进程能力的命令)、真实 dist/spine.wasm 和站点依赖都已存在,再设置 GOPROXY=off、GOSUMDB=off、GOTOOLCHAIN=local、npm_config_offline=true 等变量,最后在 macOS 的 sandbox-exec 中运行测试、站点构建和演示。缺资源时会在预检或后续实际命令中失败,不会靠临时下载假装离线准备完成;这是前提不满足,不是测试通过。
这里的沙箱策略只施加给它启动的检查子进程及其子进程:脚本在子进程中尝试连接网络应得到权限错误。它不改变用户其他程序的网络权限,也不代表整个 macOS 被隔离。不要把这条检查扩展成“所有网络都被系统关闭”。
make read 的作用不同:它要求已有 site/dist,然后用 Python 标准库 HTTP 服务器绑定 127.0.0.1,默认端口来自 PORT 或 4328。这是本机静态预览,不安装 npm 包、不上传站点;但会启动一个直到 Ctrl-C 才结束的本地进程。工程判断的顺序应是:先确认当前目录和缓存前提,再看 stdout/stderr,最后依据退出码和具体断言判断结果。
7. 从失败信息定位问题
同一个“命令失败”可能发生在不同层,修复方式也不同。若终端直接说 command not found,是 shell(读取并执行命令的程序,如 zsh 或 bash)找不到 go、python3 或其他可执行文件,先检查工具链(编译、链接、运行所需的一组工具)和 PATH,不要先修改业务代码。若程序已经启动,却说 open dist/spine.wasm: no such file,说明当前目录或构建产物前提不对;这不是 Python 语法错误。若报告 missing Guest export,则是 Wasm 和这套 ABI(两边传参数的二进制约定)不匹配。若出现 unknown tool,先检查 Host 的工具注册表和 Python 中的名称;不要因为调用跨越 C 边界就立刻怀疑 C。
可以按三个问题缩小范围:命令是否启动,输入是否被正确读取,失败是否由程序用 stderr 和非零退出码交付。比如 go test 的测试断言失败、go vet 找到问题、构建脚本在缺变量时退出,都是“程序运行了但约定没有满足”;shell 本身找不到命令则还没有进入这些检查。保留完整 stderr 和执行命令,比只截图最后一行更容易复现。
可复现不是“每次都神奇地一样”,而是把影响结果的条件说清楚:仓库 commit、当前目录、实际源文件、Wasm artifact、依赖版本、环境变量、输入 JSON 和命令参数。这个项目的真实 Guest 测试特别依赖 artifact;同一份 Go 源码配上不同 Wasm,不能假定就是同一实现。若修改了 Guest 源码,应把“重建 artifact”列为验证步骤,而不是只运行仍可能过期的旧文件。
相关入口:README、Makefile、go.mod、go.sum、runner_test.go。