跳到主要内容
约 9 分钟3 张卡

Java API 设计

Java API 通过类型和方法,规定对象有哪些合法状态,以及调用方可以执行哪些操作。本文从业务不变量入手,依次讲解参数类型、状态转换和失败表达。最后说明并发约定与兼容性,语言和 API 示例以 Java 17、21 为准。

1. 面试核心​

先记住这几条,再读正文
  • 合法初始状态。 构造器或静态工厂校验不变量,让对象从创建起就合法。卡 · 业务不变量
  • 领域类型。 Money 等领域类型把同一业务概念的解析、校验和相等性判断集中在类型内部。卡 · 领域类型
  • 受控状态转换。 pay、cancel 等业务方法统一检查前置状态并更新字段,让每次状态转换都遵守业务规则。
  • 参数组织。 参数对象按业务含义组织参数,Builder 则在 build 时统一校验必需参数和参数之间的约束。卡 · 领域类型
  • 缺失与默认值。 Optional.empty() 表示正常缺失,orElseGet 只在结果缺失时计算默认值。卡 · Optional 的默认值求值

2. 业务不变量​

业务不变量是对象在创建成功后,以及每次操作结束时都应满足的约束。设计订单 API 时,可以先列出金额与状态约束:

  • 币种必须存在。
  • 金额大于等于零。
  • 已支付订单保持原金额。
  • 支付入口只接受待支付状态,所以已取消的订单也会被拒绝。

如果允许先创建空对象,再任意调用 setter,调用方就要自己保证字段组合合法。下面的代码会让订单持有空币种和负数金额:

Order order = new Order();
order.setCurrency(null);
order.setAmount(new BigDecimal("-1"));

更稳妥的入口在对象建立时验证:

public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");

if (amount.signum() < 0) {
throw new IllegalArgumentException("amount must be >= 0");
}
}
}

构造成功后,Money 一定有金额和币种,且金额大于等于零。这样,后续方法就可以依赖这些已经验证的约束,省去重复检查。订单是否允许支付,则由订单的状态转换方法负责。

3. 领域类型​

领域类型是用来表示具体业务概念的类型,例如账户标识与金额。先看只使用字符串和数值的转账签名:

void transfer(String from, String to, BigDecimal amount, String currency);

这个签名允许传入非法账户字符串,也容易把同类型参数的位置传反。BigDecimal 只能表示数值,金额使用什么币种和舍入规则,还需要另行约定。把这些约束放进领域类型,就能集中解析和校验:

TransferResult transfer(
AccountId from,
AccountId to,
Money amount,
IdempotencyKey idempotencyKey
);

AccountId 可以负责账户标识的格式,Money 负责金额与币种,IdempotencyKey 标识一次可重复提交的请求。同一业务值的解析、校验和相等性规则因此有了明确归属。金额的舍入规则也应在金额类型或金额运算入口明确。

跨对象的约束仍要在操作入口检查。例如,from 与 to 都是 AccountId,调用方仍需核对转出与转入方向。遇到多个同类型字符串、布尔标志或可空参数时,可以检查它们是否缺少清楚的业务名称。

3.1 布尔参数​

布尔参数只有真和假,调用处却需要知道它们分别控制什么:

send(message, true, false);

调用处无法看出两个值含义。可以拆成命名方法、枚举或配置值:

send(message, DeliveryMode.RELIABLE, AuditMode.ENABLED);

4. 状态转换方法​

把状态字段暴露给 setter,会允许绕过业务规则:

order.setStatus(OrderStatus.PAID);

用行为方法表达转换:

public PaymentAccepted pay(PaymentId paymentId, Instant paidAt) {
if (status != OrderStatus.PENDING_PAYMENT) {
throw new InvalidOrderState(status, "pay");
}

status = OrderStatus.PAID;
return new PaymentAccepted(id, paymentId, paidAt);
}

pay 先检查订单是否待支付,通过后才更新状态并返回支付结果。这样,规则就集中在订单对象内部,Controller、Service 和消息消费者都通过同一入口支付。

多个线程共享订单对象时,还要让检查与更新作为整体受到同步保护。否则,两个线程仍会同时通过状态检查,再分别执行支付。

