主题
设计模式在主流框架中的应用:源码定位索引
一句话:本篇不复述模式,只给「类 + 方法 + 调用链」的定位。判断一个框架用了什么模式,要看它在哪里把变化点抽成接口、由谁决定实现、调用怎么流转;类名里带
Factory或Builder不算证据。
为什么需要
「Spring AOP 用了代理和责任链」这类结论,如果指不到具体类和方法,就只是二手传闻。实际排查问题时,需要知道增强在哪一层织入、链在哪一步中断、工厂在哪个方法选择实现。源码定位的价值是把模式从名词变成可验证的调用路径。
另外,框架版本会改包名和方法签名。下面的坐标以 Spring Framework 6.x、MyBatis 3.5.x、JDK 17、Netty 4.1.x 为基准;换版本时先用 mvn dependency:tree 确认实际版本,再以对应源码为准。
核心机制
读源码定位模式,按四步走:
- 找入口:从你调用的公开 API 进入,例如
applicationContext.getBean()、sqlSession.getMapper()、channel.pipeline().addLast()。 - 找接口:看入口依赖的是接口还是具体类;接口就是变化点。
- 找实现选择:搜索
implements或工厂方法,看运行时如何选中某个实现。 - 跟调用链:至少跟两层,确认「谁调用谁」符合该模式的意图,而不是名字相似。
Spring
| 模式 | 类/方法 | 看什么 |
|---|---|---|
| 单例注册表 | DefaultSingletonBeanRegistry#getSingleton(String, boolean)、字段 singletonObjects | 容器级单例不是 GoF 单例:同一 Class 可有多个 BeanDefinition,按名字各存一份 |
| 工厂方法 | FactoryBean#getObject()、AbstractBeanFactory#doGetBean | @Autowired 拿到的是 getObject() 的返回值;取工厂本身要用 &beanName |
| 代理 | JdkDynamicAopProxy#invoke、CglibAopProxy#getProxy | 有接口且未强制 CGLIB 时走 JDK 代理;Boot 2.x 起 AOP 自动配置默认 proxy-target-class=true |
| 责任链 | ReflectiveMethodInvocation#proceed、HandlerExecutionChain#applyPreHandle | 切面通知按顺序调用 proceed();拦截器 preHandle 返回 false 即中断 |
| 适配器 | HandlerAdapter#handle、RequestMappingHandlerAdapter#handleInternal | DispatcherServlet 只依赖 HandlerAdapter,不关心 Controller 的调用方式 |
| 模板方法 | JdbcTemplate#execute(ConnectionCallback)、AbstractApplicationContext#refresh | 骨架在父类/模板类,可变步骤由回调或子类提供 |
| 观察者 | SimpleApplicationEventMulticaster#multicastEvent | 默认同步调用监听器;异常会回到发布方,除非配置错误处理器或 @Async |
| 外观 | PlatformTransactionManager 接口及其实现 | 对上层统一 getTransaction/commit/rollback,屏蔽 JDBC/JTA 差异 |
| 建造者 | UriComponentsBuilder、RestTemplateBuilder | 链式方法返回 this,最终 build() 产出不可变对象 |
MyBatis
| 模式 | 类/方法 | 看什么 |
|---|---|---|
| 动态代理 | MapperProxy#invoke | Mapper 接口没有实现类,方法名到 SQL 的映射在 invoke 里完成 |
| 工厂方法 | MapperProxyFactory#newInstance、Configuration#newExecutor | 按 ExecutorType 选择执行器 |
| 责任链 + 代理嵌套 | InterceptorChain#pluginAll、Plugin#wrap、Plugin#invoke | 每个插件包一层代理,不需要 Handler 手动调 next;与 Servlet Filter 的显式遍历不同 |
| 组合 | MixedSqlNode#apply、IfSqlNode#apply | 动态 SQL 的标签是一棵树,叶子和容器都实现 SqlNode |
| 装饰器 | org.apache.ibatis.cache.decorators.*:LruCache、LoggingCache、BlockingCache、TransactionalCache | 二级缓存按需嵌套,每层只加一个特性 |
| 模板方法 | BaseExecutor#query | 固定「查缓存 → 查库 → 清缓存」,具体执行由子类实现 |
| 建造者 | SqlSessionFactoryBuilder#build | 分步解析 XML,最终产出 SqlSessionFactory |
JDK
| 模式 | 类/方法 | 看什么 |
|---|---|---|
| 动态代理 | java.lang.reflect.Proxy#newProxyInstance | 运行时生成实现指定接口的类,方法调用进入 InvocationHandler |
| 装饰器 | BufferedInputStream、DataInputStream、InputStreamReader | 同接口嵌套包装;InputStreamReader 同时是字节到字符的适配器 |
| 模板方法 | AbstractQueuedSynchronizer#acquire、#release | acquire 是 final 骨架,tryAcquire 留给子类 |
| 策略 | Comparator#compare | 排序算法固定,比较规则由调用方传入 |
| 单例 | Runtime#getRuntime | 饿汉式,JVM 启动即创建 |
| 享元 | Integer#valueOf、字符串常量池 | Integer 默认缓存 -128..127,可用 -XX:AutoBoxCacheMax 调整上限 |
| 命令 | Runnable、Callable | 把任务封装成对象,交给线程池或队列执行 |
| 迭代器 | Iterator#hasNext/#next | 遍历集合而不暴露内部结构 |
Netty
| 模式 | 类/方法 | 看什么 |
|---|---|---|
| 责任链(双向) | DefaultChannelPipeline#addLast、AbstractChannelHandlerContext#fireChannelRead | Inbound 从 head 到 tail,Outbound 从 tail 到 head |
| 建造者 | ServerBootstrap#group/channel/childHandler/bind | 链式配置,bind() 才真正启动 |
| 命令 | AbstractChannelHandlerContext#write 内部的 WriteTask | 写操作封装后放入 EventLoop 队列,由 EventLoop 线程执行 |
| 池化/享元 | PooledByteBufAllocator | 复用内存块,减少分配和 GC;注意引用计数与释放 |
实战场景
一、自定义注解在 Spring 里不生效:先找代理,再找后置处理器
现象:团队给方法加 @RateLimit 做接口级限流,用 BeanPostProcessor 生成代理。上线后发现:一部分标注生效,另一部分完全不拦截;被拦截的那部分,同类内部调用不生效;还有个服务启动直接报 BeanNotOfRequiredTypeException,因为注入的是接口而调用方按实现类注入。
排查:判断框架用了什么模式,要看变化点在哪里被抽成接口、由谁决定实现、调用怎么流转。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 是否生成代理 | 注入对象的 getClass().getName() 是否含 $Proxy/$$SpringCGLIB$$ | 无代理 → 增强没发生 |
| 代理类型 | 目标类是否实现接口;proxyTargetClass 取值 | 有接口默认 JDK 动态代理,按实现类注入就失败 |
| 后置处理器时机 | 自定义 BeanPostProcessor 是否实现 PriorityOrdered/Ordered | 未排序时会被其他处理器提前“定型”,或被自动代理创建器覆盖 |
| 注解扫描范围 | @RateLimit 所在类是否被容器管理 | 手工 new 的对象不经过任何处理器 |
| 调用路径 | 生效的是外部调用还是同类自调用 | 自调用绕过代理,与 @Transactional 同一机制 |
| 方法可见性 | private/final/static 方法上的注解 | CGLIB 无法拦截,等于没写 |
本例两处命中:处理器没有实现 Ordered,注册顺序在 AnnotationAwareAspectJAutoProxyCreator 之后,被覆盖掉一层代理;另一批服务因目标类有接口、生成 JDK 代理却按实现类注入。
对症做法:
java
// 1. 明确顺序,避免与其它 BPP 互相覆盖
@Component
class RateLimitBeanPostProcessor implements BeanPostProcessor, PriorityOrdered {
public int getOrder() { return LOWEST_PRECEDENCE - 10; } // 在自动代理创建器之后收尾
public Object postProcessAfterInitialization(Object bean, String name) {
if (!hasAnnotatedMethod(bean.getClass())) return bean; // 不相关的直接返回
return ProxyFactory.getProxy(bean); // 由 Spring 决定 JDK/CGLIB
}
}
// 2. 统一按接口注入,或显式要求 CGLIB
@EnableAspectJAutoProxy(proxyTargetClass = true)java
// 3. 自调用要生效就走代理,或拆到另一个 Bean
@Resource @Lazy private SelfService self; // 注入自身代理,避免 this 调用text
4. 别自己造代理:能用 AspectJ 切面表达就用切面,框架已处理代理类型与顺序;
自定义创建器只在需要替换整个调用链语义时使用。复盘要点:验收是两条断言:注入对象确实是代理(AopUtils.isAopProxy),且自调用/非 public/final 方法有明确结论(要么不支持并写进规范,要么改结构)。这些机制与 Spring IoC 容器原理 里“postProcessAfterInitialization 是代理生成点”是同一事实的两面——排障时先确认代理是否存在,比确认注解是否拼错更有效。
二、Comparator 不满足传递性:TimSort 抛异常
现象:订单列表按“优先级 + 创建时间”排序,代码写成 Comparator.comparingInt(o -> weight(o)).thenComparing(Instant::now) 之类形式,其中 weight(o) 内部会读缓存并可能返回不同值。灰度后部分请求报 IllegalArgumentException: Comparison method violates its general contract!,且只在数据量超过 32 条时出现(JDK TimSort 的触发条件,经验值需按版本核实)。
排查:比较器是一种策略,它的契约比算法更重要。
| 检查项 | 取证据 | 判定 |
|---|---|---|
| 反对称性 | 是否存在 a<b 但 b<a 也可能成立 | 依赖外部可变状态即不成立 |
| 传递性 | 分段权重(如“超时=3、普通=1”)能否构造 a>b>c 但 a<c | 分档 + 次级条件常被写坏 |
| 一致性 | 同一对象两次比较返回不同值(读缓存/时钟/远程) | 排序期间状态变化即破坏契约 |
| 相等处理 | 返回 0 时是否真的等价(equals 一致) | 不等价会丢排序稳定性语义 |
| 触发条件 | 数组长度是否超过 TimSort 的检查阈值 | 小数据不报错,容易漏测 |
对症做法:
java
// 1. 比较前先物化排序键,比较过程不读外部状态
record SortKey(int priority, Instant createdAt, long id) {}
List<SortKey> keys = orders.stream().map(o -> new SortKey(o.prioritySnapshot(), o.createdAt(), o.id())).toList();
// 快照来自请求上下文,排序期间不再变化
// 2. 组合式比较器,保证传递性:主键 → 次键 → 唯一键兜底
Comparator<SortKey> cmp = Comparator.comparingInt(SortKey::priority)
.thenComparing(SortKey::createdAt)
.thenComparingLong(SortKey::id); // 最后用唯一键,避免“相等但不同”的模糊区
// 3. 用属性测试验证契约,而不是只测正常样本
@Test void comparatorSatisfiesContract() {
List<SortKey> sample = randomKeys(500);
assertThatCode(() -> sample.sort(cmp)).doesNotThrowAnyException();
for (int i = 0; i < sample.size(); i++)
for (int j = i + 1; j < sample.size(); j++)
assertThat(Integer.signum(cmp.compare(sample.get(i), sample.get(j)))
+ Integer.signum(cmp.compare(sample.get(j), sample.get(i)))).isZero(); // 反对称
}text
4. 需要“按当前权重动态排序”的需求应改成分组 + 组内排序,
而不是让一个不稳定的权重进入比较器。复盘要点:判据是“比较过程不读外部可变状态”+ 一条契约测试(反对称、传递、相等一致)。JDK 的排序对坏比较器不一定报错——小数组用插入排序根本不检查,这也是它只在数据量大时暴露的原因;把契约测试写进 CI 才能挡住这类隐患。
三、Executors 工厂的默认值:无界队列把 OOM 藏起来
现象:批量任务用 Executors.newFixedThreadPool(16)。上游一次投递 20 万条任务后,堆使用率快速上升到 95%,Full GC 频繁,最终 OutOfMemoryError: Java heap space。线程数看起来正常(16),CPU 也不高,排查时先怀疑的是内存泄漏而不是任务提交。
排查:工厂方法把创建细节收在一处,也把默认值一起藏了进去。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 实际创建参数 | 读源码或 dump:newFixedThreadPool → LinkedBlockingQueue 无界(Integer.MAX_VALUE) | 无界 → 提交速度不受容量约束 |
| 队列长度 | getQueue().size() 指标 | 持续增长且不降即积压 |
| 线程是否够用 | active/池大小、任务平均耗时 | 处理能力不足让队列堆积更快 |
| 拒绝策略 | 无界队列下饱和策略永不触发 | “该失败时不失败”比失败更危险 |
| 单任务内存 | 平均任务对象大小 × 队列长度 | 与堆使用率曲线一致即确认来源 |
| 其他工厂 | newCachedThreadPool → 线程数 Integer.MAX_VALUE | 另一方向的同类风险:线程爆涨 |
对症做法:
java
// 显式创建线程池:容量、队列上限、拒绝策略都写在调用点可见的位置
ThreadPoolExecutor biz = new ThreadPoolExecutor(
16, 32, 60, SECONDS,
new ArrayBlockingQueue<>(2000), // 有界,积压可预测
new ThreadFactoryBuilder().setNameFormat("biz-%d").setDaemon(true).build(),
new ThreadPoolExecutor.AbortPolicy()); // 满了就失败,让上游退避
// 提交侧要有背压:任务被拒绝时不丢,转入持久队列或返回“处理中”
try { biz.submit(task); }
catch (RejectedExecutionException e) { backlog.persist(task); metrics.rejected("biz"); }
// 指标暴露:active / queue.size / completed / rejected,四个数决定容量结论
Gauge.builder("pool.queue", biz, p -> p.getQueue().size()).register(registry);
Counter.builder("pool.rejected", biz, p -> (double) rejectedCount.get()).register(registry);text
如果确需统一创建入口(工厂的初衷),就把默认值写死成安全值而不是照搬 JDK 默认:
ThreadPoolFactory.newBounded(name, core, max, queueSize) —— 队列必须有上限,
并由工厂统一注册指标与线程名前缀。复盘要点:判据是“不存在未设队列上限的 Executors.* 调用”,这条可以静态检查(禁止直接调用 Executors.new*,只允许工厂方法)。工厂模式的价值是把创建逻辑收到一处,但一处收口同时意味着一处默认值影响全局——这也是本专栏反复出现的主题:模式买来的是可控性,代价是默认值与间接层的责任归属,见 创建型模式。
最小上手
下载源码并直接看关键类(版本替换成项目实际版本):
bash
mvn -q dependency:get -Dartifact=org.springframework:spring-aop:6.1.14:jar:sources
unzip -p ~/.m2/repository/org/springframework/spring-aop/6.1.14/spring-aop-6.1.14-sources.jar \
org/springframework/aop/framework/JdkDynamicAopProxy.java | sed -n '1,140p'bash
mvn -q dependency:get -Dartifact=org.mybatis:mybatis:3.5.19:jar:sources
unzip -p ~/.m2/repository/org/mybatis/mybatis/3.5.19/mybatis-3.5.19-sources.jar \
org/apache/ibatis/plugin/InterceptorChain.java验证 JDK 代理确实生成了类文件:
bash
java -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true ProxyDemo
find . -name '$Proxy*.class'能跑出 $Proxy0.class,说明代理类是在运行时生成的;如果项目里找不到这个类,就不是编译期织入。
生产动作:误用与代价
- 从类名推断模式:
XxxFactory可能只是静态工具类,XxxBuilder可能没有build()和不变量校验。证据:看方法是否返回this、是否集中校验、是否隐藏具体实现;至少跟一层调用链。 - 源码版本与运行版本不一致:
mvn dependency:tree -Dincludes=org.springframework:spring-aop确认版本;源码 jar 和运行时 jar 不一致,结论可能相反。 - 代理隐藏了真实调用:断点停在代理类,堆栈里出现
$Proxy或CglibAopProxy;用AopUtils.isAopProxy(bean)、Advised#getTargetSource看目标和通知链,不要直接改目标类猜测。 - 把内部 API 当扩展点:
DefaultSingletonBeanRegistry、Configuration这类内部类不是稳定契约;扩展优先用BeanPostProcessor、FactoryBean、MyBatisInterceptor等公开扩展点。 - 只看一个方法就下结论:责任链要看组装处(谁把 Handler 排成链),工厂要看选择处(谁决定实现),模板方法要看骨架方法是否
final、可变步骤是否抽象。
边界
- 源码定位用于验证,不替代官方文档:内部实现可能随小版本变化,公开行为以文档和测试为准。
- 不要为了「用上模式」去改框架扩展点:能用配置解决就不要写
BeanPostProcessor;能用一个Interceptor就不要改执行器。 - 版本差异要标注:Spring 6、Boot 3、JDK 17+ 的代理默认值和模块限制与旧版本不同,跨版本比较前先对齐环境。
- 模式不是框架设计的全部:很多类是为了性能、并发或兼容性,不是模式教科书案例;不要过度解释。
讲清楚
验证框架用了什么模式,方法是从公开入口进、找接口、找实现选择、跟两层调用链。Spring 里看
DefaultSingletonBeanRegistry的容器单例、FactoryBean#getObject的工厂方法、JdkDynamicAopProxy#invoke和CglibAopProxy#getProxy的代理选择、ReflectiveMethodInvocation#proceed的切面链、HandlerAdapter#handle的适配。MyBatis 看MapperProxy#invoke、InterceptorChain#pluginAll的代理嵌套、MixedSqlNode#apply的组合树、cache.decorators的装饰链。JDK 看Proxy#newProxyInstance、AQS#acquire、Comparator。Netty 看DefaultChannelPipeline的双向传播和WriteTask的命令封装。所有结论都要能指到类和方法,并且和项目实际版本对齐。
初稿 2025-06 | 最后修订 2026-10