跳到内容

第 6 卷 / 第 9 章 / 第 1 课

9.1 边界适配:Adapter、Facade、Proxy 与 Bridge ​

供应商把“已接收”叫作 accepted,内部却区分“提交成功”和“排名已更新”;查询入口要检查当前用户权限,通知又要支持短信和邮件。这些代码都可能多包一层对象,但多出来的那层各有职责。

结构型模式处理的是对象之间的形状与连接方式。它们的代码经常都是“持有一个对象,再转发调用”,真正区别在于设计意图:翻译边界、收窄入口、控制访问,还是拆开两个独立变化维度。

Adapter:让外部模型符合内部契约 ​

本节使用 Java 21 项目片段。领域类型和供应商 SDK 由项目提供;各 public 类型放入同名文件。代码展示边界职责,不是可直接接入某家真实供应商的客户端。

业务用例不应直接依赖第三方 SDK 的请求对象、异常和状态码。先定义自己需要的能力:

java
public interface RankingPort {
    RankingSubmission submit(MatchResult result);
}

public record RankingSubmission(
        String externalId,
        SubmissionStatus status) {}

public enum SubmissionStatus { ACCEPTED, PENDING, UNKNOWN }

适配器负责第三方边界翻译:

java
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 把多个子系统协作收敛成面向调用者的粗粒度入口:

java
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 与真实对象实现相同契约,在调用前后施加访问策略。常见用途包括授权、远程调用、延迟加载、限流和缓存。

java
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:拆开两个独立扩展维度 ​

当“消息种类”和“传输通道”都独立变化,用子类组合会产生笛卡尔积:

text
UrgentEmail, UrgentSms, UrgentPush,
DigestEmail, DigestSms, DigestPush ...

Bridge 让高层抽象组合实现维度:

java
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

下一节处理可层叠行为、树形结构和大规模共享对象。

参考资料 ​

Built with VitePress | Software Systems Atlas