《后端架构设计·架构基础与边界》附录 B Go/Java 对照速查

以 arch-lab 订单系统为例,把同一套值对象、端口、密封结果、状态机与补偿逻辑分别用 Go 1.26 和 Java 21 实现并真实运行,对照错误处理、可空性、接口闭合性以及 goroutine 与虚拟线程的并发模型差异,并给出双向迁移检查表与常见误译。

这份对照解决什么问题

卷一的主线是 Go,但很多团队的真实系统是 Java。读架构书时最容易出问题的地方,不是概念本身,而是把一种语言的惯性直接搬到另一种语言:把 Java 的异常层级照搬成 Go 的 error 树,把 Go 的零值当成 Java 的 null,把 Java 的 sealed interface 当成 Go 的普通接口。

这份附录用同一个订单场景,把两边的关键差异放在一起。它不是语言教程,也不比较性能;它只回答“同一个架构决策,在两种语言里分别长什么样、代价在哪里”。

运行口径

下面的 Java 输出不是转述,是在本机真实运行得到的:

项目值
Gogo1.26.0 darwin/arm64
JavaTemurin 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 recordGo 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 goroutineJava 虚拟线程
调度Go runtimeJVM
通信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 → JavaJava → Go
非法值对象工厂校验改到紧凑构造器构造器校验改到工厂函数
错误分支哨兵错误改成错误码枚举异常类型改成哨兵错误 + %w
可空性零值改成 Optional 或显式校验Optional 改成 (T, bool) 或指针
接口闭合普通接口改成 sealed interfacesealed 改成带 Kind 的结构体 + default
状态机字符串状态改成 enumenum 改成常量集合,入口校验
补偿显式清理改成 try/catchtry/catch 改成每个失败点显式清理
并发channel 改成阻塞队列虚拟线程改成 goroutine + context
时间time.Time 改成 InstantInstant 改成 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 包,语言级结论不受影响。下一份附录把评审时要问的问题整理成清单。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查