前一阶段,我已经完成了 A Tour of Go 简体中文 zh-CN 的全部课程翻译、公共 UI 本地化和发布前全局验收。
当时项目已经达到:
- 103/103 个正式课程页面全部
ready pending=0blocked=0- 7/7 个
.article的标题和副标题完成本地化 - 公共 UI 文案完成本地化
- 103/103 个课程页面完成 HTTP 验收
但这仍然不能算真正完成了“发布”。
因为此前能够在浏览器中看到完整中文版,主要依赖项目里的 preview 流程。它适合开发、翻译验证和页面检查,却不应该直接作为生产环境部署方式。
所以接下来真正需要解决的问题变成了:
如何把已经完成的 103 个中文页面,生成一个可以脱离翻译状态目录、独立启动,并且适合生产环境使用的正式发布包?
这也是这一阶段的主要工作。
一、从“能够预览”到“能够发布”
在此前的开发过程中,项目已经形成了一套比较完整的语言投影流程。
大致的数据来源包括:
- 冻结的 A Tour of Go upstream 内容
- 103 个正式页面的
ready candidate - 7 个
.article的标题和副标题元数据 - zh-CN 公共 UI 文案
它们最终通过现有的完整投影逻辑生成一套本地化 _content。
因此,这次并没有重新设计一套所谓的“生产内容生成器”。
相反,我选择继续复用已经经过大量验证的:
BuildLocaleProjection
也就是说,无论开发期 preview,还是正式 production publish,课程正文最终都经过同一套投影和结构校验流程。
这样可以避免出现一种很危险的情况:
开发期验证的是一种内容,而正式发布时又通过另一套独立逻辑重新生成了一份内容。
这次 production publish 做的事情,本质上是在已经成熟的语言投影外面,再增加一层正式发布能力。
二、先解决生产环境中的 Go 代码执行问题
在真正实现 publish 之前,我发现还有一个必须先解决的问题:
A Tour of Go 右侧的 Go 示例,在生产环境里到底应该怎样运行?
本地 Tour 原本使用的是 /socket。
浏览器通过 WebSocket 把 Go 代码发送给 Tour 服务,然后由 Tour 所在机器上的 Go 工具链编译并运行。
本地开发这样做没有问题。
但如果直接部署到公网,就意味着:
公网用户可以把代码提交到我的服务器,再由我的服务器本机编译、执行。
这显然不能直接作为生产架构。
因此项目最终明确排除了:
公网 Tour
→ /socket
→ 本机 go build / 执行
这条路线。
三、重新采用官方 Tour 的 HTTPTransport 架构
继续检查冻结的官方 upstream 后发现,官方 Tour 的生产方式本来就与本地模式不同。
本地环境使用:
SocketTransport
而正式环境采用:
HTTPTransport
浏览器点击“运行”后,调用链变成:
浏览器
→ HTTPTransport
→ /_/compile
→ Tour production proxy
→ 官方 Go Playground
→ 返回运行结果
最终真正执行用户 Go 代码的是远程 Go Playground,而不是 Tour 所在的生产服务器。
因此,我最终也采用了这个方案。
当前 production runtime 中:
/_/compile固定代理到官方 Go Playground/_/fmt同样使用远程 Playground- 用户传入的
backend参数不能改变目标地址 /socket不注册任何本地执行 handler- production 主机不会执行用户提交的 Go 程序
同时还进一步增加了请求体大小、响应大小、超时、重定向等边界限制。
这使得 Tour 的核心体验仍然可以保留下来:
用户仍然能够修改代码、格式化代码并直接点击“运行”。
而不需要为了上线一个中文 Tour,就自己建设一整套代码执行沙箱。
四、正式加入 production publish 命令
production runtime 完成之后,下一步才真正开始实现 publish。
现在项目已经可以运行:
go run -mod=readonly ./cmd/tour-i18n publish \
--locale zh-CN \
--output <directory>
它会执行完整流程:
检查输出目录
→ 创建 staging
→ BuildLocaleProjection
→ 检查 103/0/0
→ 构建 production binary
→ 写入 release.json
→ 生成 SHA256SUMS
→ 完整校验
→ 原子 rename 为正式输出目录
最后生成:
<output>/
├── bin/
│ └── tour
├── _content/
├── release.json
└── SHA256SUMS
这里已经不再包含:
- candidate
- status
- translation-runs
- attempt
- 其他翻译开发期状态
换句话说:
正式发布包只携带运行 Tour 真正需要的内容。
五、第一次正式生成 zh-CN Production Bundle
为了在真正部署服务器之前再做一次人工验证,我重新生成了一份专门用于本地验收的发布包。
最终得到:
production bundle: /tmp/go-tour-zh-cn-blog-20260811
locale=zh-CN ready=103 pending=0 blocked=0 pages=103 articles=7
这几个数字对这个阶段来说已经非常有代表性:
ready=103
pending=0
blocked=0
pages=103
articles=7
意味着 103 个正式页面已经全部进入最终发布包,而不是仍然混杂着待翻译或者 blocked 页面。

