工程治理
收录于 工程治理体系
为什么在我们的扫描器接入场景里,我更偏向 SPI
我们有扫描任务,要接第三方扫描器,而且开放接入本身就是这套系统的核心诉求之一。问题不在于今天有几个扫描器,而在于做之前根本不知道未来会来哪些实现。比起继续把变化堆进主流程,我更想给这类代码级扩展一个统一接入边界。
我们有一类扫描任务。扫描任务本身的主流程不算复杂:拿到任务、准备上下文、调用扫描能力、回收结果、落库。
麻烦在后面。这个扫描能力不是永远固定的一套,后面是要接第三方扫描器的。而且“能接第三方”本身就不是一个附带需求,它就是这套系统很重要的产品方向之一。我们天然就希望它是开放的,希望后面可以不断接新的扫描器进来。
问题也因此变得很具体。
如果只是今天接一个扫描器,其实并不难。最顺手的写法,无非就是在主流程里加一个判断:
if ("A".equals(scannerType)) {
scannerA.scan(task);
} else if ("B".equals(scannerType)) {
scannerB.scan(task);
} else {
defaultScanner.scan(task);
}
但这件事最别扭的地方在于,我们在做之前,其实并不知道未来会有哪些扫描器。
系统从一开始面对的局面就是:后面还会持续长出新的扫描器实现,这些实现来自第三方,接入节奏、接入数量和接入方式都不稳定。这跟“我已经有三种扫描器,怎么组织一下”完全是两回事。
这时候真正麻烦的地方在于,变化会持续侵入主流程。
因为你今天也许只有两个扫描器,明天可能就多一个特化能力,后天又会来一个只服务某类任务、某类租户、某类文件格式的第三方实现。最开始大家都会觉得这很正常:
“这里加个 if 就行。” “先兼容一下,后面再收。” “这个扫描器比较特殊,单独走个分支。”
问题也恰恰出在这里。复杂度不是一下子炸开的,它往往就是被一个个“先这么接”的局部决策,慢慢堆进系统的。
改着改着就成这样了。
这里还有一层如果不说清楚,前面会显得有点突兀:我们本身就把开放接入当成一个核心方向,所以这个问题迟早会遇到,它就是这个方向往前走的必然代价。你越希望后面能不断接第三方,就越不能把每次接入都直接写进主流程。
等堆到一定程度后,系统就会出现很典型的症状:
- 主流程越来越长
- 新扫描器接入越来越难
- 改一个实现容易影响另一个场景
- 谁也说不清某段兼容逻辑到底服务哪个扫描器
- 线上排查越来越依赖熟悉历史的人
这篇想讲的,其实就是一件事:
在这种“未来实现未知,而且接入会持续发生”的场景里,怎么别让主流程跟着一起膨胀。
在这个扫描任务的改造过程中,我会比较倾向引入 SPI 这类扩展机制。因为它正好在回答这个问题。
麻烦不在逻辑多,在变化点一直往主流程里钻
代码后面越写越乱,往往不是业务没法抽象,是所有变化都直接进了主流程。
这个扫描任务的主流程,如果不控制,最后很容易长成下面这种感觉:
public void execute(ScanTask task) {
prepare(task);
if ("A".equals(task.getScannerType())) {
scannerA.scan(task);
} else if ("B".equals(task.getScannerType())) {
scannerB.scan(task);
} else if (task.isOverseaTask()) {
overseaScanner.scan(task);
} else {
defaultScanner.scan(task);
}
persist(task);
}
这段代码在系统早期不一定有问题。问题在于,它几乎注定会继续膨胀。
因为它把三件事揉在了一起:
- 主流程编排
- 场景判断
- 具体实现
于是后面每来一个新扫描器,最自然的接法就还是同一种:再补一个判断。
久而久之,主流程就不再是“扫描任务骨架”,而变成了一个巨大的条件分发器。这个阶段最糟糕的地方在于,主流程开始失去稳定性——代码丑只是表象。每次新接一个扫描器、补一个兼容逻辑、加一个特殊任务类型,第一刀都先砍到主流程上。
不能再这么接了。
这里要做的,不是把 if/else 写得更优雅,而是重新划清两类边界:
- 稳定的部分,留在主流程
- 变化的部分,从主流程里剥出去
最开始我其实也想过模板方法加策略模式
这个地方没必要假装自己一上来就想到了 SPI。
最开始的时候,我其实更自然想到的是模板方法加策略模式。这套方法本身也很经典,而且放在很多场景里完全够用:主流程骨架固定,局部步骤允许替换,不同实现通过策略切换。
如果系统里的变化集合是已知的、相对稳定的,这条路没什么问题。
但这个扫描任务的别扭点在于,后面还会继续长出新的扫描器实现,而且现在并不知道会来哪些。这跟“我已经知道有哪些策略,只要优雅地切换”完全是两种局面。这个时候,我想解决的就不止“已有实现怎么切”,还包括:
- 新扫描器怎么接进系统
- 接进来以后怎么统一发现
- 默认实现是谁
- 多个实现同时命中怎么办
- 后面怎么做冲突校验、日志和管控
也就是说,我后来偏向 SPI,不是策略模式不行,是我需要的不只是行为切换,还需要一层扩展接入机制。
JDK SPI 够知道就行,重点还是业务怎么落
这里如果展开讲 ServiceLoader、META-INF/services、类加载器,技术上当然没问题,但会很快把文章带偏。
因为我这次更关心的是:它为什么适合接这种未来实现未知、且会持续增长的业务扩展。JDK SPI 在语言层面怎么工作,放在这篇里优先级不高。
落到业务系统里,我更在意的是这些问题:
- 当前任务该命中哪个扫描器实现
- 多个实现冲突怎么办
- 默认实现是谁
- 新扫描器怎么统一接进系统
- 扩展后面怎么监控、怎么下线、怎么治理
所以这里对 JDK SPI 的态度很简单:思想要知道,细节不用讲太重。该展开的,是业务里那种更贴近 Spring 的落地方式。
如果真要落地,我更愿意把它做成一层受控的扩展机制
假设我们有一个扫描任务主流程。主流程本身相对稳定,但不同第三方扫描器、不同任务类型、不同交付形态会带来差异化处理。
那我更愿意把“扫描器差异化处理”归拢到一个扩展接口里,别继续散在主流程里。
接口大概会长成这样:
public interface ScanExtension {
boolean supports(ScanTask task);
ScanResult execute(ScanTask task);
}
然后通过 Spring 把不同实现收进容器里,再做一个统一的注册与选择中心:
@Component
public class ScanExtensionRegistry {
private final List<ScanExtension> extensions;
private final DefaultScanExtension defaultExtension;
public ScanExtensionRegistry(List<ScanExtension> extensions,
DefaultScanExtension defaultExtension) {
this.extensions = extensions;
this.defaultExtension = defaultExtension;
}
public ScanExtension select(ScanTask task) {
List<ScanExtension> matched = extensions.stream()
.filter(ext -> !(ext instanceof DefaultScanExtension))
.filter(ext -> ext.supports(task))
.toList();
if (matched.isEmpty()) {
return defaultExtension;
}
if (matched.size() > 1) {
throw new IllegalStateException("Multiple scan extensions matched");
}
return matched.get(0);
}
}
主流程则只保留编排职责:
public void execute(ScanTask task) {
prepare(task);
ScanExtension extension = registry.select(task);
ScanResult result = extension.execute(task);
persist(task, result);
}
这时候一个关键变化是,主流程终于重新像一个扫描任务主流程了。它只负责准备上下文、选择扩展、执行扫描和统一收尾。那些不断膨胀的第三方差异化逻辑,被归拢到各自的扩展实现里。
这里有个很重要的点:选择逻辑必须统一收敛,不能散落在调用方。否则只是把 if/else 从主流程搬到了别的类里,本质没有变。
两种接入方式的结构差异,画出来大概是这样:
flowchart TB
subgraph direct["直接依赖 · 编排+判断+实现混在主流程"]
M1["主流程 execute()"] --> J{"scannerType 判断"}
J -->|"A"| S1["scannerA.scan()"]
J -->|"B"| S2["scannerB.scan()"]
J -->|"海外任务"| S3["overseaScanner.scan()"]
J -->|"默认"| S4["defaultScanner.scan()"]
end
subgraph spi["SPI 扩展 · 稳定留主流程,变化进扩展边界"]
M2["主流程 execute()"] -->|"选择"| R["扩展注册中心"]
R -->|"统一发现"| EI["ScanExtension 接口"]
EI --- E1["扫描器A"]
EI --- E2["扫描器B"]
EI --- E3["未来新扫描器"]
end
为什么在我们这个场景里,我会更倾向 SPI
这里要先说清楚,SPI 不是唯一能控复杂度的办法。
规则引擎可以,配置中心可以,模板方法加策略模式也可以。但它们控制复杂度的方式并不一样。而这次我更偏向 SPI,是因为我更在意复杂度进入系统的方式,光管“代码怎么写”还不够。
1. 和规则引擎比,我要控制的是扩展边界,不是规则表达
规则引擎更擅长规则密集型问题,比如审批命中、营销判断、资格校验、组合条件决策。
它擅长把复杂逻辑从代码迁到规则系统里。
但这个扫描任务场景里,很多差异远不止“规则多”这一层:
- 不同扫描器有不同接入方式
- 不同实现有自己的依赖、超时和返回格式
- 差异化逻辑不只是一个判断,还带着完整调用动作
- 这批逻辑还要和代码、测试、发布、监控一起管
这时候如果强行上规则引擎,复杂度多半没有消失,只是从 Java 代码转移到了规则网络里。所以对这类问题来说,我更想解决的是“差异化逻辑如何以统一边界接入主流程”,这件事 SPI 更合适。
2. 和配置中心比,我要隔离的是代码级变化,不是参数外置
配置中心很适合承接这些东西:
- 开关
- 阈值
- 白名单、黑名单
- 简单路由
- 灰度比例
- 文案和映射配置
这些本来就应该外置,因为它们更像参数变化,不是能力扩展。
但很多系统做到后面,会忍不住把越来越多业务逻辑写进配置里。最后配置就不再只是配置,而变成了一种弱代码系统:配置越来越长,逻辑越来越绕,校验能力有限,调试体验也很差。
配置更适合承接轻逻辑、轻变化。SPI 则承认有些复杂度本来就是代码级复杂度,它不应该伪装成配置。比如第三方扫描器的调用差异、结果转换、异常处理和兼容逻辑,本来就是代码级扩展,不太适合继续往配置里塞。
3. 和模板方法加策略模式比,我还需要一层扩展接入治理
模板方法加策略模式本身很好用,它通常适合主流程骨架固定、某几个步骤允许替换、且系统已经知道有哪些策略的场景。
它解决的是:已有变化,如何组织得更优雅。
但 SPI 更往前一步,它还在关心这些事情:
- 新实现怎么注册进系统
- 注册后怎么被统一发现
- 多个扩展冲突怎么办
- 默认实现是谁
- 后续新增扩展怎么持续接进来
所以在这个扫描任务里,我会更偏向 SPI。因为我面对的问题不是“已有几个策略怎么切换”,而是“未来还会持续长出新的扫描器实现,这些实现该怎么有边界地接进系统”。
从治理专项的视角看,SPI 解决了什么
如果只是站在“代码重构”的视角,SPI 的价值已经比较清楚了。但如果站在“复杂业务治理专项”的视角,它的价值还可以再说得更透一点。
1. 它先把变化点摆到了桌面上
过去复杂度藏在主流程里。现在复杂度被明确表达成:
- 哪些是扩展点
- 哪些是扩展实现
- 哪些是默认逻辑
- 哪些是场景特化
这一步看起来朴素,但很重要。因为很多治理动作的前提,就是你先得知道变化到底落在哪。
2. 它让增量需求接入开始有门禁
很多系统后面之所以越来越乱,根源在于增量没有门禁。这个扫描任务场景尤其明显,因为我们一开始就知道:未来还会继续来新的扫描器,只是不知道具体是谁。
如果系统已经有清晰的 SPI 机制,那么后续每来一个需求,都应该先回答:
- 这是扫描主流程的一部分,还是某个扫描器自己的实现
- 如果是扫描器特化,是不是应该接到已有扩展点
- 是否允许直接改主流程
- 新扩展会不会和现有扩展冲突
这时候 SPI 已经变成一种增量准入机制,远不止代码技巧这一层。
3. 它能把主流程从历史兼容堆栈里拉出来
对这个场景来说,最重要的一件事,就是让“扫描任务怎么跑”尽量稳定,而不是每接一个扫描器就重写一遍任务编排。主流程越稳定,回归成本越低,风险越可控,核心链路也越容易理解。
4. 它给后续治理留下了落点
一旦扩展逻辑被收敛进 SPI 体系,很多治理动作就有了落点:
- 启动时做重复匹配校验
- 统计每个扩展命中次数
- 监控默认实现命中率
- 输出扩展选择日志
- 标记长期不再命中的实现
- 做扩展下线和清理
如果这些逻辑继续散在主流程里,治理几乎只能靠人工经验硬啃。
SPI 不是银弹,它也有边界
这件事也得说清楚,不然文章很容易写成“SPI 什么都能解决”。
1. 不是所有分支都值得抽成 SPI
有些判断只是普通业务分支,变化频率不高,边界也不明显。这种场景硬抽 SPI,只会让代码更绕。如果系统里永远只会有一两种固定扫描器,而且接入变化也几乎不发生,那未必值得单独抽一整套扩展机制。
2. 不要把复杂度从主流程搬进 supports()
这是最常见的失败姿势。很多系统表面上做了 SPI,实际上每个 supports() 写得比原来的 if/else 还复杂。
结果只是换了个地方继续堆判断。
所以如果真要把 SPI 做好,至少要补几件事:
- 统一场景模型
- 简化匹配维度
- 明确默认实现
- 做启动期冲突校验
- 做日志和指标
3. SPI 解决不了业务建模本身混乱的问题
如果一个系统连扫描任务的状态流转都没理顺,任务上下文也没有统一模型,那上 SPI 只会把混乱包装得更好看一点。
SPI 解决的是如何隔离变化点。
业务模型本身的设计错误,它帮不上。
这两个层次必须分清楚。
最后落下来,SPI 解决的是“变化怎么进系统”
复杂业务的优化,说到底是在和“变化进入系统的方式”作斗争,跟代码行数多少关系不大。
最怕的情况是所有变化都可以直接侵入主流程:今天加一个扫描器特化,明天补一个结果转换兼容,后天再塞一个特殊任务兜底。久而久之,主流程就不再是主流程,而变成了历史兼容、新需求、临时方案的混合堆栈。
所以这次我更偏向 SPI,原因很实际:面对“第三方扫描器会持续接进来,而且事前并不知道会有哪些实现”这个现实问题时,它确实比其他方案更合适。
它的价值就是给了系统一条更克制的演进路径,跟名词高不高级没关系:
稳定部分留在主流程,变化部分进入扩展边界。