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 吗?
可选参数多、需要分步收集时,Builder 更合适。只有少量必需参数时,构造器或 record 更直接。Builder 应在 build 时检查完整性,校验成功后才返回目标对象,避免半成品被当作合法对象使用。
11.3 公共参数都要检查 null 吗?
只有契约拒绝 null 时才检查,并在公共入口尽早报错、给出参数名。允许缺失的输入,可以通过重载或明确的状态类型表达。Optional 主要用于可选返回结果,输入与返回值应分别设计。
可选输入的具体取舍见 Optional 的方法参数边界。
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 按需计算。