2698 字
约 8 分钟
18
S18-X17 · 防腐层与外部系统适配

外部模型的侵入不是一次架构决定,而是一行一行长出来的:某个需求"顺手"把第三方的 PaymentResponse 引进了 Service,某个字段改名三个月后才暴露影响面,某个上游把 500 换成带业务码的 200,核心逻辑里就多了个 code == 20000。等要换供应商,才发现领域层已经说不了自己的语言。

防腐层(Anti-Corruption Layer, ACL)做的事就是划一条线:线内是领域语义,线外是别人的语义,翻译只发生在线上的适配器里。


1. 三类污染

污染类型 表现 不隔离的后果
外部模型侵入核心 领域实体带上游的 @JsonProperty,字段名跟着上游改 上游改名 → 领域层跟着改 → 全链路回归
外部变更传导 上游新增必填字段、改枚举,编译期不报错 影响面无法估计
异常语义混淆 超时、限流、业务拒绝混在同一个 RuntimeException 判断不出该重试还是该失败

第三类最容易被忽略:429 是"稍后重试",INSUFFICIENT_BALANCE 是"业务拒绝、重试无用",SIGN_INVALID 是"我方配错了、必须告警"。它们该是三个概念,而不是同一个 catch。先把结果定义成封闭类型:

public interface PaymentGateway {                 // 端口:只有领域语言
    PaymentResult charge(PaymentOrder order);
}

public sealed interface PaymentResult {
    record Success(String providerTxId) implements PaymentResult {}
    record Rejected(String reasonCode, String msg) implements PaymentResult {}       // 不重试
    record TransientFailure(String msg, Duration retryAfter) implements PaymentResult {} // 可重试
}

这段里没有 HttpClient、没有上游 DTO 和错误码。


2. 三种边界

边界 方向 内容 漏做会怎样
数据模型 外→内 / 内→外 字段名、类型、单位、枚举、空值语义、嵌套 上游改名直接改到领域实体
错误语义 外→内 上游错误码/HTTP 码 → 内部异常族 重试策略写在调用点,四处不一致
协议 内→外 签名、加密、超时、分页、批量、幂等 协议细节渗进业务代码

单位和枚举最容易漏:金额上游用"分"、内部用 BigDecimal 元;上游状态有 SUCCESS/FINISHED/DONE 三个同义词。这类转换在适配器里是一行 switch,散在业务代码里就是几十处 if。

转换落在三个动作:orderNo → 上游 outTradeNo;BigDecimal 元 → "分"(movePointRight(2),是两个世界的单位约定,不是业务规则);SUCCESS/FINISHED/DONE 归一到一个内部枚举。异常就地翻译:超时与限流都返回 TransientFailure 并带 retryAfter,其余落进 Rejected。

3. 适配器的放置:端口在内,实现在外

order-domain                        order-infrastructure
├── domain/PaymentGateway.java  ←──  ├── gateway/AcmePaymentAdapter.java
├── domain/PaymentOrder.java         ├── gateway/AcmeClient.java
└── domain/PaymentResult.java        └── gateway/AcmeChargeRequest.java
        ▲
        └── order-application 依赖 domain,不依赖 infrastructure

实现侧靠 @Bean 装配,领域侧不知道 AcmePaymentAdapter 存在。规则靠编译期强制:order-domain 的 pom.xml 里不能出现 spring-boot-starter-web、okhttp、jackson。这比靠 Code Review 可靠。外部系统多时可再分一层 PaymentProviderPort(这家供应商怎么调);只有一家供应商、短期不会换,合成一层就够。


4. DTO 转换的成本与控制

方式 优点 代价 适用
手工 setter 映射 明确、可调试、可重构 字段多时样板代码多 字段 < 15,或含条件判断
MapStruct 编译期生成 零反射、编译期报错 需注解处理器配置 字段多、结构规整
BeanUtils 反射拷贝 一行搞定 运行期反射、改名静默失败 临时脚本,不进生产
JSON 序列化往返 少写代码 类型错误推到运行期 不推荐

可维护性问题不在工具,在双向转换:30 个字段的 DTO 既外→内又内→外,就是 60 个字段要维护,且两边不对称。办法是单向化——领域侧只声明"我需要的",适配器把外部结构压成最小形状。

@Mapper(componentModel = "spring",
        unmappedTargetPolicy = ReportingPolicy.ERROR)   // 漏映射直接编译失败
public interface OrderViewMapper {
    @Mapping(source = "channelCode", target = "channel")
    OrderView toView(ExternalOrderDTO dto);   // 枚举归一放在 default 方法里

}

ReportingPolicy.ERROR 是关键:默认的 WARN 只在日志里刷过,等于没有约束。


5. 失败语义:四类,不是两类

失败类型 例子 该做什么 不该做什么
超时 连接超时、读超时 幂等前提下可重试;两者分开 无限重试;重试非幂等写
可重试 429、502/503/504、连接被拒 退避重试,尊重 Retry-After 立即重试形成风暴
不可重试 参数错、签名错、业务拒绝 快速失败,映射成明确异常 重试
部分成功 批量中一半成功 记录逐条结果并暴露 压成整体成功或失败