六、发布包不再只是一个 _content 目录
这次我没有把“正式发布”简单定义成:
把本地化后的
_content复制出来。
因为那样生成的东西仍然依赖源码仓库才能运行。
现在的 bundle 中已经包含独立的:
bin/tour
并且 locale 已经在构建时固定为:
zh-CN
所以运行时不需要再提供:
--locale
也不需要再指定:
--content
binary 会根据自己的位置找到相邻的:
../_content
这也是我比较看重的一点。
因为一个真正意义上的发布包,不应该要求部署人员知道源码仓库内部目录结构。
七、release.json:记录这个发布包到底是什么
每一个 production bundle 都会生成:
release.json
本次实际内容包括:
{
"schema_version": 1,
"locale": "zh-CN",
"upstream_commit": "e11dacba76c5aae474746e9eedee19693f492803",
"pages": 103,
"articles": 7,
"execution_transport": "http-playground-proxy",
"execution_provider": "play.golang.org",
"local_socket_enabled": false,
"goos": "linux",
"goarch": "amd64"
}
这里同时记录了:
- 发布语言
- upstream commit
- 页面数量
- article 数量
- 代码执行方式
- Playground provider
/socket是否启用- 构建目标平台
同时,我刻意没有把下面这些内容写入 manifest:
- 构建时间
- hostname
- 用户名
- staging 路径
- 输出绝对路径
- 随机 ID
因为这些信息会导致同样的源码每次 publish 都得到不同的文件。
八、185 个文件全部进行 SHA-256 校验
bundle 中还会生成:
SHA256SUMS
它覆盖:
bin/tour_content中全部正式文件release.json
本次实际发布包总计校验了:
185
个文件。
最终结果:
SHA256SUMS: all 185 files OK

/socket 明确关闭。除此之外,我还连续生成了两个位于不同目录的 bundle。
最终执行目录比较时:
diff -r -q release-a release-b
没有任何输出。
也就是说,在:
- 相同源码
- 相同 Go 工具链
- 相同 GOOS/GOARCH
条件下,两次 publish 得到的所有文件逐字节一致。
这也是为什么我把这一阶段称为:
确定性的生产发布包生成。
九、从完全无关的目录启动 production binary
自动测试全部完成后,我又做了一次人工测试。
这次特意没有站在源码仓库里运行 Tour,而是切换到了:
/tmp
然后直接使用发布包里的绝对路径启动:
/tmp/go-tour-zh-cn-blog-20260811/bin/tour \
--http 127.0.0.1:43999
启动结果:
serving locale zh-CN on http://127.0.0.1:43999 with remote Playground execution
这证明 production binary 没有依赖:
~/code/go-tour-i18n
作为当前工作目录。
随后打开浏览器,完整的中文 Tour 正常显示。

