这份对照解决什么问题
卷一的主线是 Go,但很多团队的真实系统是 Java。读架构书时最容易出问题的地方,不是概念本身,而是把一种语言的惯性直接搬到另一种语言:把 Java 的异常层级照搬成 Go 的 error 树,把 Go 的零值当成 Java 的 null,把 Java 的 sealed interface 当成 Go 的普通接口。
这份附录用同一个订单场景,把两边的关键差异放在一起。它不是语言教程,也不比较性能;它只回答“同一个架构决策,在两种语言里分别长什么样、代价在哪里”。
运行口径
下面的 Java 输出不是转述,是在本机真实运行得到的:
| 项目 | 值 |
|---|---|
| Go | go1.26.0 darwin/arm64 |
| Java | Temurin 21.0.13+9(LTS) |
| 编译器 | javac --release 21 |
| 操作系统 | macOS darwin/arm64 |
| 订单场景 | sku-book × 2,单价 4999 分 |
需要说明运行方式的偏差:书中原本计划用 eclipse-temurin:21-jdk 容器,但本机 Docker 守护进程配置的镜像代理不可达,拉取 21-jdk 超时。最终改用 Adoptium 官方发布的 Temurin 21.0.13 macOS aarch64 包,版本与容器镜像同源,语言级行为一致,差异只在运行方式。Java 代码仍然按 --release 21 编译,没有使用 22 及以上的预览特性。
复现命令如下。Java 需要先安装 JDK 21,并把 JAVA_HOME 指向它:
# Java
"$JAVA_HOME/bin/javac" --release 21 -d out $(find src -name "*.java")
"$JAVA_HOME/bin/java" -cp out app.Main
# Go
go test -v ./...
本机默认 java 是 8,所以命令里显式使用 JDK 21 的绝对路径,不依赖 PATH。这一点在团队里同样重要:CI 镜像、开发机和容器的基础 JDK 版本不一致时,--release 21 只能保证字节码目标,不能保证运行时行为。
值对象:record 与 struct
订单项在两边都是不可变值对象。Java 21 用 record,校验写在紧凑构造器里:
public record OrderItem(String sku, int quantity, long unitPriceCents) {
public OrderItem {
if (sku == null || sku.isBlank()) {
throw new DomainException("INVALID_ITEM", "sku is required");
}
if (quantity <= 0) {
throw new DomainException("INVALID_ITEM", "quantity must be positive");
}
if (unitPriceCents < 0) {
throw new DomainException("INVALID_ITEM", "unit price must not be negative");
}
}
public long subtotalCents() {
return Math.multiplyExact(unitPriceCents, quantity);
}
}
Go 没有构造器语法,校验放在工厂函数里:
type Item struct {
SKU string
Quantity int
UnitPriceCents int64
}
func NewOrder(id string, items []Item) (Order, error) {
if id == "" {
return Order{}, fmt.Errorf("invalid order: id is required")
}
if len(items) == 0 {
return Order{}, fmt.Errorf("invalid order: at least one item is required")
}
var total int64
for _, item := range items {
if item.SKU == "" || item.Quantity <= 0 || item.UnitPriceCents < 0 {
return Order{}, fmt.Errorf("invalid item: %+v", item)
}
total += item.UnitPriceCents * int64(item.Quantity)
}
return Order{ID: id, Items: items, TotalCents: total, Status: Created}, nil
}
差异不在语法,而在“非法状态能否被构造出来”:
| 维度 | Java record | Go struct |
|---|---|---|
| 不可变性 | 字段 final,天然不可变 | 字段可导出即可改,需靠约定 |
| 校验入口 | 紧凑构造器,所有构造都经过 | 工厂函数;直接字面量可绕过 |
| 相等性 | 按字段自动生成 equals | 可比较字段的结构体可 == |
| 空值 | 引用可为 null,需显式检查 | 零值可用,但可能掩盖“未设置” |
| 序列化 | 依赖框架,字段名即契约 | 依赖 tag,零值与缺省难区分 |
Go 的 Item 如果被同一个包外的代码直接用字面量构造,校验就被绕过了。实践中的补救不是“大家都别这么写”,而是把字段改为不导出、提供构造函数,或者在应用服务入口统一校验。Java 的 record 把这条约束前移到了类型系统里,这是它更稳的地方。
错误:异常与 error 值
Java 侧用一个带错误码的运行时异常:
public final class DomainException extends RuntimeException {
private final String code;
public DomainException(String code, String message) {
super(message);
this.code = code;
}
public String code() {
return code;
}
}
Go 侧用哨兵错误加 %w 包装:
var (
ErrInvalidState = errors.New("invalid state")
ErrOutOfStock = errors.New("out of stock")
ErrPayment = errors.New("payment failed")
)
func (o *Order) transition(from, to Status, action string) error {
if o.Status != from {
return fmt.Errorf("%w: %s cannot %s from %s", ErrInvalidState, o.ID, action, o.Status)
}
o.Status = to
return nil
}
调用方的差别最大:
| 维度 | Java 异常 | Go error |
|---|---|---|
| 传播 | 自动向上抛,调用方可不处理 | 显式返回,逐层决定 |
| 分支 | catch 按类型或错误码 | errors.Is / errors.As |
| 漏处理 | 编译器不强制(受检异常除外) | 不检查返回值可被 lint 发现 |
| 堆栈 | 异常自带堆栈 | 默认无堆栈,需包装或日志 |
| 契约 | 方法签名通常不声明运行时异常 | 返回值即契约的一部分 |
Java 的异常更适合“一层层往上交给统一处理器”,Go 的 error 更适合“每一层都判断自己能不能处理”。两边都可以映射到同一套 API 错误码:领域层只产生稳定错误码,HTTP 层负责翻译成状态码和响应体。不要把 Java 的异常类名或 Go 的错误文本直接暴露给调用方。
端口与密封结果
Java 21 的 sealed interface 可以把支付结果限制为三种,编译器能检查 switch 是否穷尽:
public sealed interface PaymentResult
permits Approved, Declined, Unknown {
}
public record Approved(String providerRef) implements PaymentResult {
}
public record Declined(String reason) implements PaymentResult {
}
public record Unknown(String rawStatus) implements PaymentResult {
}
使用处不需要 default:
switch (result) {
case Ports.Approved approved -> order.pay();
case Ports.Declined declined ->
throw new DomainException("PAYMENT_FAILED", declined.reason());
case Ports.Unknown unknown ->
throw new DomainException("PAYMENT_UNKNOWN", unknown.rawStatus());
}
Go 的接口是隐式实现,没有密封概念。等价的写法是把结果定义成带 Kind 的结构体,并在 switch 的 default 分支返回显式错误:
type PaymentResult struct {
Kind string
ProviderRef string
Reason string
}
func (s *Service) authorize(orderID string, cents int64) error {
result := s.payment.Authorize(orderID, cents)
switch result.Kind {
case "approved":
return nil
case "declined":
return fmt.Errorf("%w: %s", ErrPayment, result.Reason)
case "unknown":
return fmt.Errorf("payment unknown: %s", result.Kind)
default:
return fmt.Errorf("unhandled payment result: %q", result.Kind)
}
}
default 不是多余代码。Go 编译器不会告诉你“新增了一个支付状态但这里没处理”,default 是把这种遗漏变成运行时错误的最低成本方式。更好的做法是再加一个表驱动测试,把所有 Kind 枚举出来逐一断言。
状态机
Java 把迁移写成私有方法,所有公开方法只能经过它:
private void transition(OrderStatus from, OrderStatus to) {
if (status != from) {
throw new DomainException(
"INVALID_STATE",
"order " + id + " cannot transition from " + status + " to " + to);
}
status = to;
}
public void reserveInventory() {
transition(OrderStatus.CREATED, OrderStatus.INVENTORY_RESERVED);
}
public void pay() {
transition(OrderStatus.INVENTORY_RESERVED, OrderStatus.PAID);
}
Go 的写法几乎一样,只是错误通过返回值传出:
func (o *Order) transition(from, to Status, action string) error {
if o.Status != from {
return fmt.Errorf("%w: %s cannot %s from %s", ErrInvalidState, o.ID, action, o.Status)
}
o.Status = to
return nil
}
func (o *Order) ReserveInventory() error {
return o.transition(Created, InventoryReserved, "reserve inventory")
}
func (o *Order) Pay() error {
return o.transition(InventoryReserved, Paid, "pay")
}
两边都遵守同一条规则:状态字段不导出、只能通过迁移方法修改。Java 用 enum 防止拼写错误,Go 用字符串常量集合;后者需要在反序列化入口再校验一次,否则外部 JSON 可以把 Status 写成任意字符串。
补偿逻辑
支付失败要释放已预留的库存。Java 用 try/catch 包住整段:
List<OrderItem> reserved = new ArrayList<>();
try {
for (OrderItem item : order.items()) {
if (!inventory.reserve(id, item.sku(), item.quantity())) {
throw new DomainException("OUT_OF_STOCK", "sku " + item.sku());
}
reserved.add(item);
}
// ... 预留成功、支付、迁移状态
} catch (RuntimeException failure) {
for (OrderItem item : reserved) {
inventory.release(id, item.sku(), item.quantity());
}
throw failure;
}
Go 没有异常,必须在每个失败点显式调用 releaseAll:
var reserved []Item
for _, item := range order.Items {
if !s.inventory.Reserve(id, item.SKU, item.Quantity) {
s.releaseAll(id, reserved)
return Order{}, ErrOutOfStock
}
reserved = append(reserved, item)
}
if _, err := s.payment.Authorize(id, order.TotalCents); err != nil {
s.releaseAll(id, reserved)
return Order{}, err
}
Java 的 catch 更容易漏掉“哪些资源已经申请成功”的记录;Go 的显式返回更容易看出失败路径,但重复的清理代码会随失败点增加。两种写法都需要同一个前提:补偿操作本身必须幂等。否则第一次释放成功、第二次因重试再次释放,就会把库存加回去两次。
并发模型不是一回事
Go 的 goroutine 由运行时调度,创建成本低,常用 channel 传递结果。Java 21 的虚拟线程由 JVM 调度,适合把阻塞式代码改成高并发,但它仍然是线程语义:共享可变状态需要同步,ThreadLocal 的语义也和平台线程不同。
| 维度 | Go goroutine | Java 虚拟线程 |
|---|---|---|
| 调度 | Go runtime | JVM |
| 通信 | channel 优先 | 共享内存 + 同步器 |
| 取消 | context.Context | 中断与结构化并发 API |
| 阻塞 | 网络/文件 I/O 自动让出 | 阻塞调用需可被虚拟线程挂起 |
| 常见错误 | 泄漏的 goroutine | 在虚拟线程里做 synchronized 长阻塞 |
这份附录不做性能对比。用微基准比较两者没有意义,结论会随任务类型、I/O 比例和 JDK 实现变化;卷二的压测章节讲的是如何设计可复现的对比,而不是“谁更快”。
真实运行输出
Java 侧 10 条断言全部通过:
openjdk version "21.0.13" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-21.0.13+9 (build 21.0.13+9-LTS)
JAVA_VERSION=21.0.13
PASS total is 9998
PASS status starts at CREATED
PASS status becomes PAID
PASS inventory reserved 2
PASS cancel from CREATED is allowed
PASS code is INVALID_STATE
PASS code is PAYMENT_FAILED
PASS inventory released after decline
PASS release called once
PASS sealed switch covers unknown
CHECKS=10
RESULT=OK
Go 侧三个测试同样通过:
=== RUN TestHappyPath
--- PASS: TestHappyPath (0.00s)
=== RUN TestIllegalTransition
--- PASS: TestIllegalTransition (0.00s)
=== RUN TestPaymentFailureReleasesInventory
--- PASS: TestPaymentFailureReleasesInventory (0.00s)
PASS
ok archlab 0.239s
两次运行验证的是同一组行为:总额 9998、状态从 CREATED 到 PAID、取消后不能再支付、支付失败释放一次库存、未知支付结果被显式拒绝。行为一致,说明架构约束可以跨语言保留;代码形状不同,说明约束的 enforcement 方式不同。
双向迁移检查表
把 Go 代码迁到 Java,或者反过来,最容易丢的是“编译器帮你挡住的那些事”。
| 检查项 | Go → Java | Java → Go |
|---|---|---|
| 非法值对象 | 工厂校验改到紧凑构造器 | 构造器校验改到工厂函数 |
| 错误分支 | 哨兵错误改成错误码枚举 | 异常类型改成哨兵错误 + %w |
| 可空性 | 零值改成 Optional 或显式校验 | Optional 改成 (T, bool) 或指针 |
| 接口闭合 | 普通接口改成 sealed interface | sealed 改成带 Kind 的结构体 + default |
| 状态机 | 字符串状态改成 enum | enum 改成常量集合,入口校验 |
| 补偿 | 显式清理改成 try/catch | try/catch 改成每个失败点显式清理 |
| 并发 | channel 改成阻塞队列 | 虚拟线程改成 goroutine + context |
| 时间 | time.Time 改成 Instant | Instant 改成 time.Time,注意时区 |
| 金额 | int64 分改成 long 分 | BigDecimal 改成整数分,禁止浮点 |
| 序列化 | tag 改成 record 组件名 | 注解改成 tag,补零值语义 |
迁移时不要逐行翻译。先列出领域不变式,再分别用目标语言最自然的方式表达;最后用同一组场景测试验证行为一致。逐行翻译通常会保留源语言的坏习惯,比如在 Go 里模拟异常层级,或者在 Java 里手写大量 if err != null。
常见误译
第一,把 Go 的 error 当成 Java 的受检异常。Go 的 error 是值,可以忽略、可以包装、可以比较;Java 的受检异常是方法签名的一部分。两者对“调用方必须处理”的强制力不同。
第二,把 Java 的 null 当成 Go 的零值。Go 的零值对 int 是 0、对 string 是空串、对指针是 nil;这些值在业务上可能都合法,无法表达“未设置”。需要区分时用指针或 (T, bool)。
第三,把 record 当成“自动生成 getter 的类”。record 的语义是值:相等性按字段、不可变、组件即构造参数。它不是 JavaBean,也不适合承载可变状态。
第四,把 sealed interface 当成“包内可见接口”。sealed 限制的是“谁可以实现”,不是“谁可以调用”;它解决的是穷尽性检查,不是访问控制。
第五,认为虚拟线程等于 goroutine。虚拟线程仍是线程,共享可变状态仍需同步;goroutine 的 channel 语义、调度行为和取消模型都不同。
第六,把 Java 的 Optional 用在字段上。Optional 设计给返回值,不是字段或参数;字段用可空引用并在构造时校验,或者用明确的“未设置”值对象。
小结
同一套订单约束,在 Go 和 Java 里都能表达,但 enforcement 的位置不同:Java 更多依赖类型系统和构造器,Go 更多依赖工厂函数、显式错误和测试。跨语言迁移时,先迁移不变式,再迁移代码形状;不要为了“看起来一样”而模拟对方的语言特性。
这份附录的输出来自真实的 Temurin 21.0.13 和 Go 1.26.0 运行,不是示意。运行方式的偏差已经写明:容器镜像不可达时使用同版本官方 JDK 包,语言级结论不受影响。下一份附录把评审时要问的问题整理成清单。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。