decoupling

15. 解耦:从 IoC、SPI 到控制面与数据面

0. 本章先解决什么问题

先给耦合下一个可操作的定义,不然解耦就是一句口号:

两个东西耦合 = 一处变动,另一处被迫跟着变、跟着等、或者跟着挂。
跟着变: 换个支付实现,订单代码要改重编(编译期耦合)
跟着等: 下单必须等短信发完才返回(时间耦合)
跟着挂: 积分服务宕机,下单跟着失败(可用性耦合)

解耦不消灭依赖(没有依赖就没有协作),它把”依赖具体的谁、依赖它此刻在场”改成”依赖一个稳定的中间物”。按中间物的不同,前面的章节其实已经讲过谱系的一半:

  • 位置解耦:中间物是翻译表(第 8 章间接层整章)
  • 时间与速率解耦:中间物是队列和缓冲(第 8 章 MQ 节、第 3 章背压)

本章补齐另一半,全是 Java 后端天天摸但系列还没拆开的:依赖方向解耦(IoC/DI)、扩展点解耦(SPI)、变化通知解耦(事件与观察者),最后把散在各章的职责解耦收拢成家族,并算清解耦的代价。

解耦的谱系

这张图怎么读

左边是耦合的三种”被迫”:跟着变、跟着等、跟着挂。中间是五类中间物:翻译表、队列、容器、配置文件、事件总线。右边标注每类对应的章节和本章小节。

1. 依赖方向解耦:IoC 和 DI

1.1 耦合现场:new 的三宗罪

代码块JAVA · 3 行收起展开
public class OrderService {
    private PaymentService payment = new AlipayPaymentImpl(); // 病灶在这行
}
  • 罪一:换实现要改源码。切到微信支付,所有 new 的地方全改一遍
  • 罪二:测试换不掉。单元测试想用假的支付实现,源码里写死了真的
  • 罪三:生命周期各管各。每个使用方 new 自己的实例,
    想共享一个连接池般的单例,得自己手写单例模式再到处引用
  • 根因:使用方同时干了两件事,“用这个能力”和”决定用谁、何时创建”

1.2 控制反转:创建权上交,注入怎么发生

IoC 把第二件事收归容器:对象只声明”我需要一个 PaymentService”,具体给哪个实现、什么时候创建、是不是单例,容器决定。DI(依赖注入)是实现手段,Spring 的完整流程拆开:

  1. 扫描: 组件扫描找到 @Service/@Component 类,
    每个类登记成一条 BeanDefinition(类名、作用域、依赖描述),
    注意此时只有”图纸”,没有对象
  2. 实例化: 按图纸反射调构造器造出裸对象
  3. 属性填充: 处理 @Autowired。机制就是反射:
    找到标注的字段 -> 按类型在容器里找候选 Bean
    (同类型多个再按名字、@Qualifier 裁决)
    -> field.setAccessible(true) -> field.set(bean, 依赖)
    私有字段照样注入,靠的就是这两行反射
  4. 初始化: 执行 @PostConstruct,然后过一遍 BeanPostProcessor 链,
    AOP 就在这一步把原对象包成代理返回
    (第 8 章的动态代理在容器流水线里的准确位置)

使用方从”依赖具体实现”变成”依赖接口 + 容器装配”,三宗罪逐条解掉:换实现改配置或注解,测试注入假实现,单例由容器统一管理。
容器启动的完整源码线在 IOC-refreshBean创建 - doCreateBean

1.3 循环依赖:注入机制自己生的病

A 依赖 B、B 又依赖 A,按 1.2 的流程走会死锁:A 填充属性时要 B,B 填充属性时要还没做完的 A。Spring 用三级缓存把环拆开:

一级 singletonObjects: 成品 Bean
二级 earlySingletonObjects: 提前曝光的半成品(实例化了还没填充)
三级 singletonFactories: 能产出半成品引用的工厂函数
流程: A 实例化后先把自己的工厂放进三级缓存 -> 填充时发现要 B
-> 转去创建 B -> B 填充时要 A,从三级缓存拿到 A 的半成品引用
-> B 完工入一级 -> 回头把 B 注给 A,A 完工
为什么要第三级而两级不够: AOP 场景下曝光的必须是代理对象,
工厂函数把”要不要提前生成代理”的判断推迟到真被循环引用时才做
边界: 构造器注入解不了循环依赖(实例化本身就需要对方,
连”先造个半成品”的机会都没有),报错反而是好事,
循环依赖本身就是设计出味的信号

