我们有一类扫描任务。扫描任务本身的主流程不算复杂:拿到任务、准备上下文、调用扫描能力、回收结果、落库。

麻烦在后面。这个扫描能力不是永远固定的一套,后面是要接第三方扫描器的。而且“能接第三方”本身就不是一个附带需求,它就是这套系统很重要的产品方向之一。我们天然就希望它是开放的,希望后面可以不断接新的扫描器进来。

问题也因此变得很具体。

如果只是今天接一个扫描器,其实并不难。最顺手的写法,无非就是在主流程里加一个判断:

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 够知道就行,重点还是业务怎么落

这里如果展开讲 ServiceLoaderMETA-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,原因很实际:面对“第三方扫描器会持续接进来,而且事前并不知道会有哪些实现”这个现实问题时,它确实比其他方案更合适。

它的价值就是给了系统一条更克制的演进路径,跟名词高不高级没关系:

稳定部分留在主流程,变化部分进入扩展边界。