转载与翻译声明
本文译自 Krzysztof Kowalczyk(网名 kjk,SumatraPDF 的作者、独立承包商)的文章 Lessons learned porting 50k loc from Java to Go。
- 原文标题:Lessons learned porting 50k loc from Java to Go
- 原文作者:Krzysztof Kowalczyk(kjk,@kjk)
- 原文出处:blog.kowalczyk.info
- 发布时间:2019-04-05;最后修订:2023-02-12
- 翻译说明:原文版权归 Krzysztof Kowalczyk 所有。本文为完整中文翻译,代码示例与引用链接均保留原样,仅供学习交流;如原作者有异议将立即删除。
原文的技术细节我逐节保留了,没有删减。但凡原文写于 2019 年、如今已经过时的地方,我会用 【2026 现状】 的块引用补上更新——因为原文最大的坑(「Go 还没有泛型」)恰好在这七年间被填平了,而且填平的最后一块砖就在一个多月前。这些批注是我加的,不代表原作者观点。
目录
- 一、背景
- 二、测试与代码覆盖率
- 三、Go 的竞态检测器非常棒
- 四、为测试自建工具
- 五、移植流程
- 六、Java 移植到 Go 的具体挑战
- 七、今天用 Go 重写这个客户端,会怎么写
- 八、用 Notion 组织工作
- 九、写在 2026 年:哪些变了,哪些没变
- 十、可以带走的东西
- 附:原文与讨论
一、背景
我受雇把一个大型 Java 代码库移植到 Go。
代码是 RavenDB 的 Java 客户端——RavenDB 是一个 NoSQL 的 JSON 文档数据库。包含测试在内,代码大约 5 万行。
移植的成果是 ravendb-go-client。
这篇文章讲我在这个过程中学到的东西。
有一件事必须先说清楚,因为后面所有的技术选择都能由它推导出来:这次移植的目标,是让 Go 代码尽可能贴近 Java 代码库的结构,因为它将来必须跟着 Java 侧的变更持续同步。
这不是「重新设计一个 Go 客户端」,而是「让 Java 代码变成合法的 Go 代码」。理解了这条约束,你才不会觉得后面某些做法是在将就。
补一个原文没写在正文、藏在 Notion 一节里的数据:这件事总共花了他 601 小时,横跨 11 个月。这个量级是你判断后面每一条经验有没有参考价值的标尺。
二、测试与代码覆盖率
大型项目极其依赖自动化测试和覆盖率追踪。
他用 TravisCI 和 AppVeyor 做测试,用 Codecov.io 做覆盖率统计。
之所以两个 CI 一起用,是因为一年前 Travis 还不支持 Windows,而 AppVeyor 还不支持 Linux——两个都残缺,只能拼起来用。
原文里他有一句判断:如果今天从零搭,他会只用 AppVeyor,因为它现在能同时跑 Linux 和 Windows,而且 TravisCI 被私募基金收购、原开发团队据称被裁之后,前途不明。
至于 Codecov,他的评价是「勉强够用」:对 Go 而言,它会把非代码行(注释等等)算作「未执行」,所以按这个工具报出来的数字,你永远不可能达到 100% 覆盖率。Coveralls 似乎有同样的问题。
【2026 现状】 他预判对了方向,但押错了船。TravisCI 后来确实基本停摆,AppVeyor 也退到了边缘,两者都被 GitHub Actions 取代——今天答案几乎是唯一的:用 Actions 的 matrix 一次性覆盖三个平台。
覆盖率这边变化更大。他抱怨的「非代码行算未执行」是对工具行为的吐槽,而 Go 官方这几年补上了他当时缺的能力:
- Go 1.20(2023-02)起支持
go build -cover,可以对编译出的二进制做覆盖率采集,而不只是go test;同版本还修复了跨包覆盖率统计。- 今天是 Go 1.27.1(2026-09),
go test -cover配合-covermode已经足够好,Codecov 之类的服务从「必需品」降级成了「给 PR 贴个徽章」的可选项。
三、Go 的竞态检测器非常棒
代码里有些部分用到了并发,而并发是极容易写错的。
Go 提供了竞态检测器,编译时加 -race 即可开启。它会拖慢程序,但额外的检查能发现你是否在并发地修改同一块内存。
他的做法是永远开着 -race 跑测试,这帮他揪出了大量竞态并得以及时修复。
【2026 现状】 这一条七年没变,而且依然是 Go 相对其他语言最硬的卖点之一。新增的补充是:**Go 1.26(2026-02)**引入了实验性的 goroutine 泄漏 profile,1.27 转正——
-race管数据竞争,新工具管「goroutine 卡住不退出」,两类最常见的并发 bug 现在都有专门工具了。
四、为测试自建工具
在一个这么大的项目里,不可能靠肉眼审查来验证正确性——要同时装进脑子里的代码太多了。
而当测试失败时,光凭测试失败信息本身,往往很难判断到底是哪里错了。
这个客户端跟 RavenDB 服务端是通过 HTTP + JSON 通信的——用 JSON 编码命令和结果。于是在把 Java 测试移植到 Go 的时候,有一件事变得极其有用:抓取 Java 客户端与服务端之间的 HTTP 流量,再和 Go 版本产生的流量做对比。
他为此自建了工具:
- Java 侧:他用 Go 写了一个记录日志的 HTTP 代理,然后让 Java 客户端走这个代理,把流量抓下来。
- Go 侧:他在库里加了一个 hook 来拦截 HTTP 请求,把流量写到文件里。
然后他就能逐条比对 Java 客户端和 Go 移植版产生的流量,定位差异。
【2026 现状】 这是全文最值钱的一节,值得单独强调:这就是差分测试(differential testing),用一个已有的、被信任的实现作为黄金标准,验证新实现的行为等价。
当年他得手搓一个代理,今天这件事有大量现成手段:
- 抓包/改写用 mitmproxy 就够。
- 如果两边都跑在 K8s 上,服务网格的流量镜像(Istio、Envoy、Linkerd 都支持)可以把生产流量复制一份给新实现,做真实流量的在线对比——这是当年那个代理的生产级形态。
- 对比本身也可以自动化:给请求/响应做规范化(排序 JSON key、剔除时间戳和随机 ID)后直接 diff,不一致就 fail。
这个模式完全不限于语言移植。 任何「重写一个已有的实现」都适用:老服务重写、换 ORM、换数据库驱动、Python 脚本翻成 Rust。它验证的是行为等价,而不是你以为的行为——后者的效率差距是一个数量级。
五、移植流程
你不可能随机挑个顺序就开始搬 5 万行代码。 他明确说:如果不在每一小步之后都做测试和验证,他毫不怀疑自己会被复杂度击垮。
他当时对 RavenDB 和那套 Java 代码库都是新手。第一步是建立对 Java 代码的高层理解。
客户端和服务器通信的核心就是 HTTP 协议。所以他把流量抓下来看,然后写了最简单的 Go 代码去跟服务端对话。
当这段代码跑通时,他获得了「我有能力复刻这套功能」的信心。
第一个里程碑是:移植到足以跑通最简单的那个 Java 测试。
他用了自底向上和自顶向下相结合的方式:
- 自底向上:找出调用链最底层的、负责发送命令和解析响应的代码,先把它们搬过来。
- 自顶向下:沿着他正在移植的那个测试走一遍,倒推出实现这部分需要搬哪些代码。
成功走出第一步之后,剩下的工作就变成了一次移植一个测试,同时把让这个测试跑起来所需的代码一并搬过来。
等所有测试都移植完并通过之后,他才回头做改进,让代码更「Go 一点」。
他相信这种「一步一个脚印」的方式是完成这项工作的关键。
心理层面同样重要:面对一个长达一年的项目,你必须设有更小的中间里程碑。 每达成一个,都是继续下去的动力。
同时,让代码始终保持可编译、可运行、测试通过也是好习惯——任由 bug 堆积,等到你终于去处理时,会发现极其难修。
【2026 现状】 「先在源语言的习惯下跑通,再地道化」这个顺序,在今天更值得坚持,理由比当年更充分。
因为在 AI 辅助编码的时代,「让 Java 形状的代码变成地道的 Go」恰恰是模型最擅长、也最容易验证的那类工作——有明确的输入输出,有测试兜底。而「理解协议、设计差分测试、判断哪一层是对齐的锚点」依然得人来做。
换句话说:这个流程里最机械的部分正在被自动化,最需要判断力的部分没有。这个分工在今天比 2019 年更清晰,而不是更模糊。
六、Java 移植到 Go 的具体挑战
再次强调:移植的目标是尽量贴近 Java 代码库,因为将来要跟 Java 的变更同步。
他说自己有点惊讶于有多少代码是逐行搬过来的。而整个过程中最耗时的部分,居然是反转变量声明的顺序——从 Java 的 type name 变成 Go 的 name type。
真希望有个工具能替我做这件事。
(记住这句话,第八节会回来。)
6.1 String 与 string
Java 里 String 是对象,本质是个引用(指针)。因此字符串可以是 null。
Go 里 string 是值类型。它不可能是 nil,只能是空串。
这不是大问题,绝大多数情况他直接机械地把 null 替换成 ""。
6.2 错误与异常
Java 用异常来传递错误,Go 返回 error 接口的值。
移植本身不难,但它要求改动大量函数签名,让它们返回 error 值并沿调用栈向上传播。
【2026 现状】 机制没变,但配套体验改善了不少:
- **Go 1.13(2019)**引入
errors.Is/errors.As/%w,错误包装和判定终于有了标准做法——原文写作时这套还不存在。- 原文中他反复写
interface{},今天的惯用写法是等价的any(Go 1.18 引入的别名)。- 至于「Go 该不该加异常/Result 类型」,七年来争论不断、提案不断,结论是没有。他当年怎么改签名,你今天还是怎么改。
6.3 泛型
Go 没有泛型(当时还没有)。
移植泛型 API 是最大的挑战。
Java 里的泛型方法长这样:
public <T> T load(Class<T> clazz, String id) {
调用方:
Foo foo = load(Foo.class, "id");
他用了两种策略。
第一种是用 interface{}——它把值和类型合在一起,类似于 Java 里的 object。这是他不推荐的做法:能用,但对库的使用者来说,操作 interface{} 非常笨拙。
第二种、也是更多时候的做法,是用反射。 上面那段代码被移植成:
func Load(result interface{}, id string) error
他可以用反射查出 result 的类型,然后按这个类型从 JSON 文档里构造出值。调用方:
var result *Foo
err := Load(&result, "id")
【2026 现状】 这里是原文过时最严重、而更新得最戏剧性的一节。
Go 1.18(2022-03)引入了类型参数(泛型)。 原文在 2023-02 修订过,但这一节的正文显然没跟上,还写着 “Go doesn’t have them (yet)”。
更值得注意的是:泛型方法(generic methods)直到 Go 1.27(2026-08-19)才落地——也就是本文写作前一个多月。在此之前,方法不能声明自己的类型参数,只能写成包级函数。原文那个 Java 签名
load(Class<T>, String),从形状上讲在 Go 里直到上个月才真正可译。Go 1.27 的官方例子就很有代表性:// math/rand/v2 里原先只有包级泛型函数: func N[Int intType](r *Rand) Int // 1.27 起可以写成方法: func (r *Rand) N[Int intType]() Int但这里有个反直觉的结论:即便泛型齐全了,他当年的反射方案依然是对的,而且今天仍是 Go 生态的主流。
理由是调用方体验。用泛型写大概是:
result, err := Load[Foo]("id") // 调用方必须显式写出类型参数而反射方案是
var result *Foo; err := Load(&result, "id")——调用方不用写类型参数,类型由目标变量推出来。encoding/json、sqlx、gorm、protobuf,「传一个目标指针进去、由实现决定往里塞什么」这套 out-param 惯例,至今是绝对主流。还有一个新变化正好命中这个模式:Go 1.26(2026-02)允许
new的参数是表达式,即new(expr)。官方给的例子就是用指针表示可选字段:type Person struct { Name string `json:"name"` Age *int `json:"age"` // 不知道年龄时是 nil } func personJSON(name string, born time.Time) ([]byte, error) { return json.Marshal(Person{ Name: name, Age: new(yearsSince(born)), // 一行搞定 }) }最后别忘了那个前提约束:他的移植要保持与 Java 代码库结构对齐。泛型是 Go 的方言,用了它,Go 版本和 Java 版本之间就多了一道结构鸿沟,后续同步变更会更痛苦。所以对这一节而言,「能用上泛型了」和「该不该用泛型」是两回事。
6.4 函数重载
Go 没有函数重载,而且很可能永远不会有。
他说自己没能找到好的移植方案。
有些情况下重载只是用来提供更短的辅助方法:
void foo(int a, String b) {}
void foo(int a) { foo(a, null); }
他的处理办法是:有时候直接砍掉那个短的辅助方法。
有时候写成两个具名不同的函数:
func foo(a int) {}
func fooWithB(a int, b string) {}
当潜在参数很多时,有时候会改成结构体:
type FooArgs struct {
A int
B string
}
func foo(args *FooArgs) { }
【2026 现状】 纹丝不动。Go 团队对函数重载的拒绝态度七年来没有任何松动,也没有任何进入提案流程的迹象。这一段可以原样照抄到今天。
6.5 继承
Go 不算特别面向对象,它没有继承。
简单的继承情况可以用嵌入(embedding) 移植:
class B : A { }
有时可以写成:
type A struct{}
type B struct {
A
}
我们把 A 嵌入到 B 里,于是 B「继承」了 A 的所有方法和字段。
但这对虚函数不管用。
没有任何好办法能直接移植使用虚函数的代码。
一种模拟虚函数的办法是嵌入结构体加函数指针——这本质上是在手工重建 Java 通过 object 实现免费送给你的虚函数表。
另一种办法是写一个独立函数,用 type switch 针对具体类型分发到正确的函数上。
【2026 现状】 依旧如此。这一节是「Go 不打算变成 Java」的最典型体现,七年没有任何变化。实践中大型 Go 项目通常选择重构掉继承体系,而不是模拟它——但那恰恰与本文「保持与上游结构对齐」的目标冲突,所以在这种同步型移植里,手工虚表仍然是无奈但合理的选择。
6.6 接口
Java 和 Go 都有接口,但它们是两种完全不同的东西,差别就像苹果和萨拉米。
(这个比喻可以说是全文最传神的一句。)
有几次他确实创建了 Go 接口类型来复刻 Java 接口。但在更多的情况下,他干脆放弃接口,直接在 API 里暴露具体的 struct。
【2026 现状】 没变。补充一句背景,方便你理解他为什么说「苹果和萨拉米」:Go 的接口是结构化(隐式实现) 的——只要方法集匹配就自动满足,不需要
implements声明;Java 的接口是名义化(显式声明) 的,且承担了「定义类型层次」的职责。前者用于解耦和测试替身,后者用于建模继承关系。用途根本不同,所以「移植」这个词在这里是失效的。
6.7 包之间的循环导入
Java 允许包之间的循环导入。Go 不允许。
结果是他无法在自己的移植版里复刻 Java 的包结构。
为了简单起见,他选择了单个包。这不理想,因为这个包最终变得非常大——大到 Go 1.10 在 Windows 上处理不了单个包里这么多源文件。所幸这个问题在 Go 1.11 里被修好了。
【2026 现状】 循环导入的禁令七年来没有松动,Go 团队视其为架构上的特性而非缺陷。这一段依然成立。
顺带一提,原文这里其实藏着一个不错的论据:连「单个包放太多文件」这种纯实现层面的事,Go 1.10 → 1.11 的修复周期也只用了一年。这个语言在「该修的工具问题」上反应很快,在「不该加的语言特性」上则异常顽固——后者你通常等不到。
6.8 private、public、protected
Go 的设计者被严重低估了。他们简化概念的能力无人能及,访问控制就是一个例子。
其他语言倾向于细粒度的访问控制:用一个尽可能小的粒度(每个类的每个字段、每个方法)去指定 public / private / protected。
结果就是:一个库在实现某个功能时,访问同一个库里其他类的权限,跟外部使用这个库的代码完全一样。
Go 把它简化成了只有 public 和 private,而且把访问作用域定在包级别。
这更合理。
当我写一个库去解析 markdown 时,我不希望把实现细节暴露给库的使用者;但把这些细节藏在我自己面前,是适得其反的。
Java 程序员其实意识到了这个问题,有时候会用接口当 hack 来修正过度暴露的类:返回接口而不是具体类,就能对你这个类的直接使用者隐藏掉一部分 public API。
【2026 现状】 这一段是全文唯一一段毫无保留的赞美,而且七年后依然成立——甚至更有说服力了。因为 Go 生态里「返回结构体、导出字段直接可读可写」的惯例没有引发混乱,反而催生了更好的可组合性。
唯一值得补一句的是:
internal/目录约定给包级可见性加了一层「只允许本模块内部导入」的约束,算是这套简化模型后来的补充,但粒度依然粗放——这正是它好用而不是难用的原因。
6.9 并发
Go 的并发就是最好的,而内置的竞态检测器在抵御并发 bug 上帮助极大。
话虽如此,在第一轮移植时,他选择的是模仿 Java 的 API——例如他实现了一个 Java CompletableFuture 的仿制品。
只有在代码能工作之后,他才回去把它重构成更地道的 Go。
【2026 现状】 这条原则值得单独拎出来。它和第五节是一回事:先让它对,再让它好。 对「需要长期与上游同步」的移植项目,这个顺序是刚需——因为结构对齐了,你才能 diff 出上游改了什么。
另外,「Go 的并发最好」这个判断在 2019 年有一定争议,今天则基本成为共识。而
CompletableFuture的对应物在今天有了更明确的答案:直接用 goroutine + channel,或者errgroup(golang.org/x/sync)——后者是 Go 团队自己维护的、处理「一组并发任务中任一失败就整体取消」的标准解法,比手搓 Future 链条省事得多。
6.10 流式函数链
RavenDB 的查询能力非常复杂。Java 客户端用方法链来构建查询:
List<ReduceResult> results = session.query(User.class)
.groupBy("name")
.selectKey()
.selectCount()
.orderByDescending("count")
.ofType(ReduceResult.class)
.toList();
这种写法只在用异常传递错误的语言里才行得通。 当函数还要额外返回一个 error 时,就没法这么链下去了。
为了在 Go 里复刻链式调用,他用了**「有状态的错误」** 方案:
type Query struct {
err error
}
func (q *Query) WhereEquals(field string, val interface{}) *Query {
if q.err != nil {
return q
}
// 可能设置 q.err 的逻辑
return q
}
func (q *Query) GroupBy(field string) *Query {
if q.err != nil {
return q
}
// 可能设置 q.err 的逻辑
return q
}
func (q *Query) Execute(result interface{}) error {
if q.err != nil {
return q.err
}
// 执行逻辑
return nil
}
于是就能链起来了:
var result *Foo
err := NewQuery().WhereEquals("Name", "Frank").GroupBy("Age").Execute(&result)
【2026 现状】 这个模式今天有了正式名字:sticky error / delayed error。它在 Go 生态里被广泛使用——最著名的是
database/sql的sql.Tx和gorm的链式 API,go-git、ent也是同一个套路。Go 1.20 起标准库还把链式结构体字面量的写法简化了(
errors.Join、fmt.Errorf的多重包装),但核心思路没变:用一个字段暂存错误,让每个链式方法在入口处短路,最后在终结方法上一次性抛出。 代价是错误会被推迟暴露,所以Execute必须存在、且必须被检查——这是用这个模式时要守住的红线。
6.11 JSON 序列化
Java 没有内置的序列化机制,客户端用的是 Jackson 库。
Go 标准库有 JSON 支持,但它提供的、用于干预序列化过程的钩子没有那么多。
他没有试图去匹配 Java 的全部功能——因为 Go 内置 JSON 支持所提供的灵活性在他看来已经够用了。
【2026 现状】 这一节的变化非常大。Go 1.27(2026-08)引入了全新的
encoding/json/v2和encoding/json/jsontext包,正是为了解决他抱怨的「钩子不够」问题。v2 提供了更细粒度的编码/解码控制、更好的性能,以及对omitzero、大小写不敏感匹配等长期被诟病行为的修正。不过要注意:v2 是新包而非默认行为,现有
encoding/json依然保留(v1 与 v2 可以互操作)。所以对今天的移植者来说,这个坑从「标准库不够用,忍了吧」变成了「标准库够用了,但要决定用 v1 还是 v2」。
6.12 Go 代码更短
但这与其说是 Java 的特性,不如说是文化决定的——文化决定了什么算地道的代码。
Java 里 setter 和 getter 是家常便饭。于是这段 Java 代码:
class Foo {
private int bar;
public void setBar(int bar) {
this.bar = bar;
}
public int getBar() {
return this.bar;
}
}
到了 Go 里变成:
type Foo struct {
Bar int
}
3 行对 11 行。 当你有很多类、每个类有很多成员时,这个差距会累积起来。
其他代码的长度基本相当。
【2026 现状】 没变。这段其实是对「Go 代码比 Java 少一半」这种流行说法的冷静纠正——真正的差异集中在样板代码上,业务逻辑本身长度相近。 这个判断七年后依然准确。
七、今天用 Go 重写这个客户端,会怎么写
前面六节是原文的翻译加勘误。这一节是我加的:如果把同一个项目放到 2026 年用 Go 1.27 重做一遍,哪些地方会不一样。
注意这里有个前提要守住——原文的目标是与 Java 代码库保持结构对齐以便长期同步。所以下面这些现代写法不是「无脑上」,而是「知道有什么牌,再决定打不打」。
7.1 泛型:什么时候用,什么时候还是别用
Go 1.18(2022-03)有了泛型,Go 1.27(2026-08)有了泛型方法。所以原文那个 load(Class<T>, String) 今天可以直译了:
// 泛型函数:包级
func Load[T any](c *Client, id string) (*T, error)
// 泛型方法:Go 1.27 起,可以挂在类型上
func (c *Client) Load[T any](id string) (*T, error)
但数据库客户端这个场景,惯例依然是 out-param:
func Load(result any, id string) error
var foo *Foo
err := Load(&foo, "id")
理由不是「反射更先进」,而是调用方体验:泛型版本要求调用方显式写 Load[Foo](c, "id"),而 out-param 版本让类型从目标变量推出来。encoding/json、sqlx、gorm、protobuf 至今全是这个形状。
而且 Go 1.26(2026-02)的 new(expr) 正好是为这种模式补的便利:
type Person struct {
Name string `json:"name"`
Age *int `json:"age"` // 不知道年龄时是 nil
}
json.Marshal(Person{
Name: name,
Age: new(yearsSince(born)), // 一行搞定,不用先声明临时变量
})
结论:泛型现在是一张可打的牌,但在「反序列化到调用方指定的目标」这个具体问题上,反射 + 指针的风格仍是更符合 Go 惯例的选择——它从「被迫」变成了「刻意」。
7.2 错误处理:标准答案从 1.13 起就有了
原文写作时(2019 年初)errors.Is / errors.As / %w 都还没发布。今天错误处理有一套完整的标准做法:
var ErrNotFound = errors.New("document not found")
func (c *Client) Load(result any, id string) error {
resp, err := c.get(id)
if err != nil {
return fmt.Errorf("load %s: %w", id, err) // %w 包装,保留错误链
}
if resp.Status == 404 {
return fmt.Errorf("load %s: %w", id, ErrNotFound)
}
// ...
return nil
}
调用方:
if errors.Is(err, ErrNotFound) { /* 文档不存在 */ }
var apiErr *APIError
if errors.As(err, &apiErr) { /* 拿到具体的 API 错误 */ }
另外 errors.Join(1.20)可以一次返回多个错误——处理「批量操作里好几个都失败了」这种场景时很顺手。
至于链式 API 的「有状态的错误」(原文 6.10 节):这个模式今天不仅没被淘汰,反而成了标准做法,database/sql 的 sql.Tx、gorm、go-git 都在用。要守住的红线也一样:Execute 这类终结方法必须存在、且必须检查返回值。
7.3 并发:别手搓 Future,用 errgroup + context
原文说他第一轮实现了一个 Java CompletableFuture 的仿制品。今天对应的标准解法是 errgroup(golang.org/x/sync,Go 团队自己维护):
g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
g.Go(func() error {
return client.Load(&result, id)
})
}
if err := g.Wait(); err != nil {
return err // 任意一个失败,整个 group 的 ctx 会被取消
}
几个这几年的具体变化:
context已经是标配:取消和超时贯穿整个调用栈。原文那个年代的 Go 代码经常不带 context,今天库的公开 API 不带 context 会被认为是设计缺陷。- Go 1.22 起循环变量每次迭代新建。 这是个绕了整整十年的坑——在此之前,上面
g.Go(func() error {...})里的id必须写成id := id才能正确捕获。今天这一行可以删了。 原文没遇到这个问题(他 2019 年写的代码),但任何在 1.22 之前写并发的 Go 程序员都踩过。 sync.OnceFunc/OnceValue(1.21):惰性初始化的标准写法,不用再手写sync.Once加闭包。testing/synctest(1.25 转正):在虚拟时钟里确定性地测试并发代码,不用再靠time.Sleep碰运气。
7.4 标准库这几年补上的东西
原文大量使用的 interface{},今天写 any(1.18 起的别名,完全等价)。
其他值得知道的:
| 版本 | 新增 |
|---|---|
| 1.18 | 泛型;go test -fuzz 模糊测试进工具链 |
| 1.20 | go build -cover 二进制覆盖率;errors.Join |
| 1.21 | log/slog 结构化日志进标准库;slices / maps / cmp 泛型容器包;内置 min / max / clear;PGO |
| 1.22 | math/rand/v2;range 支持整数 |
| 1.23 | range-over-func 迭代器;unique 包 |
| 1.24 | 泛型类型别名;go.mod 的 tool 指令(不再需要 tools.go);os.Root(把文件访问锁在目录内,防路径穿越) |
| 1.25 | testing/synctest;容器感知的 GOMAXPROCS |
| 1.26 | new(expr);新的垃圾回收器;goroutine 泄漏 profile |
| 1.27 | 泛型方法;encoding/json/v2;uuid 包;crypto/mldsa |
其中两条和这篇文章的场景直接相关:
encoding/json/v2(1.27) 正是冲着原文 6.11 节那句「Go 标准库的 JSON 钩子不够多」来的。它提供了更细粒度的编解码控制和更好的性能,修正了大小写不敏感匹配之类的长期行为。注意它是新包,encoding/json 依然保留且两者可互操作——所以是「多了个选择」,不是「被迫迁移」。
容器感知的 GOMAXPROCS(1.25) 值得单独说:在此之前,你给容器限制 2 个 CPU,Go 运行时会读宿主机的核数,起 64 个 P,然后在 2 个核上疯狂上下文切换。现在 Go 会读 cgroup 的 CPU 限制来决定 GOMAXPROCS。这是「用 Go 写服务、又跑在容器里」的人白捡的性能提升,也正好是这两篇文章的交汇点。
7.5 工具链:今天的标准配置
| 工具 | 用途 |
|---|---|
gopls | 官方 LSP,编辑器补全/跳转/重命名 |
go vet | 内置静态检查 |
staticcheck / golangci-lint | 聚合 linter,CI 标配 |
govulncheck | 基于调用图判断「这个 CVE 你到底会不会被影响」,比扫依赖清单准得多 |
gofumpt / goimports | 比 gofmt 更严格的格式化 |
-race | 竞态检测,如原文所说——永远开着跑测试 |
| PGO(1.21+) | 把生产 profile 喂给编译器,典型 2–7% 提升,零代码改动 |
对移植项目特别值得提的是 go test -fuzz(1.18):模糊测试进了标准工具链。你可以直接拿 Java 端的输入样本做种子,让 Go 自己生成边界输入去找差异——这比人工设计测试用例的覆盖面大得多,和原文第四节的差分测试是绝配。
7.6 项目结构:循环导入禁区怎么绕
原文里他因为 Go 不允许包间循环导入,被迫把所有代码塞进一个包,大到 Go 1.10 在 Windows 上处理不了。
今天更常见的解法(其实当年也成立,只是很多人不知道):
- 接口定义在消费方,实现放在提供方——依赖倒置,环自然就断了。这是 Go 社区反复推荐的做法。
internal/目录表达「只允许本模块内部导入」,是包级可见性的一层补充。- 按职责/领域分包,不要按名词分包。
models/、utils/、helpers/是典型的坏味道。 - 如果真的有环,通常说明抽象层次错了,而不是语言错了。这是 Go 团队一贯的立场,也是原文 6.7 节那条限制背后的真实理由。
7.7 移植这件事本身,今天怎么做
把原文第五节的方法论和今天的工具合起来:
- 第一遍机械移植交给 AI。 声明顺序反转、getter/setter 展开、
null→""、函数签名加 error——这类有明确映射规则的工作,正是模型最擅长、最容易用测试验证的。 - 确定性的变换用 codemod 兜底。 AST 重写(
golang.org/x/tools/go/ast/astutil)或gofmt -r的改写规则,保证可复现、可审查、可回滚。不要全押在模型上——凡是能用规则确定的,就别用概率。 - 验证环节依然是人的工作,而且比重更大。 因为翻译变快了,瓶颈只会更明显地落在「怎么证明它是对的」上。原文的差分测试(抓 HTTP 流量比对)今天依然是核心手段,还可以叠加
go test -fuzz。 - CI 用 GitHub Actions 的 matrix,一次覆盖 Linux / macOS / Windows ×
-race× 覆盖率——这正是原文当年要拼两个 CI 才能做到的事。
八、用 Notion 组织工作
他是 Notion.so 的重度用户——一个层级化的笔记应用。
他是这么用 Notion 组织这次 Go 移植工作的:
(原文附图展示了他的 Notion 页面结构)
具体包括:
- 一个日历视图页面,记录他某一天做了什么、花了多少时间。因为这是一份按小时计费的合同,这些信息至关重要。正是靠这些记录,他才知道自己在 11 个月里花了 601 小时。
- 客户想知道进展。 他每个月有一个页面,像这样总结当月完成的工作——这些页面会共享给客户。
- 一份短期 todo 列表,每天早上开工时很有用。
- 甚至连发票都是 Notion 页面,用「导出为 PDF」功能生成 PDF 版发票。
【2026 现状】 「自由职业者的项目管理」这部分和语言无关,但有个细节值得留意:他这套做法的核心是按小时计费下的时间可追溯性——日历页不是自娱自乐,它是计费和进度沟通的证据链。AI 时代同理:交付周期缩短了,但「哪些工作值得按小时计费、哪些该按结果计费」反而更值得重新想清楚。
九、写在 2026 年:哪些变了,哪些没变
原文修订于 2023-02,那时 Go 是 1.20。今天是 2026-09,Go 是 1.27.1。四年半过去,逐条对照:
确认过时的(原文写完就被打脸)
| 原文说法 | 现状 |
|---|---|
| 「Go 没有泛型」 | Go 1.18(2022-03)就有了。原文 2023-02 修订时这一节漏改了 |
| 「泛型方法无从谈起」 | Go 1.27(2026-08-19)刚支持泛型方法,就在一个多月前 |
| 「重来只用 AppVeyor」 | TravisCI 和 AppVeyor 双双退场,GitHub Actions 一统 |
| 「Codecov 勉强够用」 | Go 1.20 起有 go build -cover 和跨包覆盖率,工具依赖大幅降低 |
| 「希望有工具替我反转声明顺序」 | 这个愿望实现了,见下 |
interface{} | 现在写 any |
| 标准库 JSON「钩子不够」 | Go 1.27 引入 encoding/json/v2 |
纹丝不动的(Go 团队不打算改)
- ❌ 函数重载 —— 明确拒绝
- ❌ 类继承 / 虚函数 —— 用 embedding 和 type switch 自己想办法
- ❌ 包间循环导入 —— 视为特性而非缺陷
- ❌ 异常 / Result 类型 —— 争论了七年,还是没有
- ✅ 包级访问控制 —— 依然是设计上的亮点
- ✅ 竞态检测器 —— 依然是最硬的卖点
- ✅ 反射 + out-param 的 API 风格 —— 即便有了泛型,仍是主流
关于那个「希望有工具」的愿望
他说最耗时的是把 type name 反转成 name type,并希望有工具代劳。这件事今天能做到,而且实现路径和他设想的完全不同。
当年最接近的答案是 gofmt -r 的重写规则(其实当时就有),但它处理不了这种需要类型信息的改写。今天可选的路子多了:
- AST 重写:
golang.org/x/tools/go/ast/astutil配合类型信息,写一个 codemod。对「Java 声明 → Go 声明」这种有明确映射的变换,几百行就够了。 - tree-sitter:跨语言解析后做结构映射,适合同时要读 Java、写 Go 的场景。
- LLM 驱动的批量改写:这是 2026 年最大的变量。「逐行翻译 + 保持结构对齐 + 有测试兜底」——这恰好是模型最擅长、最容易自动验证的任务。
但要说得更准确一点:这类工作不是「被解决了」,而是「被重新定价了」。 机械翻译从「耗掉 601 小时里的很大一块」变成「几乎不花时间」,可整件事的总工期不会等比例下降——因为瓶颈从来不在翻译,而在验证。他真正卡住的地方是「5 万行代码不可能靠肉眼验证正确性」,而他给出的答案——差分测试 HTTP 流量——今天依然需要人来设计和判断。
所以如果 2026 年再做一次同样的项目,最省时间的做法是:
- 用 LLM 做第一遍机械移植,把
type name这类全部交给它; - 用手写的 codemod /
gofmt -r做确定性的批量修正,保证可复现、可审查——不要全押在 LLM 上; - 把省下来的时间全部投进差分测试的建设——这是唯一能规模化验证正确性的手段;
- 最终的 Go 化重构,反而可以大胆交给模型,因为有测试兜底。
十、可以带走的东西
抛开具体的语言差异,这篇文章里有几条是跨场景通用的:
1. 用已有的实现做黄金标准,而不是靠单元测试去猜行为。 抓协议流量做比对,验证的是行为等价;逐函数写单测,验证的是你以为的行为。后者的效率差距是一个数量级。这个模式适用于一切「重写已有实现」的场景。
2. 先让它对,再让它好。
他第一轮直接仿制 CompletableFuture,跑通后才重构。对需要长期与上游同步的项目,这甚至不是效率选择,而是刚需——结构对齐了才 diff 得动。
3. 真正的瓶颈是验证,不是编写。 5 万行代码,最大的时间开销不是设计、不是算法,而是机械的声明顺序反转和难以定位的失败原因。这提示我们:这种项目里最该投资的是能做机械变换的工具,而不是把人力堆在设计上。
4. 保持代码始终可编译、可运行、测试通过。 他的话是:任由 bug 堆积,等到你终于去处理时,会发现极其难修。这条在任何规模的工程里都成立。
5. 一年期的项目必须切出中间里程碑。 第一个里程碑是「跑通最简单的那个测试」——小到不能再小,但正是它给了他继续下去的信心。这不是鸡汤,是长周期项目能不能活下来的问题。
6. 语言之间没有「更先进的」,只有「更合适的」。 他夸 Go 的开发者「简化概念的能力无人能及」,同时老老实实承认虚函数、重载、循环导入三个坑没有好方案。「苹果和萨拉米」这个比喻是全文的底色:很多概念根本无法翻译,只能重新设计。
附:原文与讨论
- 原文:Lessons learned porting 50k loc from Java to Go(Krzysztof Kowalczyk,2019-04-05 发布,2023-02-12 修订)
- 作者在 Hacker News 讨论 和 /r/golang 讨论 中补充了不少问答
- 成果代码:ravendb-go-client
- 本文引用的 Go 版本现状,依据 go.dev 官方发布记录(截至 2026-09,最新稳定版 Go 1.27.1)