9.1 边界适配:Adapter、Facade、Proxy 与 Bridge
供应商把“已接收”叫作 accepted,内部却区分“提交成功”和“排名已更新”;查询入口要检查当前用户权限,通知又要支持短信和邮件。这些代码都可能多包一层对象,但多出来的那层各有职责。
结构型模式处理的是对象之间的形状与连接方式。它们的代码经常都是“持有一个对象,再转发调用”,真正区别在于设计意图:翻译边界、收窄入口、控制访问,还是拆开两个独立变化维度。
Adapter:让外部模型符合内部契约
本节使用 Java 21 项目片段。领域类型和供应商 SDK 由项目提供;各 public 类型放入同名文件。代码展示边界职责,不是可直接接入某家真实供应商的客户端。
业务用例不应直接依赖第三方 SDK 的请求对象、异常和状态码。先定义自己需要的能力:
public interface RankingPort {
RankingSubmission submit(MatchResult result);
}
public record RankingSubmission(
String externalId,
SubmissionStatus status) {}
public enum SubmissionStatus { ACCEPTED, PENDING, UNKNOWN }适配器负责第三方边界翻译:
public final class VendorRankingAdapter implements RankingPort {
private final VendorRankingClient client;
public VendorRankingAdapter(VendorRankingClient client) {
this.client = java.util.Objects.requireNonNull(client, "client");
}
private static SubmissionStatus mapStatus(String status) {
return switch (status) {
case "accepted" -> SubmissionStatus.ACCEPTED;
case "pending" -> SubmissionStatus.PENDING;
case null, default -> SubmissionStatus.UNKNOWN;
};
}
@Override
public RankingSubmission submit(MatchResult result) {
VendorRequest request = new VendorRequest(
result.matchId().value(),
result.winnerId().value(),
result.score().value());
try {
VendorResponse response = client.createRanking(request);
return new RankingSubmission(
response.id(), mapStatus(response.status()));
} catch (VendorRateLimitException ex) {
throw new RankingTemporarilyUnavailable(ex.retryAfter(), ex);
} catch (VendorValidationException ex) {
throw new RankingRejected(ex.getMessage(), ex);
}
}
}这里假设供应商状态是字符串,只确认 accepted、pending 两个值;新增或空状态映射为 UNKNOWN,业务端必须进入待核实流程,不能按成功发奖。ACCEPTED 也只表示供应商已接收,不承诺排名已经更新。示例只翻译两种异常,其余异常策略须在真实 SDK 接入时补齐。
Adapter 不只是改方法名,也可以转换协议、数据模型和错误分类。但它不应擅自决定“失败时给用户加多少积分”这类领域规则。外部概念与内部概念不完全等价时,要显式记录有损映射与未知值策略。
不要用字符串拼接 XML/JSON。使用供应商 SDK 或经过配置的序列化器,处理转义、编码、schema 和安全限制。
适配器测试
至少覆盖:
- 内部请求到外部请求的字段和单位映射;
- 外部状态到内部状态的完整映射,包括未知值;
- 超时、限流、认证和校验失败的分类;
- 重试与幂等键是否透传;
- SDK/协议升级的契约测试。
Facade:为一个用例提供稳定入口
Facade 把多个子系统协作收敛成面向调用者的粗粒度入口:
public final class RegistrationFacade {
private final EligibilityService eligibility;
private final PaymentService payments;
private final RegistrationRepository registrations;
public RegistrationFacade(EligibilityService eligibility,
PaymentService payments,
RegistrationRepository registrations) {
this.eligibility = java.util.Objects.requireNonNull(eligibility);
this.payments = java.util.Objects.requireNonNull(payments);
this.registrations = java.util.Objects.requireNonNull(registrations);
}
public RegistrationReceipt register(RegisterCommand command) {
eligibility.requireEligible(command.playerId(), command.tournamentId());
PaymentReceipt payment = payments.authorize(command.payment());
Registration registration = Registration.confirmed(command, payment);
registrations.save(registration);
return RegistrationReceipt.from(registration);
}
}它减少调用者需要知道的顺序和子系统数量。示例假设支付授权已满足报名确认条件;授权冻结额度并不等于最终扣款,若业务要求实际扣款后确认,流程和状态必须随之调整。
Facade 不自动解决一致性:授权成功而保存失败,需要持久记录以便撤销授权或恢复流程;保存响应丢失时也不能盲目重扣。资格检查与名额占用之间的并发竞争,仍需事务或条件更新。把调用排成一列,数据库和支付网关不会因此开始列队行进。
Facade 是否允许绕过取决于边界。如果它只是库的便捷 API,底层接口可以公开;如果它承担模块一致性与权限边界,允许外部直接访问内部服务会破坏不变量。
Facade 与 Application Service 经常重叠。模式名不重要,关键是入口是否围绕用例提供完整契约,而不是变成一个拥有几十个无关方法的“万能门面”。
Proxy:保持接口,同时控制一次访问
Proxy 与真实对象实现相同契约,在调用前后施加访问策略。常见用途包括授权、远程调用、延迟加载、限流和缓存。
public final class AuthorizedTournamentQueries
implements TournamentQueries {
private final TournamentQueries target;
private final Authorizer authorizer;
private final CurrentPrincipal principal;
public AuthorizedTournamentQueries(TournamentQueries target,
Authorizer authorizer,
CurrentPrincipal principal) {
this.target = java.util.Objects.requireNonNull(target);
this.authorizer = java.util.Objects.requireNonNull(authorizer);
this.principal = java.util.Objects.requireNonNull(principal);
}
@Override
public TournamentView find(TournamentId id) {
authorizer.require(
principal.get(), Permission.VIEW_TOURNAMENT, id);
return target.find(id);
}
}Authorizer.require() 的契约是拒绝时抛异常,只有允许时才返回;用 Fake 验证“拒绝时 target 调用次数为 0,允许时为 1”,可以直接查出先读数据后鉴权的错误。授权失败必须也是 TournamentQueries 对外契约允许的结果。
代理必须保留原契约。缓存代理如果返回超过新鲜度承诺的数据,远程代理如果把网络失败伪装成“数据不存在”,就不再是可透明替换的 Proxy。
控制逻辑并非天然安全
- 授权要默认拒绝,并覆盖对象级权限;
- 限流要说明作用域、算法、时钟和多实例协调;
- 缓存要说明 key、TTL、失效和权限隔离;
- 重试只用于可安全重试的操作,并尊重总超时;
- 远程代理要保留追踪、取消和错误语义。
Java 动态代理通过 InvocationHandler 转发接口调用;AOP 和服务网格也可实现部分拦截。服务网格看到的通常是网络请求,不能替应用补齐赛事归属这类领域授权。拦截链仍应有确定顺序和独立测试。
Bridge:拆开两个独立扩展维度
当“消息种类”和“传输通道”都独立变化,用子类组合会产生笛卡尔积:
UrgentEmail, UrgentSms, UrgentPush,
DigestEmail, DigestSms, DigestPush ...Bridge 让高层抽象组合实现维度:
public interface DeliveryChannel {
DeliveryResult deliver(Message message, Recipient recipient);
}
public abstract class Notification {
private final DeliveryChannel channel;
protected Notification(DeliveryChannel channel) {
this.channel = java.util.Objects.requireNonNull(channel, "channel");
}
public final DeliveryResult send(Recipient recipient) {
return channel.deliver(compose(), recipient);
}
protected abstract Message compose();
}
public final class MatchCancelledNotification extends Notification {
private final Match match;
public MatchCancelledNotification(DeliveryChannel channel, Match match) {
super(channel);
this.match = java.util.Objects.requireNonNull(match, "match");
}
@Override
protected Message compose() {
return Message.text("Match " + match.id() + " was cancelled");
}
}前提是各通道支持 Message 的共同契约。短信长度、附件支持等能力不同,必须在组合前校验或显式返回不支持,不能假装组合任意两项都合法。
新增通知内容不要求修改通道;新增通道不要求复制每种通知。它与 Adapter 的差别是时间和目的:Adapter 通常连接已有不兼容接口,Bridge 在设计时主动拆开两个变化维度。
如果只有一个维度会变化,普通策略组合已经足够,不必强行称为 Bridge。
四种模式快速辨别
| 你真正要解决的问题 | 更可能的模式 |
|---|---|
| 外部接口/模型与内部契约不一致 | Adapter |
| 调用者不应了解一组子系统协作 | Facade |
| 保持同一接口并控制一次访问 | Proxy |
| 两个维度需要独立扩展 | Bridge |
下一节处理可层叠行为、树形结构和大规模共享对象。