十、在最终发布包中再次人工运行 Go 示例
页面能打开还不够。
这一次我又直接在 production bundle 中点击了:
运行
右侧的官方示例:
package main
import "fmt"
func main() {
fmt.Println("Hello, 世界")
}
成功返回:
Hello, 世界
程序已退出.
也就是说,这次最终发布包并不是一个只能阅读的静态中文版。
它仍然保留了 A Tour of Go 最重要的交互能力:
阅读 → 修改 → 格式化 → 运行。
区别只在于,正式环境不再通过本地 /socket 执行程序,而是通过远程 Playground。

十一、Format 也重新人工验证
除了 Run,我还专门故意破坏了一下代码缩进,然后点击:
格式化
代码可以正常恢复格式。
因此最终人工验收中:
Run → 成功
Format → 成功
两条真实交互路径都已经通过。
十二、最后再检查一次 /socket
由于 production runtime 安全边界是这次实现里的重点,我最后又在运行中的正式 bundle 上人工请求了几个路径。
结果:
/socket → 404
/socket WebSocket Upgrade → 404
/_/share → 404
/_/anything → 404
其中第二项尤其重要。
即使显式发送 WebSocket Upgrade 请求:
Connection: Upgrade
Upgrade: websocket
/socket 仍然返回:
404
也就是说 production runtime 并不是“前端暂时没使用 /socket”,而是:
服务端本身就没有开放这条本地代码执行路径。
同时,当前 Tour UI 没有实际使用 Share,所以:
/_/share
暂时也明确返回 404。
未知的:
/_/anything
同样不会错误地落入 Tour 页面路由并返回一个假的 200 HTML。
十三、到这里,“生产发布能力”才算真正完成
这一阶段最终形成了两个比较关键的提交。
production runtime:
98877f7 feat: 增加安全的生产环境 Tour 运行服务
production publish:
e4a8fe0 feat: 增加确定性的生产发布包生成
随后又更新了 README 和项目状态:
8d2bf82 docs: 更新生产发布完成状态
所以现在项目状态已经从此前的:
production publish 尚未实现
正式变成了:
production runtime 已完成
production publish bundle 已完成
最终发布前验收已通过
实际生产环境部署尚未开始
这个区别还是很重要的。
目前我已经拥有了一份真正可以用于部署的生产发布包,但网站还没有因此自动变成“已经上线”。
十四、下一步:实际生产环境部署
接下来,项目终于可以进入下一阶段:
实际生产环境部署
接下来需要处理的已经不再是翻译算法或者 candidate 校验,而是更传统的生产部署问题,例如:
- 服务器部署目录
- production bundle 上传和版本管理
- 进程托管
- Nginx 反向代理
- HTTPS
- 域名
- CDN
- 对外访问验证
- Playground 外部网络可达性
- 正式上线后的最终 HTTP 验收
这些内容我准备单独记录。
因为从现在开始,问题的性质已经发生了变化。
前面解决的是:
如何把 A Tour of Go 可靠地翻译成一个完整的简体中文版。
这一阶段解决的是:
如何把已经完成的中文版变成一个确定、安全、可以独立运行的生产发布包。
而下一阶段要解决的,则是真正的:
如何让它正式出现在公网。
到这里,A Tour of Go 多语言翻译项目的 zh-CN 第一阶段,距离真正上线只剩最后一步了。
A Tour of Go 多语言翻译项目
本系列完整记录 A Tour of Go 多语言翻译项目从架构设计、整页翻译、结构保护、自动校验,到生产发布与后续维护的实际开发过程。
项目入口:
✅ 在线学习:A Tour of Go 简体中文版
✅ 项目源码:GitHub:shuijingwan/go-tour-i18n
当前第一阶段已完成简体中文版本。项目属于非官方社区多语言翻译项目,与 Go 官方无隶属或授权关系。


发表回复