连接超时和读超时是两回事:前者说明请求没发出去,重试安全;后者说明请求很可能已被处理,非幂等写重试就重复提交。

ClientHttpRequestFactorySettings s = ClientHttpRequestFactorySettings.defaults()
    .withConnectTimeout(Duration.ofMillis(500))    // 连不上就是连不上
    .withReadTimeout(Duration.ofMillis(3000));     // 给上游留处理时间

读超时后要重试写操作,必须依赖上游幂等键或本地幂等表——写进注释,否则下一个人加个 @Retryable 就把重复扣款加上了。


6. 版本变化如何被隔离

正确做法:兼容分支只出现在一个文件里。

@Component
class AcmePaymentAdapter implements PaymentGateway {
    private final boolean useV2;           // 由配置注入,不写死

    @Override
    public PaymentResult charge(PaymentOrder order) {
        return useV2 ? chargeV2(order) : chargeV1(order);   // /payments vs /pay/apply
    }
    private PaymentResult chargeV2(PaymentOrder order) { ... }
    @SuppressWarnings("unused")
    private PaymentResult chargeV1(PaymentOrder order) { ... }
}

同时写契约测试:同一批输入下两套实现的 PaymentResult 语义必须一致,切换时立刻能发现漂移。差异大到无法共存(如认证从签名改 OAuth)就做两个实现,由配置决定装配哪个,业务不感知。


7. 与被调方共享库的反模式

反模式 表面好处 实际代价
上游 SDK 模型类直接进领域层 省掉 DTO 上游升级 = 强制升级;带来一堆上游注解
SDK 与本地 Jackson/okhttp 版本冲突 开发省事 仲裁结果不可预期,运行期 NoSuchMethodError
共享"公共 DTO 包" 版本一致 谁都改,改一次全体重编
复用 SDK 内部线程池 不用配 上游默认参数不可控,成隐藏瓶颈

原则:SDK 只能出现在基础设施模块的适配器里,且必须被端口包住,升级影响面收敛到"改一个适配器 + 跑契约测试"。


8. 什么时候不需要防腐层

判据是变更速率差:两边模型的变更原因和节奏不同就该切;总是一起改,切了只增加同步成本。四类不必上:内部同构服务(同团队同模型,字段名即领域语言)、一次性脚本、只取一个 boolean 的探针调用、同进程内的模块调用(那是模块边界问题)。


9. 测试策略

重点不是覆盖率,是契约:

类型 打什么 断言什么
桩单测 端口接口 业务逻辑对三种 PaymentResult 的处理
契约测试 真实适配器 + 录制的上游响应 上游结构不变时转换正确
抓包回放 样本报文存入 src/test/resources 上游新版报文也能解析
集成测试 上游沙箱 端到端连通性,只在改适配器时跑
@Test
void should_treat_read_timeout_as_transient() {
    AcmeClient client = mock(AcmeClient.class);
    when(client.charge(any(), any())).thenThrow(new AcmeTimeoutException("read"));
    assertInstanceOf(PaymentResult.TransientFailure.class,
                   new AcmePaymentAdapter(client, false).charge(order()));
}

这条比"字段映射对不对"更重要:错误分类错一次,就是一次重试风暴或一次静默丢单。每加一个上游错误码,都要补一条映射测试。


10. 常见坑

坑 现象 改法
外部 DTO 直接进领域层 领域实体带上游注解,上游加字段就序列化异常 领域模型与上游 DTO 分离
异常映射只做在 Controller Service 互调时拿到原始上游异常 在适配器里就映射成领域异常
适配层塞业务逻辑 "顺便算个优惠",换供应商要重写 适配层只翻译,不决策
外部字段改名引发全链路改动 一处改名十几个文件 转换只在一处
重试写在调用点 各业务里次数不同 收敛到 RestClient 配置

11. 排错

现象 排查方向
上游说收到、本地说失败 读超时;按 outTradeNo 查上游结果并对账
换上游要改一大片业务代码 端口太薄,或领域层直接用了上游类型
上游报错但日志只有 NullPointerException 响应体解析失败被吞;先记原始报文再解析
启动后随机 NoSuchMethodError SDK 与本地依赖冲突;mvn dependency:tree 找重复 class
契约测试全过但线上解析错 线上返回了网关 HTML;断言 Content-Type

12. 边界

场景 更适合的做法
内部同构服务、同团队同模型 直接共享模型或走 gRPC
上游本身就是你的领域概念 客户端库加薄封装即可
上游 API 每周改 防腐层治标;优先找稳定版本或本地镜像
需要全量动态 schema JsonNode 直通并标注"未建模"

13. 自测

  1. 上游返 429 与返 BALANCE_NOT_ENOUGH,在领域层该是同一个异常吗?
  2. 端口在领域层、实现在基础设施层,靠什么机制保证依赖方向不被打破?
  3. 读超时之后为什么不能无条件重试创建订单的请求?
  4. 上游字段改名,改动该落在几个文件里?
  5. 什么样的两个系统之间不需要防腐层?
  6. 适配器测试里,为什么"错误分类"比"字段映射"更关键?
S18-X17 · 防腐层与外部系统适配
https://www.clxhxhhr.top/posts/5930/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。