把 5 万行 Java 移植到 Go:一份完整的实战复盘

转载与翻译声明

本文译自 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 还没有泛型」)恰好在这七年间被填平了,而且填平的最后一块砖就在一个多月前。这些批注是我加的,不代表原作者观点。

目录


一、背景

我受雇把一个大型 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 移植版产生的流量,定位差异。

【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.20go build -cover 二进制覆盖率;errors.Join
1.21log/slog 结构化日志进标准库;slices / maps / cmp 泛型容器包;内置 min / max / clear;PGO
1.22math/rand/v2;range 支持整数
1.23range-over-func 迭代器;unique 包
1.24泛型类型别名;go.mod 的 tool 指令(不再需要 tools.go);os.Root(把文件访问锁在目录内,防路径穿越)
1.25testing/synctest;容器感知的 GOMAXPROCS
1.26new(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 移植这件事本身,今天怎么做

把原文第五节的方法论和今天的工具合起来:

  1. 第一遍机械移植交给 AI。 声明顺序反转、getter/setter 展开、null → ""、函数签名加 error——这类有明确映射规则的工作,正是模型最擅长、最容易用测试验证的。
  2. 确定性的变换用 codemod 兜底。 AST 重写(golang.org/x/tools/go/ast/astutil)或 gofmt -r 的改写规则,保证可复现、可审查、可回滚。不要全押在模型上——凡是能用规则确定的,就别用概率。
  3. 验证环节依然是人的工作,而且比重更大。 因为翻译变快了,瓶颈只会更明显地落在「怎么证明它是对的」上。原文的差分测试(抓 HTTP 流量比对)今天依然是核心手段,还可以叠加 go test -fuzz。
  4. 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 年再做一次同样的项目,最省时间的做法是:

  1. 用 LLM 做第一遍机械移植,把 type name 这类全部交给它;
  2. 用手写的 codemod / gofmt -r 做确定性的批量修正,保证可复现、可审查——不要全押在 LLM 上;
  3. 把省下来的时间全部投进差分测试的建设——这是唯一能规模化验证正确性的手段;
  4. 最终的 Go 化重构,反而可以大胆交给模型,因为有测试兜底。

十、可以带走的东西

抛开具体的语言差异,这篇文章里有几条是跨场景通用的:

1. 用已有的实现做黄金标准,而不是靠单元测试去猜行为。 抓协议流量做比对,验证的是行为等价;逐函数写单测,验证的是你以为的行为。后者的效率差距是一个数量级。这个模式适用于一切「重写已有实现」的场景。

2. 先让它对,再让它好。 他第一轮直接仿制 CompletableFuture,跑通后才重构。对需要长期与上游同步的项目,这甚至不是效率选择,而是刚需——结构对齐了才 diff 得动。

3. 真正的瓶颈是验证,不是编写。 5 万行代码,最大的时间开销不是设计、不是算法,而是机械的声明顺序反转和难以定位的失败原因。这提示我们:这种项目里最该投资的是能做机械变换的工具,而不是把人力堆在设计上。

4. 保持代码始终可编译、可运行、测试通过。 他的话是:任由 bug 堆积,等到你终于去处理时,会发现极其难修。这条在任何规模的工程里都成立。

5. 一年期的项目必须切出中间里程碑。 第一个里程碑是「跑通最简单的那个测试」——小到不能再小,但正是它给了他继续下去的信心。这不是鸡汤,是长周期项目能不能活下来的问题。

6. 语言之间没有「更先进的」,只有「更合适的」。 他夸 Go 的开发者「简化概念的能力无人能及」,同时老老实实承认虚函数、重载、循环导入三个坑没有好方案。「苹果和萨拉米」这个比喻是全文的底色:很多概念根本无法翻译,只能重新设计。


附:原文与讨论