5. 参数与返回值的所有权​

所有权约定说明谁负责保管对象,以及谁可以修改它。公共 API 应说明是否保留输入引用、是否复制,以及调用过程中是否修改输入。输入由调用方持有、方法保持其内容不变,可以减少调用后的意外影响。确实要修改参数时,应通过方法名和文档明确说明。

比如,规则列表经 List.copyOf 保存后,调用方修改原列表也不会改变内部规则顺序。返回列表的结构同样保持稳定,但其中可变元素还需要单独处理。

列表与元素的复制示例见 不可变对象。数组 getter 的处理见该文的输出边界。

6. 缺失与失败​

找不到查询结果与命令执行失败,需要调用方采取不同动作。API 应区分这两种情况,并让同一种失败保持一致的表达。

6.1 查询无结果​

查询允许找不到用户时,可以通过返回类型直接表达这种正常缺失:

Optional<User> findById(UserId id);

Optional 是装有一个结果或为空的容器,适合表达方法返回的可选结果。找不到用户时,返回 Optional.empty(),由调用方决定补默认值还是结束操作。返回值本身始终是 Optional 实例,这样调用方就只需要处理容器是否为空。

6.2 命令失败​

参数本身非法时,可以抛出 IllegalArgumentException。对象状态拒绝操作时,则用业务异常或结果类型说明原因,例如订单已取消。跨服务接口应将这些失败映射为稳定错误码,让客户端依据错误码处理,内部类名和堆栈只用于诊断。

6.3 失败表达的一致性​

同一种失败保持一致的表达方式,调用方才能知道应检查返回值还是捕获异常。异常消息用于诊断,程序分支依据异常类型或错误码判断。这样,即使诊断文字调整,调用方的处理逻辑仍能保持稳定。

7. 接口范围​

接口应围绕调用方需要的业务能力设计。比如,货币换算只需要查询汇率,就可以依赖下面的接口:

public interface ExchangeRateProvider {
ExchangeRate rate(Currency from, Currency to, Instant at);
}

汇率可以来自数据库、远程服务或内存,调用方只依赖查询能力。这样既便于切换实现,也能在测试中提供固定汇率。

一次业务动作应有完整的调用入口。如果正确使用必须依次调用 begin、setX、setY、validate、commit,调用方就要管理多个中间状态。遗漏或调换其中一步,都可能让对象停留在非法状态。

必须分步收集参数时,可以用 Builder 暂存输入,再在 build 时统一校验。若阶段顺序本身有业务含义,也可以让不同阶段使用不同类型,只暴露该阶段允许的操作。

8. 并发、幂等与阻塞​

方法签名之外,还需要说明调用会怎样等待、重试或共享对象。公开方法的契约应回答以下五个问题:

  • 实例能否跨线程共享。
  • 操作是否会阻塞或发起 I/O。
  • 超时和中断怎样传播。
  • 重复调用是否安全。
  • 回调会在哪个线程执行。
PaymentResult charge(
IdempotencyKey key,
Money amount,
Duration timeout
);

幂等表示同一请求重复执行时,业务效果与执行一次一致。签名中的幂等键用来识别重复请求,超时参数表达调用方最多愿意等待多久。

实现还要按幂等键保存结果,并把等待上限传递给下游。否则,即使签名有这些参数,调用方仍会遇到重复扣款或无限等待。明确这些约定后,调用方才能决定超时后是否可以重试。

9. 兼容性​

接口调整后,旧客户端既要能继续调用,也要得到符合原约定的结果。兼容性需要从三个层面检查:

  • 源码兼容:旧客户端源码能否重新编译。新增重载会参与匹配,因此重新编译时选中的方法可能改变。
  • 二进制兼容:已经编译的客户端能否继续链接运行。删除它调用的公开方法会破坏链接,保留旧方法的新增重载则保留了原入口。
  • 行为兼容:旧客户端观察到的结果、异常和副作用是否仍符合约定。

