跳到正文

设计模式在主流框架中的应用:源码定位索引 ​

一句话:本篇不复述模式,只给「类 + 方法 + 调用链」的定位。判断一个框架用了什么模式,要看它在哪里把变化点抽成接口、由谁决定实现、调用怎么流转;类名里带 Factory 或 Builder 不算证据。

为什么需要 ​

「Spring AOP 用了代理和责任链」这类结论,如果指不到具体类和方法,就只是二手传闻。实际排查问题时,需要知道增强在哪一层织入、链在哪一步中断、工厂在哪个方法选择实现。源码定位的价值是把模式从名词变成可验证的调用路径。

另外,框架版本会改包名和方法签名。下面的坐标以 Spring Framework 6.x、MyBatis 3.5.x、JDK 17、Netty 4.1.x 为基准;换版本时先用 mvn dependency:tree 确认实际版本,再以对应源码为准。

核心机制 ​

读源码定位模式,按四步走:

  1. 找入口:从你调用的公开 API 进入,例如 applicationContext.getBean()、sqlSession.getMapper()、channel.pipeline().addLast()。
  2. 找接口:看入口依赖的是接口还是具体类;接口就是变化点。
  3. 找实现选择:搜索 implements 或工厂方法,看运行时如何选中某个实现。
  4. 跟调用链:至少跟两层,确认「谁调用谁」符合该模式的意图,而不是名字相似。

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#handleInternalDispatcherServlet 只依赖 HandlerAdapter,不关心 Controller 的调用方式
模板方法JdbcTemplate#execute(ConnectionCallback)、AbstractApplicationContext#refresh骨架在父类/模板类,可变步骤由回调或子类提供
观察者SimpleApplicationEventMulticaster#multicastEvent默认同步调用监听器;异常会回到发布方,除非配置错误处理器或 @Async
外观PlatformTransactionManager 接口及其实现对上层统一 getTransaction/commit/rollback,屏蔽 JDBC/JTA 差异
建造者UriComponentsBuilder、RestTemplateBuilder链式方法返回 this,最终 build() 产出不可变对象

MyBatis ​

模式类/方法看什么
动态代理MapperProxy#invokeMapper 接口没有实现类,方法名到 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、#releaseacquire 是 final 骨架,tryAcquire 留给子类
策略Comparator#compare排序算法固定,比较规则由调用方传入
单例Runtime#getRuntime饿汉式,JVM 启动即创建
享元Integer#valueOf、字符串常量池Integer 默认缓存 -128..127,可用 -XX:AutoBoxCacheMax 调整上限
命令Runnable、Callable把任务封装成对象,交给线程池或队列执行
迭代器Iterator#hasNext/#next遍历集合而不暴露内部结构

Netty ​

模式类/方法看什么
责任链(双向)DefaultChannelPipeline#addLast、AbstractChannelHandlerContext#fireChannelReadInbound 从 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、MyBatis Interceptor 等公开扩展点。
  • 只看一个方法就下结论:责任链要看组装处(谁把 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

把一门技术讲透到既懂也会用