最近又把Spring Boot Actuator的源码翻了一遍,这次很刻意地先从一个小类开始入手——HttpTrace。说实话,刚开始我自己也犹豫:一个一百多行的数据类,能读出什么门道?结果硬着头皮读完之后才发现,这个类几乎把HTTP跟踪模块的设计思路全写在脸上了。这篇记录是第一篇,只聊HttpTrace本身:它为什么这么设计、每个字段为什么存在、构造器为什么只有一种、以及你读完之后能拿它做什么。
这篇笔记适合三类人:想系统性啃Spring Boot源码但不知道从哪下口的开发者;正在做网关、中间件调用链追踪,需要一个标准请求快照模型的人;以及用过/想用Actuator的/httptrace端点,但不清楚返回的JSON到底从哪儿生成的人。如果你属于其中任何一类,跟着这份源码笔记走一遍,后面的HttpTraceRepository、HttpTraceFilter再读起来就会顺畅很多。
1. 先把HttpTrace放进整个运行链路里:它到底在哪个环节出现
读源码最忌讳的就是拿到一个类就开始逐行看,看完整个人是懵的。我先花了几分钟梳理了HTTP跟踪这条链路里HttpTrace的位置,它其实扮演的是“数据载体”的角色。
一次请求经过Actuator的跟踪体系时,大致流程是这样的:
- 客户端发出HTTP请求,进入Spring MVC或WebFlux的处理链。
- 配置在过滤器链里的跟踪过滤器(比如HttpTraceFilter)拦下请求。
- 过滤器先基于当前请求信息构造出一个“半成品”的HttpTrace,记录时间戳、请求行、请求头、用户身份等。
- 请求继续向下执行,业务处理器返回响应。
- 过滤器在响应返回后,把响应状态、响应头、耗时这些信息补全,重新生成一个完整的HttpTrace对象。
- 过滤器调用HttpTraceRepository的add方法,把这个对象存起来。
- 当访问/actuator/httptrace端点时,Spring Boot从HttpTraceRepository里取出最近一批HttpTrace,序列化成JSON返回。
用一张表来理解各个角色更清楚:
| 角色 | 做什么 | 和HttpTrace的关系 |
|---|---|---|
| HttpTraceFilter/WebFilter | 拦截请求和响应 | 负责创建HttpTrace、填充数据 |
| HttpTraceRepository | 存储“最近一段时间的跟踪记录” | 保存和读取HttpTrace对象列表 |
| HttpTrace | 一次HTTP请求的完整快照 | 整个链路的核心数据模型 |
| HttpTraceEndpoint | 暴露跟踪记录的HTTP端点 | 把HttpTrace集合序列化为JSON输出 |
所以说,HttpTrace是这个模块里最底层、最核心的数据对象。它不参与过滤逻辑,不参与存储逻辑,只负责把“一次请求的有效信息”装在自己身上。这也是我为什么坚持从它开始读源码——先把数据模型啃透,再去看数据和数据之间怎么流转,比一上来就钻进过滤器实现里要轻松得多。
1.1 为什么数据模型适合当源码阅读的第一站
很多人读源码的习惯是从入口类开始,比如直接读HttpTraceFilter。但过滤器的逻辑里会牵扯到大量Servlet API、响应包装器、耗时计算,很容易陷入细节出不来。我更喜欢“自底向上”地读:先找一个最核心的数据模型,搞清楚它长什么样;再找这个模型相关的接口,看它允许外部怎么使用;最后再看实现类,理解默认行为是什么。
HttpTrace就完美符合“第一站”的条件。它没有复杂的继承体系,没有抽象方法,几乎是纯数据载体。但正是这个“纯”,逼迫你去思考:一个HTTP请求到底需要记录哪些信息?哪些信息可以不要?在一个共享库、分布式追踪系统里,这些问题的答案直接影响内存占用和数据安全。读懂它之后,再读HttpTraceFilter时,你会发现自己不是在读代码,而是在验证当初设计HttpTrace的人做的每一个取舍。
2. 类头与字段声明:一眼就能读懂不可变设计
打开HttpTrace.java,类头非常短,核心声明大概长这样(以Spring Boot 2.x分支为准):
package org.springframework.boot.actuate.trace.http; public final class HttpTrace { private final Instant timestamp; private final Principal principal; private final Session session; private final Request request; private final Response response; private final Long timeTaken; }第一眼会注意到的就是三个关键字:final class、final字段、引用类型字段全部通过构造器传入,没有setter。这代表HttpTrace是一个彻头彻尾的不可变对象。理解这一点非常重要,因为后面所有设计都建立在“快照不该被修改”这个前提上。
为什么不可变?举例来说,假设HttpTrace是可变的,某个复用的Filter在处理高并发请求时不慎修改了其中一个字段,那么所有历史追踪记录都会被污染,排查问题时你根本不知道数据是哪个环节改坏的。而不可变对象一旦创建完成,它的状态就固定了,后续无论被多少个线程读取、被序列化多少次、被存进哪个Repository,都不会出现数据不一致的问题。你可以类比成快递面单:贴上去之后,运单号、收件人、地址就不能改了,后面每个节点都是只读这张面单,才能保证整个物流链路的信息对得上。
2.1 字段逐个拆解:时间、身份、请求、响应、耗时
六个字段的含义非常直白,我逐个解释一下:
timestamp:请求开始的时间点,类型是Instant。principal:发起请求的用户身份,如果没有登录认证就是null。session:HTTP会话的轻量信息,包括会话ID和是否是新建会话。request:请求部分的快照,包含HTTP方法、URI、请求头、来源IP。response:响应部分的快照,包含HTTP状态码、响应头。timeTaken:整个请求从开始到响应完成所消耗的时间,单位是毫秒,可能为null。
为什么要拆成request和response两个嵌套类,而不是把method、uri、status全部平铺在HttpTrace里?这就是“把啰嗦组织成结构”的典型做法。假如平铺,字段会变成:requestMethod、requestUri、requestHeaders、responseStatus、responseHeaders……名字越来越长,而且没有层次感。改成嵌套类之后,代码里表达一个路径就是trace.getRequest().getUri(),语义非常清楚,序列化成JSON时也会自动形成嵌套结构,和/actuator/httptrace接口返回的格式正好对应。
2.2 时间戳为什么选Instant,timeTaken为什么是Long
这里有两个值得留意的小细节。
第一个是timestamp的类型。为什么不继续用老的java.util.Date或者简单的long?因为Instant是Java 8时间API的一部分,它表达的是一个绝对时间点,不带任何时区概念。在跨服务、跨时区的追踪场景里,如果每个服务用本地时间记录,日志一汇总就会出现一小时甚至几十小时的偏差。用Instant记录,后续无论在哪里展示,都可以按目标时区格式化,不会丢语义。
第二个是timeTaken的类型。它不是基本类型long,而是包装类型Long。我当时第一反应是:耗时不可能为负,为什么不用long,避免拆箱装箱的性能损耗?后来结合HttpTrace的创建方式才明白:在过滤器刚拦截到请求时,response和timeTaken还不知道;等到响应返回后,你可能需要一个“仍然缺耗时”的HttpTrace在某个中间环节流转。用Long可以表达“尚未赋值/未知”这种状态,而不是硬塞一个0。0是有效值,而null明确表示“这里还没有数据”,两者含义完全不同。这是很多自研追踪系统容易忽略的细节。
3. 只有一个构造器的硬核取舍:所有参数一次到位
继续往下读,你还会发现HttpTrace的构造器也很“倔”。它不是常见的无参构造器加setter,也不是给你一堆重载,而是只有一个全参构造器,大致如下:
public HttpTrace(Instant timestamp, Principal principal, Session session, Request request, Response response, Long timeTaken) { this.timestamp = timestamp; this.principal = principal; this.session = session; this.request = request; this.response = response; this.timeTaken = timeTaken; }这个设计的潜台词是:这个类根本没有中间状态。你要创建HttpTrace,就必须一次性把六个核心维度全部给定;字段可以为null,但不能不传。读到这里我停下来想了一会儿,这种做法和传统JavaBean“先new出来,再一个个set”的模式完全是两个思路。
好处很明显:第一,对象从诞生那一刻就是完整的快照。如果某个请求缺少principal或session,它们至少被显式赋值为null,而不是被调用方忘掉set,给排查造成“这字段到底有没有值”的困惑。第二,没有setter,外部想改也改不了,不可变性在构造器层面就闭环了。缺点嘛,如果你需要用对象关系映射框架(比如MyBatis)把HttpTrace直接映射成数据库表,这种没有无参构造器的类会非常别扭,需要额外配置。但Actuator的定位是内存追踪,不是持久化一套完整审计系统,所以这种取舍是合适的。
3.1 从TraceInfo到HttpTrace:两代数据模型的演进
读源码时最好看一眼历史版本,你会更清楚设计者为什么这样改。在Spring Boot早期的actuator里,类似的模型叫TraceInfo,字段很少,结构也比较扁平,不支持session和principal,也没有专门的Request/Response嵌套类。后来升级为HttpTrace时,明显做了三件事:
- 把请求和响应拆成两个嵌套类,结构层次更清晰。
- 增加principal和session字段,让跟踪记录能表达“谁在什么会话里发起了请求”。
- 时间戳从
Date换成Instant,耗时字段也更明确。
这种演进思路对我自己做接口设计很有启发:一个数据模型不要追求字段越加越多,而应该思考哪些维度是独立的,哪些维度可以分组。请求和响应天然是两组东西,硬塞在一个平铺对象里会让调用方频繁地“属性跳着看”,而嵌套之后,代码可读性和扩展性都会好很多。
3.2 Jackson能直接序列化这个不可变类吗
另一个我实际踩过的坑,是序列化。如果你尝试自己写一个类似的不可变数据模型,然后用Jackson序列化,很可能会遇到“无法实例化”的报错——因为Jackson默认需要无参构造器。但Spring Boot Actuator里的HttpTrace在/actuator/httptrace里能正常输出JSON,靠的是Jackson的构造器参数名推断能力,或者显式配置的jackson-datatype-jsr310等模块支持。
这不是让你去关心Spring内部怎么配置,而是提醒你:设计自己的追踪对象时,如果也想走“全参构造器+不可变”的路子,要么给构造器参数加上名字元数据,要么配合Jackson的@JsonProperty标注,否则序列化环节会消耗你大量调试时间。当时我在一个模拟项目里直接照搬了HttpTrace的设计,结果单元测试里序列化就报错,后来给构造器加上@JsonCreator才解决。这种细节,单纯看源码是看不出来的,真的需要亲手写一遍才能记住。
4. 嵌套类Request和Response:一次HTTP交互的关键快照字段
HttpTrace的核心内容其实全在嵌套类里。Request和Response这两个静态类的字段不多,但每一个都值得琢磨:
public static class Request { private final String method; private final String uri; private final Map<String, List<String>> headers; private final String remoteAddress; } public static class Response { private final int status; private final Map<String, List<String>> headers; }先说Request里的四个字段。method是HTTP方法,比如GET、POST;uri是请求地址,一般而言只记录路径和查询参数,不会记录绝对域名;remoteAddress是客户端来源IP;headers则是整个请求头的快照。这里最有趣的是headers,它被定义为Map<String, List<String>>,而不是常见的Map<String, String>。
为什么是这个类型?因为HTTP协议本身允许同一个名字出现多次,比如Set-Cookie、Cache-Control、自定义的业务头都可能有多个值。用List<String>作为value,才能完整保留这些信息。如果你用Map<String, String>,遇到多值场景,后面的值会把前面的覆盖掉,追踪数据就丢了。这个细节看起来简单,但在自研追踪系统里,极其容易踩坑。
4.1 Request为什么唯独不记录body
很多第一次接触HttpTrace的朋友会问:为什么有请求头,却没有请求体?拿到Post请求时,body里才是关键参数,不记录怎么追踪业务?这个问题背后其实是安全和内存的双重考量。
HTTP body可能包含密码、密钥、个人敏感信息,如果默认全部记录到追踪仓库里,等于让日志变成了“数据泄露仓库”。同时,body可能非常大,一个文件上传请求动辄几十MB,一旦全量记录,内存和磁盘会立刻被打满。HttpTrace的设计目标是记录“一次请求的元信息和状态结果”,不是抓包工具,也不是网关日志,所以默认只记录到请求行、请求头这个粒度。如果你确实需要body内容做接口调试,可以在业务层的过滤器里自己做,不要指望Actuator默认帮你搞定。
我自己的经验是,如果做日志脱敏系统,这种“只记录头、不记录体”的设计反而更省心,因为不需要再对body做脱敏处理。即便将来要扩展,也可以在Request类里加一个单独的字段,而不是把整个body塞进headers里混淆视听。
4.2 Response状态码和响应头的“坑”
Response里除了status字段之外,也是Map<String, List<String>>的headers。status是int类型,直接用数字表示HTTP状态码,比如200、302、500。这里需要特别注意的是:响应头里往往包含Set-Cookie。如果使用HttpTrace自带的记录能力,响应头会原样保存set-cookie里的sessionId等敏感信息。
这不是源码bug,而是使用时要留个心眼。如果你把这套东西用在生产环境,并且开启了httptrace端点,建议在存储到仓库之前做一个清洗,或者配置过滤器,把Cookie和Authorization这类敏感头过滤掉。Spring Boot虽然提供了一些默认过滤能力,但只靠默认配置不一定符合你的公司安全规范,最好自己确认一遍。
4.3 Session和Principal:轻量级身份快照
除了Request和Response,HttpTrace还有Session和Principal两个静态类。前者只记录会话id和是否新建会话:
public static class Session { private final String id; private final boolean newSession; }后者则直接使用JDK的Principal接口,保存用户名或认证主体。说实话,这两个类的内容都很克制。为什么不直接保存整个HttpSession对象?因为那是重量级对象,保存它会导致内存泄漏、序列化异常,而且你并不需要整个session的属性。只需要“这个请求属于哪个会话、这个用户是谁”,就能满足绝大多数审计和排查需求。这个思路在做调用链追踪时很值得借鉴:能记录id就绝对不记录整个对象,能少存一个字段就少存一个字段。
5. equals、hashCode和toString:不可变对象最容易被忽略的三件套
老实说,第一次看源码时我的注意力全在字段上,等翻到文件后半部分,才发现HttpTrace还认真地重写了equals、hashCode和toString。这三个方法在很多人眼里是IDE一键生成的“模板代码”,但在这种价值对象里,它们决定了对象能不能被正常放进集合、能不能被快速比较、能不能在日志里一眼看出价值。
equals和hashCode的目标非常明确:只要两个HttpTrace的timestamp、principal、session、request、response、timeTaken都相同,就认为它们是同一个跟踪记录。这个比较逻辑并不难,但实现时有一个潜规则:必须保证几个字段在比较时不会出现“类型一样、值不同但hash相同”的问题,所以要基于全部字段生成hashCode。实际开发中,我看到过几次因为某个字段没参与hashCode计算,导致对象在HashSet里出现重复的惨案,最终排查时才发现是新加的字段没同步更新equals和hashCode。阅读HttpTrace源码时,你会看到它把所有字段都放到比较逻辑里,这是最稳的做法。
5.1 两个HttpTrace什么时候相等
这个“相等”定义不是理论问题,它有非常具体的应用场景。比如你写单元测试,需要断言“这次请求被正确跟踪了”,你可以直接构造一个期望的HttpTrace对象,然后调用repository的查询结果来做assertEquals,而不是逐个字段去比较。如果HttpTrace没有重写equals,这个操作会失败,因为两个对象引用不同。
另一个场景是内存仓库的去重。默认的仓库通常是一个循环缓冲列表,如果自己实现一个基于Set的仓库,要求在跟踪时不能有重复记录,那么equals就直接决定重复判断是否正确。读源码时搞清楚它比较哪些字段,比你自己瞎猜要靠谱得多。
5.2 toString的调试价值
toString更是被很多人忽略,但实际排查问题时作用巨大。假如HttpTrace没有重写toString,你打印一个List时看到的是一串类似org.springframework.boot.actuate.trace.http.HttpTrace@1a2b3c4的对象地址,完全没用。而重写之后,你可以直接看到:
HttpTrace{request=GET /order/1, response=200, timeTaken=214}我个人的调试习惯是:遇到这类核心数据模型,一定要先确认它有没有toString,没有的话第一件事就是自己补一个。至少在打日志的时候,这一句话就能省下你五分行时间。
6. 读源码的额外收获:从HttpTrace往外延伸的三个动手方向
读完一个类如果只是在笔记里抄一遍字段,那收获很有限。我当时读完HttpTrace之后,顺手做了三件具体的事,它们让源码阅读的价值立刻落地了。
6.1 自定义HttpTraceRepository,把跟踪数据真正存起来
HttpTraceRepository是接口,默认实现是内存中的环形缓冲,容量有限。如果你想保留更多的追踪记录,可以自己实现这个接口:
public class CustomHttpTraceRepository implements HttpTraceRepository { private final List<HttpTrace> traces = new ArrayList<>(); @Override public List<HttpTrace> findAll() { return new ArrayList<>(traces); } @Override public void add(HttpTrace trace) { synchronized (traces) { traces.add(trace); if (traces.size() > 1000) { traces.remove(0); } } } }这个例子非常简单,但已经能说明问题:你完全可以基于它把HttpTrace写入数据库,或者转发到消息队列。我做模拟项目时,就是把HttpTrace转成JSON后写到本地的日志文件中,再用日志分析工具做统计。有了HttpTrace清晰的字段结构,整个转换过程非常轻松。
6.2 从timeTaken到耗时统计:可观测性的一个小入口
timeTaken这个毫秒字段,单独看只是一次请求的耗时。但如果你把所有HttpTrace都收集起来,就能做出非常有价值的统计:平均耗时、P95、P99,甚至按uri维度拆开看哪些接口最慢。这不需要AOP,不需要埋点,只需要在Repository的add方法里做一次计算即可。
我实操时最喜欢的是配合principal字段做用户维度审计:某位同学在一次会话中对哪些URI做了哪些类型的请求,响应状态是什么,耗时多久。这套能力对权限审计、异常回溯都有帮助。不过要提醒一句:不要在业务代码里直接依赖HttpTrace的字段做判断,它只是辅助观测,不是业务事实。
6.3 写源码笔记的一个小方法:造一个最小可运行测试
读HttpTrace这样的数据类,最忌讳只看不写。我当时在IDE里新建了一个测试类,直接new一个HttpTrace对象,然后打印它的toString、序列化成JSON、再反序列化回来,看到结果和/actuator/httptrace接口返回的结构完全一致,心里的疑惑才彻底消失。
这个方法也推荐给你:遇到这种小模型,花十分钟写一个最小测试,比盯着屏幕看半个小时源码都管用。测试代码不一定要提交到项目里,但这个过程能逼着你把源码中每个字段的实际输出看清楚,比你自以为“看懂了”要可靠得多。
说到底,HttpTrace这个类并不复杂,但它把“数据模型应该记录什么、怎么记录、记录到什么粒度”这个问题回答得非常干脆。下一篇我准备顺着这条线继续读HttpTraceFilter,看看这个不可变对象到底是在哪个环节被创建、又是如何被补全的。如果你也在读Actuator源码,建议先动手把HttpTrace这个类“榨干”,再往前走,后面会越读越顺。