即使方法签名相同,下面这些行为变化也会影响调用方:

  • 原来允许 null,现在拒绝。
  • 遍历顺序改变。
  • 异常类型或错误码改变。
  • 同步方法改为异步。
  • 返回可变集合改为不可修改集合。
  • equals、hashCode 或序列化形态改变。

发布前应记录调用方依赖的可观察行为,再用旧客户端与契约测试验证。契约测试检查双方约定的输入输出和失败方式。需要迁移时,迁移阶段也要验证新旧调用方的行为,单纯编译通过只覆盖源码这一层。

10. Optional 的默认值求值​

查询结果缺失时,调用方经常需要提供默认值。这里要区分直接传入默认结果与传入计算默认结果的函数。

orElse(defaultValue()) 会先执行 defaultValue(),再调用 orElse。因为 Java 在调用方法前先求值实参,所以即使 Optional 有值,这次计算也已经发生。按需加载默认用户时,可以这样写:

User user = found.orElseGet(() -> loadDefaultUser());

orElseGet 接收一个 Supplier,也就是调用时才产生结果的函数。只有 Optional 为空时,才会调用它来加载默认用户。默认值需要访问数据库或产生其他副作用时,这种按需求值可以避免多余操作。需要把缺失转为异常时,可使用 orElseThrow。

11. 常见问题​

11.1 应该使用构造器还是静态工厂?​

构造器适合含义单一的创建。静态工厂可命名创建方式、复用实例或返回子类型。两者都应在返回前完成必需校验。

11.2 参数多时都应该使用 Builder 吗?​

11.3 公共参数都要检查 null 吗?​

11.4 为了测试私有方法,应把它改成公开方法吗?​

12 · 面试题卡片​

本页题目与参考回答

12.1 设计一个类时,怎样保证对象不会进入非法状态​

出现公司:蚂蚁集团、众安保险

考察重点

  • 说明先写不变量,再设计构造和状态转换。
  • 说明类型、封装与防御性复制怎样减少非法组合。
  • 说明失败和并发语义怎样成为 API 契约。

相关内容:业务不变量至并发、幂等与阻塞。

参考回答

构造器或静态工厂应保证初始状态合法,状态转换方法则保证每次操作后仍满足不变量。

设计时先列出这些约束,再用领域类型集中参数解析与校验。pay、cancel 等方法统一检查前置状态并更新字段,避免任意 setter 绕过规则。

可变输入和输出要明确所有权,并通过防御性复制隔离外部修改。API 还应约定以下调用行为:

  • 正常缺失与执行失败分别怎样表达。
  • 对象能否跨线程共享,方法是否阻塞。
  • 超时后怎样结束等待,重复请求是否保持幂等。

测试验证这些约定,包括合法状态转换,以及操作被拒绝后原状态是否保持。

12.2 一个方法的参数很多时应该怎样改进​

出现公司:蚂蚁集团

考察重点

  • 说明参数是否属于同一个领域概念。
  • 说明必需参数、可选参数和布尔标志怎样表达。
  • 说明参数对象如何校验完整性,避免只是把散参数搬进带有任意 setter 的对象。

相关内容:领域类型、接口范围。

参考回答

参数多时,应按稳定的业务概念合并参数;涉及多项独立职责时,则应拆分方法。

比如,金额与币种可以组成 Money,分页字段可以组成 PageRequest。多个布尔标志可以改成枚举或命名方法,让调用处直接表达含义。

必需参数与可选参数要分开考虑。可选参数多时,用 Builder 分步收集,并在 build 时统一验证。参数对象自身也要维护不变量,单纯把十个参数搬进一个任意可写的 Bean,仍会保留非法组合。

12.3 Optional 有值时,orElse 中的方法还会执行吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释 Java 的实参求值顺序,以及 Supplier 为什么能把默认值计算推迟到需要时。

相关内容:Optional 的默认值求值。

参考回答

会,orElse 中的方法会在调用 orElse 之前执行。

Java 先计算实参,再进入方法,所以 Optional 有值也无法省去这次计算。

orElseGet 接收的是产生默认值的 Supplier,只有结果缺失时才调用它。因此,默认值需要访问数据库或产生其他副作用时,适合使用 orElseGet 按需计算。

13. 参考资料​