外观接口如何做版本兼容和演进?
简化版
外观接口如何做版本兼容和演进 的核心,是围绕「版本兼容」划清稳定边界和变化边界。面试回答时要说明它解决的具体变化点、对象如何协作、边界条件怎么兜底,以及为什么不用更简单的 if-else 或普通函数。
详细版
在项目里,外观模式 的价值通常不在类图本身,而在它能不能降低变化扩散。以「旧客户端仍调用 v1,新客户端需要 v2 聚合字段」为例,如果处理方式分散在多个调用方,需求变化时就要到处修改;如果把规则收敛到清晰的模式边界里,调用方只依赖稳定入口,具体变化由内部对象承担。
这类题的回答可以按四步组织:第一,说清它解决的变化点;第二,给出对象关系或执行流程;第三,说明哪些字段、状态或副作用不能随便放;第四,补上测试、异常、性能和可观测性。这样答案会比“它能解耦、能复用”更像真实项目经验。
一个常见判断标准是:如果变化点每个月出现 3 次以上,且每次都影响 2 个以上模块,就值得考虑模式化;如果只是一次性分支,用简单函数反而更清楚。
完整版教学
一、先定位这道题真正考什么
面试官问「版本兼容」时,通常不是想听模式定义,而是想看你能不能把设计模式落到工程约束里。外观模式 只是工具,真正要回答的是变化如何被隔离、调用方如何保持稳定、错误边界怎么兜住。离开这些问题,类图再标准也容易变成纸面设计。
在「旧客户端仍调用 v1,新客户端需要 v2 聚合字段」这个场景中,变化点至少有 2 类:一类是业务规则变化,另一类是技术实现变化。好的设计会让调用方只看到稳定语义,而不是被迫理解每个细节。设计前先列出变化点,比上来就写类更可靠。
变化来源 -> 稳定入口 -> 模式内部协作 -> 结果/异常/观测
记忆钩子:先找变化点,再套模式;先定边界,再写类图。
二、把稳定入口和变化实现分开
外观模式 的落地通常需要一个稳定入口。调用方依赖这个入口完成业务动作,内部再根据场景选择不同实现、状态、节点或表达式。这样新增一种实现时,调用方不需要改;修改内部规则时,也不会把变化传染到外层。
interface PatternPort<C, R> {
R execute(C context);
}
final class BusinessContext {
private final String scene;
private final String requestId;
BusinessContext(String scene, String requestId) {
this.scene = scene;
this.requestId = requestId;
}
}
这段代码不是要求所有模式都长这样,而是强调入口要稳定。比如适配器的入口是内部接口,状态模式的入口是上下文行为,命令模式的入口是命令执行器,解释器的入口是表达式求值器。入口稳定后,变化才能被收拢到内部。
三、用流程图说明对象如何协作
回答设计模式题时,流程图比单纯类名更有说服力。因为面试官关心请求进来后到底经过哪些对象,在哪里分发,在哪里短路,在哪里返回结果。你可以用简单 ASCII 图把执行顺序讲清楚。
Client
-> Stable API
-> Pattern Object / Registry / Context
-> Concrete Implementation
-> Result(code, data, trace)
如果这个流程里某一步需要远程调用、数据库写入或缓存读取,就要特别标出来。比如一次请求经过 5 个节点,其中 2 个节点有副作用,就必须说明幂等和补偿;如果只是 3 个纯内存对象协作,重点就落在接口粒度和扩展成本。
四、关键数据要有明确归属
很多模式代码最后变难维护,是因为数据归属不清。字段到底由谁创建、谁修改、谁读取、谁负责校验,如果没有规则,后续对象之间会通过隐式字段耦合。外观模式 同样需要先定义数据边界。
| 数据类型 | 应放位置 | 风险 |
|---|---|---|
| 请求输入 | 调用上下文或命令对象 | 被内部对象随意改写 |
| 业务规则 | 具体实现或规则对象 | 散落在调用方 if-else |
| 执行结果 | 统一结果模型 | 只返回 boolean 丢失原因 |
| 观测字段 | 入口或编排层统一记录 | 每个类日志格式不同 |
举个数字例子:一个流程有 6 个字段被 4 个对象读写,如果没有归属说明,最多会形成 24 条潜在依赖。把字段拆成输入、规则、结果、观测 4 类,可以显著降低读代码时的猜测成本。
五、异常和边界条件必须提前定义
设计模式不是只处理成功路径。以「直接删除旧字段导致老客户端崩溃」为例,问题通常不是没有类,而是边界没有定义。异常应该在哪里转换,非法状态在哪里阻止,外部接口失败时是否降级,重复请求会不会产生副作用,这些都属于模式落地的一部分。
正常路径: validate -> execute -> return success
业务失败: validate -> reject(code) -> stop
系统异常: execute -> catch/log -> error result
重复请求: idempotent key -> existing result
如果回答时能主动补一句“业务拒绝和系统异常分开处理”,会明显提高答案质量。业务拒绝是可预期分支,系统异常是故障;二者混在一起会让监控、重试和用户提示都失真。
六、性能和复杂度要算账
引入模式会增加对象数量和间接调用。多数时候这点开销很小,但高频路径或大批量处理时必须算账。比如一次请求创建 10 个临时对象、每秒 2000 次请求,就是每秒 20000 个对象;如果对象里还有序列化、反射或远程调用,成本就不能忽略。
| 设计选择 | 收益 | 成本 |
|---|---|---|
| 直接 if-else | 简单直观 | 变化扩散快 |
| 引入外观模式 | 扩展点清楚 | 类数量增加 |
| 配置化 | 可动态调整 | 校验和观测成本上升 |
| 缓存或注册表 | 降低重复创建 | 要处理更新和淘汰 |
所以面试回答不要把模式说成天然更优。更专业的说法是:当变化频率、复用需求和边界复杂度超过简单代码能承受的范围时,引入模式才划算。
七、测试要覆盖契约而不是只测实现
外观模式 的测试重点是契约。稳定入口接收什么输入,输出什么结果,遇到异常如何表现,新增实现是否影响旧场景,这些都要固定下来。只测某个具体类的内部细节,无法保证组合后的行为正确。
@Test
void shouldKeepStableContract() {
BusinessContext context = new BusinessContext("default", "req-001");
Result result = port.execute(context);
assertEquals("OK", result.code());
}
测试矩阵至少要覆盖 4 类:正常路径、边界输入、业务失败、系统异常。如果有顺序、状态或树结构,还要补顺序断言、状态迁移断言或遍历结果断言。这样未来重构内部类时,外部契约不会被破坏。
八、常见误区与追问
- 误区:用了外观模式就一定更解耦。 如果边界没划清,类变多只会让依赖更隐蔽。
- 误区:所有变化都应该抽象。 低频、一次性的变化用简单代码更清晰,过度抽象会提高理解成本。
- 误区:只画类图就算掌握。 面试更看重执行流程、异常边界、测试和项目取舍。
- 追问:什么时候不该用这个设计? 当对象关系简单、变化点不稳定、团队还没形成统一约定时,应优先保持直接实现。
- 追问:如何防止后续代码腐化? 用接口契约、命名规范、测试矩阵和代码评审限制扩展点使用方式。
- 追问:这个设计如何上线排查? 在稳定入口记录 traceId、场景、实现名称、结果码和耗时,避免内部协作变成黑箱。
九、加强记忆
记住“变化点、稳定口、协作图、数据归属、异常边界、测试契约”这 6 个词。设计模式题不要停在定义层,要把变化如何进入系统、如何被分发、如何返回结果、失败后如何处理讲清楚。只要能把这 6 个点串起来,外观模式 的项目落地就比较完整。