细节在 循环依赖 - 三级缓存

1.4 一句收拢:依赖倒置

IoC 的思想内核是依赖方向的反转:高层业务和低层实现互相都改成依赖中间的接口,接口由业务方定义(我需要什么能力),实现方来适配。变化被接口挡住,两侧各自演化。

2. 扩展点解耦:SPI

2.1 JDBC 案例:Class.forName 为什么消失了

老代码连数据库前要写 Class.forName("com.mysql.jdbc.Driver"),后来不写也能用。背后是 JDK 的 SPI(Service Provider Interface)机制:

  • 约定:实现方在自己 jar 包里放一个文本文件
    路径: META-INF/services/java.sql.Driver(文件名 = 接口全名)
    内容: com.mysql.cj.jdbc.Driver(一行一个实现类全名)
  • 加载:DriverManager 初始化时执行
    ServiceLoader.load(Driver.class)
    -> 扫 classpath 上所有 jar 的这个文件
    -> 逐行反射实例化,驱动的静态逻辑把自己注册进 DriverManager
  • 效果:JDK 定义接口,MySQL 的 jar 放进 classpath 就自动生效,
    JDK 一行代码不认识 MySQL。框架和实现的发布彻底分离

2.2 JDK SPI 的病和 Spring 的改良

病: ServiceLoader 只会”全量顺序加载”,
不能按名字取某一个、不能控制顺序、不能按条件跳过
Spring 的改良版就是 SpringBoot 自动配置的根:
实现方(各 starter)在 META-INF 下登记自动配置类清单
(老版 spring.factories,新版 AutoConfiguration.imports)
启动时读清单,但每个配置类还要过条件注解的筛子:

代码块JAVA · 2 行收起展开
   @ConditionalOnClass(classpath 有这个类才生效)
   @ConditionalOnMissingBean(用户没自己定义才给默认的)

-> “引入 starter 即自动装配、用户一定义即让位”的体验,
全是 SPI 加条件筛选拼出来的

启动源码线在 SpringBoot - 启动与自动配置

2.3 门面:编译期只认接口的极致形态

SLF4J 是日志界的 JDBC:业务代码只依赖 slf4j-api 的接口,运行时 classpath 里放 logback 还是 log4j2,由各实现自带的绑定机制接管。换日志框架 = 换一个 jar,零行代码改动。判据同 SPI:调用方和实现方的发布节奏、选型权彻底分离。

3. 变化通知解耦:事件与观察者

3.1 进程内:Spring 事件机制

耦合现场:下单方法里挨个调积分、短信、仓储,每加一个下游改一次下单代码,下单的耗时和可用性被全体下游绑架。事件化:

发布方: publisher.publishEvent(new OrderCreatedEvent(orderId))
发完就走,不知道谁在听、有几个在听
监听方: @EventListener public void onCreated(OrderCreatedEvent e)
容器启动时登记,事件到达时被逐个回调
新增下游 = 新增一个监听器类,下单代码零改动
两个必须知道的默认行为:
默认同步: 监听器在发布者的线程里、同一个事务里挨个执行,
一个监听器抛异常,发布方的事务跟着回滚
(想真异步要 @Async,那又回到第 9 章线程池的地盘)

3.2 事务边界:事件解耦最经典的坑

  • :在事务方法里发事件,监听器立刻执行:
    监听器去查库读不到还没提交的订单(同事务能读到,
    但若监听器标了 @Async 或自己开新事务就读不到了);
    更糟的是发布方后续回滚了,监听器的短信已经发出去
  • :@TransactionalEventListener(phase = AFTER_COMMIT)
    事件登记后压着不跑,等发布方事务真提交了才回调
    -> “变化通知”和”变化生效”对齐了

3.3 跨进程:MQ 把观察者搬出进程

进程内事件解决”改代码”的耦合,解决不了”跟着挂”和”跟着等”:监听器还在同一个进程里消耗同一份资源。
MQ 把事件搬到进程外(第 8 章讲过它同时占间接层和缓冲两个思想的便宜),换来的三条解耦已在那章展开,这里只补事件视角的账单:流程从代码里消失,变成”翻遍所有消费组才知道下单后会发生什么”;投递是至少一次,消费端幂等是必修(第 13 章那条线索第三次出现)。

4. 职责解耦家族:散在各章的收拢

前面各章反复出现”把一件事切成两个角色”的手法,收拢成四对:

接收与处理分离:
MySQL 从库 relay log(第 5 章: 收日志和放日志互不拖累)
Tomcat 的 Poller 与 Executor、Kafka 的 network 与 io 线程组
(第 1 章: 等待便宜处理贵,分开各自定容量)
MQ 削峰(收单速率和处理速率脱钩)
读与写分离:
MySQL 读写分离(第 5 章)、MVCC 读不阻塞写(第 10 章)
JUC 的 ReentrantReadWriteLock: 读锁共享写锁独占,
读多写少时吞吐远超独占锁;写线程可能被连续读饿死,
StampedLock 的乐观读又把”读连锁都不加”推进一步
控制面与数据面分离:
NGINX master 管配置 worker 扛流量(第 1 章)
哨兵管切换、主从扛读写(第 6 章)
Kafka controller 管元数据、broker 数据路径扛消息(第 6 章)
规律: 控制面低频、复杂、允许慢;数据面高频、简单、必须快。
混在一起时复杂逻辑会拖累热路径,切开后各按各的 SLA 演化
逻辑与物理分离:
逻辑删除与后台回收(第 12 章 lazy free、purge)
逻辑过期(第 3 章缓存击穿的方案二)
槽位与节点(第 7 章: 逻辑编号稳定,物理归属漂移)

四对的共同判据:两侧的变化频率、性能要求或负责团队不同,就值得切;两侧总是同时变、同节奏跑,切开只是白付账单。

5. 解耦的代价:防止解耦教

每次解耦都引入一个中间物(接口、容器、事件、队列、配置文件),账单固定五条:

  1. 链路隐形: 直调时序一眼看穿;容器装配的 Bean 从哪来、
    事件有谁在听、SPI 加载了哪些实现,全要靠工具和全局搜索还原
  2. 排障跳转: 断点从”下一行”变成”跨代理、跨线程、跨进程找下一站”
  3. 过度抽象: 只有一个实现还硬套接口加工厂,
    变化从没发生,中间物白养着(YAGNI 说的就是它)
  4. 一致性弱化: 同步直调的强一致,换成事件和 MQ 就是最终一致,
    业务要能接受中间态
  5. 性能税: 反射、代理、序列化、多一跳网络,每层都收一点

判据收成一句:预期的变化真实存在(换实现、加订阅方、扩容独立演化),中间物才值得养;解耦是应对变化的保险,不是代码的美德本身。

6. 联系实际:解耦机制的排查清单

  • 现象:@Transactional/@Async/@Cacheable 不生效

  • 方向:自调用绕过了代理(第 8 章的病根),或类不是容器管的 Bean

  • 现象:事件监听器读不到刚写的数据,或回滚后副作用已发生

  • 方向:事务边界,换 @TransactionalEventListener(AFTER_COMMIT)

  • 现象:自定义 SPI 实现死活不加载

  • 方向:META-INF/services 下文件名要是接口全名、内容是实现全名,
    拼写和打包路径逐字检查;Spring 体系查 imports 文件名的版本差异

  • 现象:启动报循环依赖错误

  • 方向:构造器注入的环无解,先当设计信号重新拆分职责;
    实在要过渡,字段注入或 @Lazy 打断环

  • 现象:引入 starter 后自定义配置不生效或生效了不该有的默认

  • 方向:条件注解的装配顺序,查 @ConditionalOnMissingBean
    是否被你的 Bean 定义时机影响

  • 现象:读写锁场景写线程长期抢不到

  • 方向:读锁连续到达的饥饿问题,评估 StampedLock 或公平策略

7. 学完本章你能解决什么问题

  1. 耦合的三种”被迫”各对应什么解耦手段?
  2. @Autowired 注入的机制细节是什么,AOP 代理在流水线的哪一步介入?
  3. 三级缓存各存什么,为什么两级不够,构造器注入为什么无解?
  4. Class.forName 为什么消失了,META-INF/services 的文件约定是什么?
  5. SpringBoot 自动配置怎么用 SPI 加条件注解拼出来?
  6. Spring 事件的默认同步语义有什么坑,AFTER_COMMIT 修的是什么?
  7. 四对职责解耦各自的判据是什么,控制面和数据面的规律是什么?
  8. 解耦的五条账单是什么,什么时候应该拒绝解耦?

核心一句话:解耦是把”依赖具体的谁、依赖它此刻在场”换成”依赖稳定的中间物”,中间物有五种(翻译表、队列、容器、配置约定、事件总线);切不切看两侧的变化频率和节奏是否真的不同,解耦是保险不是美德。

延伸阅读