读懂一个系统的设计:先模型,再接口,最后看实现
300 次
KafkaProducer.send()在 8.2ms 内全部返回,这时一条消息都还没发出去;第一个回调在第 515ms 才到达,300 条消息只产生了一个网络请求。直接从send()的源码读起,很容易迷失在锁、内存池和重试里;先知道 Producer 的模型是「记录攒成批次、按分区发送」,这些现象和代码就都有了位置。
阅读 Spring、Kafka、JDK 这类成熟系统,是学习设计最有效的方式之一,但很多人读源码的方式是从入口方法一路 debug 下去。本文给出一个固定的阅读顺序,并用三个小切片(Spring 容器、Kafka Producer、JDK Stream)演示,每个切片都配一个可以直接运行的实验。实验环境:JDK 21.0.5、Spring Framework 7.0.9、kafka-clients 4.3.1(Broker 4.3.1)。
一、先说结论
- 阅读顺序是模型、接口、实现。之前学习的一些教程里把它概括成一句话:「了解设计,先模型,再接口,最后是实现。」模型决定了接口为什么长成这样,接口决定了实现要解决哪些问题。
- 模型是系统提供的抽象:Spring 容器的模型是「Bean 定义和依赖关系」,Kafka Producer 的模型是「记录、批次、分区」,Stream 的模型是「惰性流水线」。
- 接口不只是 Java
interface:注解、配置项、命令行、HTTP API、DSL 都是接口,它们是用户接触模型的方式。 - 带着问题读实现:先从模型和接口里提出问题(「send 返回时消息在哪里」),再去实现里找答案,而不是逐行通读。
- 这个方法可以递归使用:实现里出现的组件,本身又有自己的模型、接口和实现。
二、为什么先看模型
直接读实现的问题在于,实现里混着太多与设计核心无关的代码:兼容旧版本的分支、异常处理、性能优化、监控埋点。不知道模型的时候,没法判断哪些代码是关键。
先理解模型,相当于先拿到一张地图:
| 阅读层次 | 要回答的问题 | 典型材料 |
|---|---|---|
| 模型 | 这个系统提供了什么抽象?核心对象有哪些,它们之间是什么关系? | 官方文档的概念章节、设计文档、论文 |
| 接口 | 用户怎么使用这个抽象?有哪些稳定的操作和配置? | API 文档、配置参考、示例代码 |
| 实现 | 模型是怎么落地的?依赖了哪些运行时或硬件假设? | 源码、测试、提交记录 |
下面三个切片都按同样的四个问题展开:核心对象是什么、稳定的接口是什么、哪部分实现值得继续追、实现依赖哪些假设。
三、切片一:Spring 容器
3.1 模型:Bean 定义和依赖关系
Spring 容器的核心对象不是 Bean 实例,而是 Bean 定义(BeanDefinition):一个 Bean 用哪个类创建、作用域是什么、依赖哪些其他 Bean、是否懒加载。容器先收集所有定义,再根据依赖关系决定创建顺序。
这个模型带来了一种新的编程方式:对象不再由调用方 new 出来,而是由容器根据定义组装。依赖注入、AOP 代理、生命周期回调,都是在这个模型上叠加的能力。
3.2 接口:注册定义、获取 Bean、扩展点
用户接触这个模型的方式有好几种:@Component 和 @Bean 注解、registerBean 这样的编程式 API、getBean 获取实例,以及修改定义的扩展点 BeanFactoryPostProcessor。
3.3 实验:先有定义,后有对象
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext();
ctx.registerBean(Service.class); // 先登记 Service:它依赖 Repo
ctx.registerBean(Repo.class);
ctx.registerBean(Report.class, bd -> bd.setLazyInit(true));
ctx.addBeanFactoryPostProcessor(bf -> {
// 此时只有定义,没有实例
bf.getBeanDefinition(bf.getBeanNamesForType(Repo.class)[0]).setDescription("由后置处理器修改过");
});
ctx.refresh();输出(节选):
refresh() 之前:还没有任何 Bean 实例
BeanFactoryPostProcessor:已有 7 个 Bean 定义
springSlice.Service 作用域=singleton 懒加载=false 已实例化=false
springSlice.Repo 作用域=singleton 懒加载=false 已实例化=false
springSlice.Report 作用域=singleton 懒加载=true 已实例化=false
→ 创建 Repo
→ 创建 Service,注入 Repo
refresh() 之后:Report 已实例化?false
→ 创建 Report(懒加载)从输出能看出模型的三个特征:定义阶段一个实例都没有(7 个定义里有 4 个是容器自己的基础设施);Service 先登记,但容器按依赖关系先创建了 Repo;懒加载的 Report 直到第一次获取才创建。后置处理器修改的是定义,这是容器最重要的扩展点之一。
3.4 值得继续追的实现
有了模型之后,实现里值得看的地方就很明确了:DefaultListableBeanFactory 怎样保存定义、preInstantiateSingletons 怎样按依赖创建单例、BeanPostProcessor 在哪个阶段介入。AOP 代理就是在这个阶段替换掉原始对象的,代理失效的各种情况见 Spring AOP 为什么会失效。
四、切片二:Kafka Producer
4.1 模型:记录、批次、分区
Producer 的模型是:应用产生一条条记录,Producer 按目标分区把它们攒成批次,再把发往同一个 Broker 的批次合成一个网络请求。这个模型是为吞吐设计的:网络往返的成本被一批记录分摊。
4.2 接口:send、flush、回调
对应的接口很少:send() 把记录交给 Producer 并立即返回一个 Future;回调在 Broker 确认后执行;flush() 等待已经交出的记录全部发送完成。配置项 linger.ms 和 batch.size 控制攒批的时间和大小。
模型决定了接口的语义:send() 返回,只代表记录进了批次,不代表发出去了。
4.3 实验:send 返回时,消息在哪里
p.put("linger.ms", "500"); // 最多等 500ms 攒批
for (int i = 0; i < 300; i++) {
futures.add(producer.send(new ProducerRecord<>(topic, "k" + i, "v" + i), callback));
}| 观察 | 结果 |
|---|---|
300 次 send() 全部返回用时 | 8.2 ms |
返回时未完成的 Future | 300 个 |
| 第一个回调到达时间 | 第 515 ms |
| 这 300 条消息产生的生产请求 | 1 个 |
send() 后立即 flush() | 7.5 ms 内完成 |
生产请求数来自 Producer 自带的指标 request-total。这组数字就是模型的直接体现:记录先进批次,批次等满 linger.ms 后才发出。
4.4 值得继续追的实现
实现分成两部分:调用 send() 的业务线程只负责序列化、选择分区、追加到 RecordAccumulator 的批次里;后台的 Sender 线程负责把就绪的批次取出、按 Broker 合并、发送并处理响应。这个结构依赖的假设是:网络往返远比内存操作贵,所以值得用一点延迟换吞吐。
投递语义和重试见 Kafka 不丢、不重与 Exactly Once。
五、切片三:JDK Stream
5.1 模型:惰性流水线
Stream 的模型是一条流水线:数据源、若干个中间操作、一个终止操作。中间操作只是描述「要做什么」,真正的执行由终止操作触发,元素一个一个地流过整条流水线。
5.2 接口:中间操作与终止操作
filter、map 这类中间操作返回新的 Stream,findFirst、collect、forEach 这类终止操作产生结果。中间操作又分为无状态的(filter、map)和有状态的(sorted、distinct)。
5.3 实验:什么时候真正执行
Stream<Integer> pipeline = IntStream.rangeClosed(1, 1_000_000).boxed()
.filter(i -> { filtered.incrementAndGet(); return i % 7 == 0; })
.map(i -> { mapped.incrementAndGet(); return i * 10; });
// 此时 filter 执行 0 次,map 执行 0 次
Optional<Integer> first = pipeline.findFirst();
// 结果 70,filter 执行 7 次,map 执行 1 次一百万个元素的流水线,组装时一次都没执行,findFirst 只让 7 个元素经过了 filter。再用 peek 观察顺序:
filter 看到 a
filter 看到 bb
map 看到 bb
终止操作收到 BB
filter 看到 ccc
map 看到 ccc
终止操作收到 CCC元素是逐个流过整条流水线的,而不是先对所有元素 filter、再对所有元素 map。加上 sorted() 之后,顺序变成先收集全部元素、排序、再往下传。这就是有状态操作的含义。
5.4 值得继续追的实现
ReferencePipeline 把每个操作包装成一个阶段,终止操作从最后一个阶段往前,把各阶段的 Sink 串成一条链,再遍历数据源。短路操作(findFirst、anyMatch)通过 Sink.cancellationRequested() 提前停止遍历。这个设计依赖的假设是:大多数操作可以逐元素完成,不需要中间集合。
六、把方法用到自己的系统上
读懂别人的系统之后,可以用同样的问题检查自己的系统:
- 能不能用一两句话说出模块的模型? 说不出来,往往说明模块的职责不清楚。
- 接口的行为能不能从模型推出来? 如果调用方必须读实现才知道某个方法会不会阻塞、会不会重试,说明接口和模型脱节了。
- 实现里哪些部分是核心,哪些是适配? 核心部分应该稳定,适配部分可以随外部环境变化。
- 实现依赖了哪些假设? 比如「调用方都在同一个进程里」「时钟是单调的」。假设被打破时,系统会怎么表现。
七、常见误区
- 「读源码就是从入口一路 debug」:没有模型的指引,很容易在兼容和容错代码里迷路。
- 「接口就是 Java interface」:注解、配置项、HTTP API、DSL 同样是接口,而且往往比 Java 接口更难修改。
- 「理解了实现就理解了设计」:实现回答「怎么做」,模型回答「为什么这样做」。
- 「成熟系统的每行代码都值得学」:很多代码是为了历史兼容或特定环境存在的,带着问题读,才能分清主次。
- 「
send()返回就代表消息发出去了」:这是把接口的调用和模型的状态混在了一起。
小结
阅读一个系统的设计,先问它提供了什么模型,再看用户通过哪些接口使用这个模型,最后带着具体的问题去看实现。三个切片的实验说明,接口的很多行为(懒加载、send() 立即返回、流水线不执行)都可以从模型直接推出。这个顺序同样适用于评审自己的系统:说不清模型的模块,接口往往也不稳定。
下一篇 封装、组合与多态 回到代码层面,看面向对象的三个手段各自控制哪一类变化。
配套实验
- codesphere-labs/design/reading-software-design:Spring 容器、Kafka Producer 与 JDK Stream 三个切片(验证记录)
可以用 make verify 一条命令复现;链接固定在实验仓库的 c9f6692 版本。
参考资料