Spring Boot 4.1.1 深度源码解析-第 19 章 · Spring MVC 自动配置:DispatcherServlet、消息转换与 Jackson 3
本章定位 :卷五 · Web 栈的第三站。第 17 章解决了"用哪种上下文",第 18 章解决了"服务器怎么起来", 本章解决最后一块:请求进来之后,是谁把它交给你的
@RestController,又是谁把返回值变成 JSON。这章有两个绕不开的主题:一是 Spring Framework 7 对 MVC 配置模型的改造 (
WebMvcConfigurer里出现了ServerBuilder这种新类型),二是 Jackson 3 ------ 包坐标从com.fasterxml.jackson换成了tools.jackson,这是 Spring Boot 4 里破坏性最大的一次依赖升级。源码坐标 :
module/spring-boot-webmvc、module/spring-boot-http-converter、module/spring-boot-jackson、module/spring-boot-jackson2。 版本:gradle.properties里jacksonVersion=3.1.5、jackson2Version=2.21.5。
0. 引子:一个请求穿过的十层"自动"决策
java
@RestController
public class HelloController {
@GetMapping("/hello")
public Map<String, String> hello() { return Map.of("msg", "hi"); }
}
启动后 curl localhost:8080/hello 拿到 {"msg":"hi"}。这中间 Boot 替你做了十件事:
markdown
1. 判定这是 SERVLET 应用 ← 第 17 章
2. 创建 AnnotationConfigServletWebServerApplicationContext
3. 起 Tomcat,绑定 8080 ← 第 18 章
4. 创建 DispatcherServlet bean(名字必须叫 dispatcherServlet)
5. 把它注册到 Servlet 容器,URL mapping = "/" ← ★ 本章
6. 创建 RequestMappingHandlerMapping / Adapter
7. 决定消息转换器列表(谁负责 JSON)
8. 创建 JsonMapper(Jackson 3) ← ★ 本章
9. 把 JsonMapper 装进 JacksonJsonHttpMessageConverter
10. 把转换器装进 DispatcherServlet 的 HandlerAdapter
第 4--5 步是 DispatcherServletAutoConfiguration,第 6、10 步是 WebMvcAutoConfiguration, 第 7、9 步是 HttpMessageConvertersAutoConfiguration,第 8 步是 JacksonAutoConfiguration。
四个自动配置类,四种不同的协作方式,这本身就是本章的骨架:
| 自动配置 | 协作方式 | 关键机制 |
|---|---|---|
DispatcherServletAutoConfiguration |
条件类自研 (两个 SpringBootCondition) |
按 bean 名判定,而非类型 |
WebMvcAutoConfiguration |
实现框架接口 (WebMvcConfigurer) |
配置而非替换 |
HttpMessageConvertersAutoConfiguration |
定制器链 (*ConvertersCustomizer) |
新增的 ClientBuilder/ServerBuilder |
JacksonAutoConfiguration |
建造者定制器 (JsonMapperBuilderCustomizer) |
prototype 作用域的 Builder |
看清楚这四种模式,比记住任何一个类都重要------它们是 Boot 应对"第三方配置对象不可控"的四种标准答案。
1. spring-boot-webmvc 模块全景
先看第一定律(先查 *.imports):
vbnet
module/spring-boot-webmvc/src/main/resources/META-INF/spring/
├── org.springframework.boot.autoconfigure.AutoConfiguration.imports
│ ├── DispatcherServletAutoConfiguration
│ ├── WebMvcAutoConfiguration
│ ├── WebMvcObservationAutoConfiguration
│ ├── actuate.endpoint.web.WebMvcHealthEndpointExtensionAutoConfiguration
│ ├── actuate.web.mappings.WebMvcMappingsAutoConfiguration
│ └── error.ErrorMvcAutoConfiguration
├── org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports
│ ├── actuate.web.WebMvcEndpointManagementContextConfiguration
│ └── actuate.web.WebMvcEndpointChildContextConfiguration
└── org.springframework.boot.web.server.test.AutoConfigureWebServer.imports
└── DispatcherServletAutoConfiguration ← ★ 只有这一个
三个信息点:
- 只有 3 个 是普通自动配置,另外 3 个是 Actuator 相关(挂在
.actuate.包下,第 25 章会展开)。 ManagementContextConfiguration.imports是 Actuator 子上下文专用的通道------这是一个独立于AutoConfiguration.imports的注册点 ,第 14 章讲AutoConfigurationImportSelector时没覆盖到它。AutoConfigureWebServer.imports里只有DispatcherServletAutoConfiguration------ 这是给@AutoConfigureMockMvc/ 测试切片用的"Web 服务器测试"通道,只注册 DispatcherServlet 相关的最小集。
⚠️ 未核实 :
AutoConfigureWebServer这个 key 的消费方(大概是某个ImportSelector或测试上下文 定制器)我没有追踪到底。可以肯定的是它不在AutoConfigurationImportSelector的标准流程里。
主包 org.springframework.boot.webmvc.autoconfigure 一共 17 个 java 文件(不含子包):
go
DispatcherServletAutoConfiguration.java DispatcherServletPath.java
DispatcherServletRegistrationBean.java JspTemplateAvailabilityProvider.java
ProblemDetailsExceptionHandler.java WebMvcAutoConfiguration.java
WebMvcObservationAutoConfiguration.java WebMvcProperties.java
WebMvcRegistrations.java WelcomePage.java
WelcomePageHandlerMapping.java WelcomePageNotAcceptableHandlerMapping.java
package-info.java (+ actuate/、error/ 两个子包)
WebMvcAutoConfiguration 单文件 750 行,是 Boot 里最大的自动配置类之一。
2. DispatcherServletAutoConfiguration:按 名字 判定的条件
2.1 类级注解
java
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@AutoConfiguration
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass(DispatcherServlet.class)
public final class DispatcherServletAutoConfiguration {
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)=Integer.MIN_VALUE。 注意WebMvcAutoConfiguration是HIGHEST_PRECEDENCE + 10,数值更大 = 排更后 , 所以 DispatcherServlet 的配置确实先被处理。这与第 14 章讲的AutoConfigurationSorter规则一致。@ConditionalOnClass(DispatcherServlet.class)是唯一的类型门槛。- 类上有两个 public static final 常量,这是它对外契约的一部分:
java
public static final String DEFAULT_DISPATCHER_SERVLET_BEAN_NAME = "dispatcherServlet";
public static final String DEFAULT_DISPATCHER_SERVLET_REGISTRATION_BEAN_NAME = "dispatcherServletRegistration";
这两个字符串常量是本章最值得记住的东西,因为整个类的逻辑都是围绕"这个名字被占用了吗"展开的。
2.2 DispatcherServletConfiguration:创建 servlet
java
@Configuration(proxyBeanMethods = false)
@Conditional(DefaultDispatcherServletCondition.class)
@ConditionalOnClass(ServletRegistration.class)
@EnableConfigurationProperties(WebMvcProperties.class)
protected static class DispatcherServletConfiguration {
@Bean(name = DEFAULT_DISPATCHER_SERVLET_BEAN_NAME)
DispatcherServlet dispatcherServlet(WebMvcProperties webMvcProperties) {
DispatcherServlet dispatcherServlet = new DispatcherServlet();
dispatcherServlet.setDispatchOptionsRequest(webMvcProperties.isDispatchOptionsRequest());
dispatcherServlet.setDispatchTraceRequest(webMvcProperties.isDispatchTraceRequest());
dispatcherServlet.setPublishEvents(webMvcProperties.isPublishRequestHandledEvents());
dispatcherServlet.setEnableLoggingRequestDetails(webMvcProperties.isLogRequestDetails());
return dispatcherServlet;
}
@Bean
@ConditionalOnBean(MultipartResolver.class)
@ConditionalOnMissingBean(name = DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME)
MultipartResolver multipartResolver(MultipartResolver resolver) {
// Detect if the user has created a MultipartResolver but named it incorrectly
return resolver;
}
}
四个 spring.mvc.* 开关直接读进 servlet:
| 属性 | 默认值 | 含义 |
|---|---|---|
spring.mvc.dispatch-options-request |
true | OPTIONS 请求是否也走 doService |
spring.mvc.dispatch-trace-request |
false | TRACE 请求是否走 doService |
spring.mvc.publish-request-handled-events |
true | 每个请求结束发 ServletRequestHandledEvent |
spring.mvc.log-request-details |
false | DEBUG/TRACE 级别是否允许打印请求详情 |
注意 dispatchOptionsRequest 在 WebMvcProperties 里是 = true 显式初始化, 而 dispatchTraceRequest 是默认 false。TRACE 默认不开 是有道理的:TRACE 请求会回显请求头, 存在信息泄露风险(历史上就是 HttpOnly cookie 泄露的通道之一)。
multipartResolver 这段是 Boot 里最经典的"改名"技巧 ,注释写得很直白: "Detect if the user has created a MultipartResolver but named it incorrectly"。
DispatcherServlet 从容器里取 multipart resolver 时用的是固定名字 DispatcherServlet.MULTIPART_RESOLVER_BEAN_NAME(值为 "multipartResolver")。 用户如果写:
java
@Bean
MultipartResolver myMultipartResolver() { ... } // ← 名字错了
类型是对的,但 DispatcherServlet 按名字找不到,于是静默地不启用文件上传 ------ 没有任何报错,只是 @RequestParam MultipartFile 永远为 null。这是极难排查的问题。
Boot 的解法:定义一个同名 bean 方法,但返回用户那个 bean 。 条件是 @ConditionalOnBean(MultipartResolver.class)(有这个类型)
@ConditionalOnMissingBean(name = "multipartResolver")(但没有正确名字的)。 方法体return resolver;------直接把注入进来的那个 bean 原样返回,等于给它"补一个正确名字的别名"。
这里有个容易看漏的点 :方法名叫
multipartResolver,Boot 的 bean 命名规则(第 13 章) 是"@Bean 方法名即 bean 名",所以这个新 bean 的名字正好就是multipartResolver。 方法名不是随便起的,它就是这段逻辑生效的前提。
2.3 DefaultDispatcherServletCondition:四分支判定
java
@Order(Ordered.LOWEST_PRECEDENCE - 10)
private static final class DefaultDispatcherServletCondition extends SpringBootCondition {
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
ConditionMessage.Builder message = ConditionMessage.forCondition("Default DispatcherServlet");
ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
Assert.state(beanFactory != null, "'beanFactory' must not be null");
List<String> dispatchServletBeans = Arrays
.asList(beanFactory.getBeanNamesForType(DispatcherServlet.class, false, false));
if (dispatchServletBeans.contains(DEFAULT_DISPATCHER_SERVLET_BEAN_NAME)) {
return ConditionOutcome.noMatch(...); // ① 已存在同名且同类型的 bean
}
if (beanFactory.containsBean(DEFAULT_DISPATCHER_SERVLET_BEAN_NAME)) {
return ConditionOutcome.noMatch(...); // ② 存在同名但类型不是 DispatcherServlet
}
if (dispatchServletBeans.isEmpty()) {
return ConditionOutcome.match(...); // ③ 一个 DispatcherServlet 都没有
}
return ConditionOutcome.match( // ④ 有,但没有叫 dispatcherServlet 的
message.found("dispatcher servlet bean", "dispatcher servlet beans")
.items(Style.QUOTE, dispatchServletBeans)
.append("and none is named " + DEFAULT_DISPATCHER_SERVLET_BEAN_NAME));
}
}
真值表 (设 N = 名字叫 dispatcherServlet 的 bean,T = 类型是 DispatcherServlet 的 bean):
| 场景 | 判定 | 结果 |
|---|---|---|
| 无 N,无 T | ③ | match → Boot 创建默认的 |
| 无 N,有 T(名字叫别的) | ④ | match → Boot 仍然创建默认的! |
| 有 N,N 是 DispatcherServlet | ① | no match → 用用户的 |
| 有 N,N 不是 DispatcherServlet | ② | no match → 用户占用了名字,Boot 放弃 |
第 ④ 行最反直觉 :你明明定义了一个 DispatcherServlet 类型的 bean(名字叫 myDispatcher), Boot 仍然会再创建一个名叫 dispatcherServlet 的。两个 DispatcherServlet 共存。
为什么这样设计?看第 2.5 节的 DispatcherServletRegistrationCondition.checkDefaultDispatcherName 就明白了: 注册配置只认 dispatcherServlet 这个名字 。如果 Boot 不自己创建一个,你自定义的那个就永远不会被注册到容器, 等于"定义了但没生效"。与其让它静默失效,不如创建一个能被注册的默认实例。
第 ② 行的意义:用户写了
java
@Bean(name = "dispatcherServlet")
SomeOtherThing dispatcherServlet() { ... } // 名字占用了
Boot 直接放弃,而不是抛异常或覆盖。这是"名字被占用即退让"的策略。
2.4 DispatcherServletRegistrationConfiguration:注册到容器
java
@Configuration(proxyBeanMethods = false)
@Conditional(DispatcherServletRegistrationCondition.class)
@ConditionalOnClass(ServletRegistration.class)
@EnableConfigurationProperties(WebMvcProperties.class)
@Import(DispatcherServletConfiguration.class) // ★ 注意这里
protected static class DispatcherServletRegistrationConfiguration {
@Bean(name = DEFAULT_DISPATCHER_SERVLET_REGISTRATION_BEAN_NAME)
@ConditionalOnBean(value = DispatcherServlet.class, name = DEFAULT_DISPATCHER_SERVLET_BEAN_NAME)
DispatcherServletRegistrationBean dispatcherServletRegistration(DispatcherServlet dispatcherServlet,
WebMvcProperties webMvcProperties, ObjectProvider<MultipartConfigElement> multipartConfig) {
DispatcherServletRegistrationBean registration = new DispatcherServletRegistrationBean(dispatcherServlet,
webMvcProperties.getServlet().getPath());
registration.setName(DEFAULT_DISPATCHER_SERVLET_BEAN_NAME);
registration.setLoadOnStartup(webMvcProperties.getServlet().getLoadOnStartup());
multipartConfig.ifAvailable(registration::setMultipartConfig);
return registration;
}
}
@Import(DispatcherServletConfiguration.class) 放在注册配置上,而不是外层类上 ------ 所以注册配置生效时,一定会把创建配置一起带进来。这是一个**"依赖方导入被依赖方"**的写法, 比在外层统一 @Import 更能表达"注册依赖创建"的关系。
@ConditionalOnBean(value = DispatcherServlet.class, name = ...) 是类型 + 名字的与条件 (第 15 章讲过:value 与 name 同时出现时是 AND)。这保证注入进来的 dispatcherServlet 一定是那个名字正确、类型正确的 bean。
registration.setName(...) 把 servlet 名字设成 dispatcherServlet, registration 这个 bean 自己的名字是 dispatcherServletRegistration------ 两个名字不同,别搞混。
2.5 DispatcherServletRegistrationCondition:两段式检查
java
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
ConditionOutcome outcome = checkDefaultDispatcherName(beanFactory);
if (!outcome.isMatch()) {
return outcome;
}
return checkServletRegistration(beanFactory);
}
第一段 checkDefaultDispatcherName:
java
boolean containsDispatcherBean = beanFactory.containsBean(DEFAULT_DISPATCHER_SERVLET_BEAN_NAME);
if (!containsDispatcherBean) {
return ConditionOutcome.match(); // ★ 没有这个名字 → 直接放行
}
List<String> servlets = Arrays.asList(beanFactory.getBeanNamesForType(DispatcherServlet.class, false, false));
if (!servlets.contains(DEFAULT_DISPATCHER_SERVLET_BEAN_NAME)) {
return ConditionOutcome.noMatch(...); // 名字被非 DispatcherServlet 占用 → 不匹配
}
return ConditionOutcome.match();
注意第一个分支返回 match() 且不带 message------这是"无事发生"的快速通道。
第二段 checkServletRegistration 是四个分支的完整枚举:
java
List<String> registrations = Arrays.asList(beanFactory.getBeanNamesForType(ServletRegistrationBean.class, false, false));
boolean containsDispatcherRegistrationBean = beanFactory.containsBean(DEFAULT_DISPATCHER_SERVLET_REGISTRATION_BEAN_NAME);
if (registrations.isEmpty()) {
if (containsDispatcherRegistrationBean) {
return noMatch("non servlet registration bean"); // 有同名非注册 bean
}
return match("did not find servlet registration bean at all");
}
if (registrations.contains(DEFAULT_DISPATCHER_SERVLET_REGISTRATION_BEAN_NAME)) {
return noMatch("servlet registration bean"); // 已经注册过了
}
if (containsDispatcherRegistrationBean) {
return noMatch("non servlet registration bean");
}
return match("... and none is named dispatcherServletRegistration");
这里的对称性很漂亮 :两段检查用的是同一套"名字 vs 类型"的四象限, 注释写得像散文(found("non servlet registration bean").items(...)), 是为了让 /actuator/conditions 的输出人类可读 (第 16 章讲过 ConditionEvaluationReport)。
2.6 两个条件类共用的 @Order(Ordered.LOWEST_PRECEDENCE - 10)
两个内部条件类都标了:
java
@Order(Ordered.LOWEST_PRECEDENCE - 10)
SpringBootCondition 实现 Ordered 时,这个 order 影响什么?
⚠️ 推测(未核实) :我推断它与
ConditionEvaluationReport里条件的输出排序 有关, 或者与OnBeanCondition之类的条件在ConditionEvaluator中的执行次序有关。 但 Framework 的ConditionEvaluator.shouldSkip是否读取条件类的@Order, 我没有在 Framework 7.0.9 的源码里确认(Framework 不在本仓库)。 自测方法 :写两个自定义Condition,给不同@Order,打断点看shouldSkip的调用顺序。有一点可以确定:
LOWEST_PRECEDENCE - 10是一个**"几乎最后但留一点余地"**的数值, 这种写法在 Boot 里反复出现(第 18 章DefaultErrorAttributes的 order 也是这个套路), 意图是"排在绝大多数条件之后,但比绝对最低优先级高一点"。
2.7 DispatcherServletPath:URL 映射的四种推导
spring.mvc.servlet.path 不是直接拿去当 servlet mapping 的,中间有一层转换:
java
@FunctionalInterface
public interface DispatcherServletPath {
String getPath();
default String getRelativePath(String path) {
String prefix = getPrefix();
if (!path.startsWith("/")) {
path = "/" + path;
}
return prefix + path;
}
default String getPrefix() {
String result = getPath();
int index = result.indexOf('*');
if (index != -1) {
result = result.substring(0, index);
}
if (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
default String getServletUrlMapping() {
if (getPath().isEmpty() || getPath().equals("/")) {
return "/";
}
if (getPath().contains("*")) {
return getPath();
}
if (getPath().endsWith("/")) {
return getPath() + "*";
}
return getPath() + "/*";
}
}
getServletUrlMapping() 的四条规则(按顺序短路):
spring.mvc.servlet.path |
getServletUrlMapping() |
说明 |
|---|---|---|
"" 或 "/" |
"/" |
默认:接管所有请求 |
含 * |
原样返回 | 用户自己写通配符,不干预 |
以 / 结尾,如 /app/ |
/app/* |
补 * |
其他,如 /app |
/app/* |
补 /* |
关键 :path = "/app" 最终映射成 /app/*,不是 /app 。 Servlet 规范里 /app 是"精确匹配",/app/* 是"前缀匹配"------ 如果用精确匹配,/app/foo 就进不来。Boot 帮你补上 /* 是必要的。
getPrefix() 的作用是给相对路径加前缀。谁在用?
java
// WebMvcAutoConfiguration.WebMvcAutoConfigurationAdapter
this.dispatcherServletPath.ifAvailable((dispatcherPath) -> {
String servletUrlMapping = dispatcherPath.getServletUrlMapping();
if (servletUrlMapping.equals("/") && singleDispatcherServlet()) {
UrlPathHelper urlPathHelper = new UrlPathHelper();
urlPathHelper.setAlwaysUseFullPath(true);
configurer.setUrlPathHelper(urlPathHelper);
}
});
有意思的是 :这段只在 AntPathMatcher 分支里执行(外层 if 判断了 MatchingStrategy.ANT_PATH_MATCHER), 而且它并没有用 getPrefix() ------它只用了 getServletUrlMapping()。 getPrefix() / getRelativePath() 的调用方在本模块内我没找到(可能在 Actuator 的端点映射里)。
⚠️ 未核实 :
getPrefix()与getRelativePath()的实际调用点。 我在spring-boot-webmvc里grep到DispatcherServletPath的使用只有ErrorPageCustomizer、WebMvcAutoConfigurationAdapter、以及 Actuator 子包。 具体谁调getRelativePath未确认。
2.8 DispatcherServletRegistrationBean:不可变的 URL 映射
java
public class DispatcherServletRegistrationBean extends ServletRegistrationBean<DispatcherServlet>
implements DispatcherServletPath {
private final String path;
public DispatcherServletRegistrationBean(DispatcherServlet servlet, String path) {
super(servlet);
Assert.notNull(path, "'path' must not be null");
this.path = path;
super.addUrlMappings(getServletUrlMapping()); // ★ 构造时就算好
}
@Override
public String getPath() { return this.path; }
@Override
public void setUrlMappings(Collection<String> urlMappings) {
throw new UnsupportedOperationException("URL Mapping cannot be changed on a DispatcherServlet registration");
}
@Override
public void addUrlMappings(String... urlMappings) {
throw new UnsupportedOperationException("URL Mapping cannot be changed on a DispatcherServlet registration");
}
}
三个设计点:
- 双身份 :既是 servlet 注册器,又是
DispatcherServletPath的实现------ 注册完自己就能对外提供"我映射在哪个路径"的信息。 - 构造时算好 mapping :
super.addUrlMappings(getServletUrlMapping())在构造函数里调用,getServletUrlMapping()是DispatcherServletPath的 default 方法,此时this.path已赋值。 这里有个隐患 :在构造函数里调用一个可被覆写的 default 方法。 若子类覆写getServletUrlMapping()并依赖子类字段,会拿到 null。DispatcherServletRegistrationBean是public的,理论上可被继承。 但path是private final,子类拿不到,也只能覆写逻辑------风险可控但确实存在。 - 封死修改入口 :
setUrlMappings/addUrlMappings直接抛异常。 为什么这么强硬?因为 Boot 的很多地方都基于"只有一个 DispatcherServlet 且路径固定"这一假设 (比如singleDispatcherServlet()的判断、错误页的注册)。 允许运行时改路径会打破这些假设,且不会有明显报错------宁可抛异常。
3. WebMvcAutoConfiguration:750 行的配置中枢
3.1 类级注解:五层信息
java
@AutoConfiguration(after = { DispatcherServletAutoConfiguration.class, TaskExecutionAutoConfiguration.class },
afterName = "org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration")
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class })
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
@ImportRuntimeHints(WebResourcesRuntimeHints.class)
public final class WebMvcAutoConfiguration {
逐条解读:
after = { DispatcherServletAutoConfiguration, TaskExecutionAutoConfiguration }------ 前者好理解;后者是因为configureAsyncSupport需要applicationTaskExecutorbean 存在 (下面 3.6 节)。afterName(字符串形式)引用ValidationAutoConfiguration------ 因为 webmvc 模块不依赖 validation 模块 ,不能用.class字面量 (第 14 章讲过afterName就是为这种"可选依赖"准备的)。@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)------ 这是最关键的一条 。WebMvcConfigurationSupport是@EnableWebMvc导入的那个类。 所以:只要用户写了@EnableWebMvc,整个WebMvcAutoConfiguration全部失效。 这是 Boot "让位给用户"的最大粒度开关。@AutoConfigureOrder(HIGHEST_PRECEDENCE + 10)=Integer.MIN_VALUE + 10, 紧跟在DispatcherServletAutoConfiguration(+0)之后。@ImportRuntimeHints(WebResourcesRuntimeHints.class)------ 为 GraalVM 注册静态资源相关的反射/资源提示 (第 28 章会展开)。
3.2 两个 Filter:一个默认开,一个默认关
java
@Bean
@ConditionalOnMissingBean(HiddenHttpMethodFilter.class)
@ConditionalOnBooleanProperty("spring.mvc.hiddenmethod.filter.enabled")
OrderedHiddenHttpMethodFilter hiddenHttpMethodFilter() {
return new OrderedHiddenHttpMethodFilter();
}
@Bean
@ConditionalOnMissingBean(FormContentFilter.class)
@ConditionalOnBooleanProperty(name = "spring.mvc.formcontent.filter.enabled", matchIfMissing = true)
OrderedFormContentFilter formContentFilter() {
return new OrderedFormContentFilter();
}
HiddenHttpMethodFilter 默认是关闭的 (@ConditionalOnBooleanProperty 不带 matchIfMissing)。 这个 filter 的作用是把 POST 请求里 _method=DELETE 参数转成 DELETE 方法------ 配合老式 HTML 表单(只能发 GET/POST)使用。Boot 4 默认关掉它, 反映了一个事实:现在的应用基本都用 fetch/AJAX,直接发 DELETE/PUT,不再需要这个 hack。 关掉也顺带消除了一个小的攻击面。
FormContentFilter 默认开 。它解决的是:servlet 规范里 PUT/DELETE 请求的 body 不能用 request.getParameter() 读 (只有 POST 表单才会被容器解析)。 这个 filter 把表单 body 提前解析并包装 request,使 getParameter 可用。
两个都是 Ordered* 前缀的实现------Boot 给它们加了 Ordered 接口以便排序 (OrderedHiddenHttpMethodFilter 在 Boot 3.x 的 order 是 -10000)。(OrderedFormContentFilter 的默认 order 值我未核实。)
@ConditionalOnBooleanProperty是 3.5.0 新增的 (@since 3.5.0), 它比@ConditionalOnProperty多做了什么?看它的 Javadoc: "checks if the specified properties have a specific boolean value. By default the properties must be present in the Environment and equal totrue" 。 也就是havingValue默认就是true,不必每次写havingValue = "true"。 它是@Repeatable的(容器是ConditionalOnBooleanProperties),且底层复用OnPropertyCondition。 这是一个纯语法糖注解------不引入新的条件语义。
3.3 三层嵌套结构
java
WebMvcAutoConfiguration ← 外层:Filter
├── WebMvcAutoConfigurationAdapter implements WebMvcConfigurer, ServletContextAware
│ └── @Import(EnableWebMvcConfiguration.class) ← 中间层:配置器
│ └── EnableWebMvcConfiguration extends DelegatingWebMvcConfiguration ← 内层:=@EnableWebMvc
├── ResourceChainCustomizerConfiguration
├── ProblemDetailsErrorHandlingConfiguration
└── JacksonMixin...(无)
为什么要有 Adapter 这一层? 源码注释给出了答案:
java
// Defined as a nested config to ensure WebMvcConfigurer is not read when not
// on the classpath
@Configuration(proxyBeanMethods = false)
@Import(EnableWebMvcConfiguration.class)
@EnableConfigurationProperties({ WebMvcProperties.class, WebProperties.class })
@Order(0)
static class WebMvcAutoConfigurationAdapter implements WebMvcConfigurer, ServletContextAware {
"确保 WebMvcConfigurer 不在 classpath 时不会被读取" 。 外层类的 @ConditionalOnClass 里有 WebMvcConfigurer.class,所以理论上已经保证了。 但嵌套类的 @Import 是在类被解析时才展开的 ,外层类如果被条件排除,内部类根本不会被加载。 这里加一层是为了让 WebMvcConfigurer 这个接口类型只出现在内部类的方法签名/ implements 列表里, 即使外层因为某种原因被解析,内部类也能被独立排除。
这属于防御性分层:把"可能与缺失类型耦合的代码"隔离到单独的类里。
@Order(0) 让这个 WebMvcConfigurer 排在用户自定义 WebMvcConfigurer 之前 (用户的默认 order 是 LOWEST_PRECEDENCE)------Boot 的配置先应用,用户的后应用, 于是用户可以覆盖 Boot 的配置。这与第 14 章讲的"用户 Bean 优先"是同一原则的另一种实现。
3.4 构造注入:10 个参数,7 个是 ObjectProvider
java
WebMvcAutoConfigurationAdapter(WebProperties webProperties, WebMvcProperties mvcProperties,
ListableBeanFactory beanFactory,
ObjectProvider<ServerHttpMessageConvertersCustomizer> httpMessageConvertersCustomizerProvider,
ObjectProvider<ResourceHandlerRegistrationCustomizer> resourceHandlerRegistrationCustomizerProvider,
ObjectProvider<DispatcherServletPath> dispatcherServletPath,
ObjectProvider<ServletRegistrationBean<?>> servletRegistrations,
ObjectProvider<ApiVersionResolver> apiVersionResolvers,
ObjectProvider<ApiVersionParser<?>> apiVersionParser,
ObjectProvider<ApiVersionDeprecationHandler> apiVersionDeprecationHandler) { ... }
只有 WebProperties、WebMvcProperties、ListableBeanFactory 是强依赖 , 其余 7 个都是 ObjectProvider------因为它们的实现类来自其他可选模块 (ServerHttpMessageConvertersCustomizer 来自 spring-boot-http-converter, ApiVersion* 来自 Framework 但只是可选能力)。
resourceHandlerRegistrationCustomizer 是唯一一个在构造时就 getIfAvailable() 取值的:
java
this.resourceHandlerRegistrationCustomizer = resourceHandlerRegistrationCustomizerProvider.getIfAvailable();
其余都在使用时才 orderedStream()。原因:ResourceChainCustomizerConfiguration 是同文件内的另一个 嵌套配置 ,它注册的 ResourceChainResourceHandlerRegistrationCustomizer 需要在 addResourceHandlers 被调用前确定,而构造期取值能确保拿到。 (不过这依赖 bean 创建顺序,属于隐式时序耦合------不优雅但能工作。)
3.5 configureMessageConverters(ServerBuilder):4.x 的新签名
java
@Override
public void configureMessageConverters(ServerBuilder builder) {
this.httpMessageConvertersCustomizerProvider.orderedStream()
.forEach((customizer) -> customizer.customize(builder));
}
这是 Spring Framework 7 的变化 :WebMvcConfigurer.configureMessageConverters 的入参 从 List<HttpMessageConverter<?>> 变成了 ServerBuilder。 方法体只有两行------Boot 不再自己组装转换器列表,而是把定制器链转发给 Framework 的 builder。
这是一个职责移交 :转换器的"默认清单 + 排序 + 去重"逻辑从 Boot 搬到了 Framework。 第 6 节会看到 Boot 侧剩下的是什么(DefaultServerHttpMessageConvertersCustomizer)。
ServerBuilder/ClientBuilder是 Framework 7 引入的类型, 位于org.springframework.http.converter(不在org.springframework.web.servlet.config.annotation)。 它们同时服务 servlet 与 reactive 栈------这解释了为什么HttpMessageConvertersAutoConfiguration能同时给 MVC 和 WebClient 用。
3.6 configureAsyncSupport:与 TaskExecution 的耦合点
java
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
if (this.beanFactory.containsBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)) {
Object taskExecutor = this.beanFactory
.getBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME);
if (taskExecutor instanceof AsyncTaskExecutor asyncTaskExecutor) {
configurer.setTaskExecutor(asyncTaskExecutor);
}
}
Duration timeout = this.mvcProperties.getAsync().getRequestTimeout();
if (timeout != null) {
configurer.setDefaultTimeout(timeout.toMillis());
}
}
这里用的是 beanFactory.containsBean + getBean,不是 ObjectProvider ------ 因为要做 instanceof AsyncTaskExecutor 判断,ObjectProvider 的 ifAvailable(Consumer) 拿不到"不满足类型时跳过"的语义(其实可以,但这样写更直白)。
注意 getBean() 是即时获取 ,这会强制提前实例化 applicationTaskExecutor bean。 放在 configureAsyncSupport 里(而不是构造函数里)是为了推迟到 MVC 配置阶段, 此时容器已基本就绪。
这解释了类级 @AutoConfiguration(after = TaskExecutionAutoConfiguration.class) 的必要性: after 只影响配置类的解析顺序,不影响 bean 的创建顺序 。 真正保证 applicationTaskExecutor 存在的是 containsBean(按名字查定义,不需要实例)。 所以这里的 after 更像是文档性的------表达依赖关系,而非功能必需。
⚠️ 推测 :如果
applicationTaskExecutor是懒加载或由用户以别的名字提供,这段代码不会生效。 但containsBean按名字查的是bean 定义 ,只要TaskExecutionAutoConfiguration生效就一定有。spring.task.execution.*相关配置的细节我放到第 30 章。
3.7 configurePathMatch:PathPatternParser 是默认
java
@Override
@SuppressWarnings("removal")
public void configurePathMatch(PathMatchConfigurer configurer) {
if (this.mvcProperties.getPathmatch()
.getMatchingStrategy() == WebMvcProperties.MatchingStrategy.ANT_PATH_MATCHER) {
configurer.setPathMatcher(new AntPathMatcher());
this.dispatcherServletPath.ifAvailable((dispatcherPath) -> {
String servletUrlMapping = dispatcherPath.getServletUrlMapping();
if (servletUrlMapping.equals("/") && singleDispatcherServlet()) {
UrlPathHelper urlPathHelper = new UrlPathHelper();
urlPathHelper.setAlwaysUseFullPath(true);
configurer.setUrlPathHelper(urlPathHelper);
}
});
}
}
逻辑是"只有显式选 ANT_PATH_MATCHER 时才做事" ------默认不进入这个 if, 即默认使用 Framework 的 PathPatternParser(自 Spring MVC 5.3 起成为默认)。 @SuppressWarnings("removal") 说明 AntPathMatcher 相关 API 在 Framework 7 里已被标记为 待移除(forRemoval) ------PathPatternParser 是未来唯一选择。
singleDispatcherServlet() 的定义:
java
private boolean singleDispatcherServlet() {
return this.servletRegistrations.stream()
.map(ServletRegistrationBean::getServlet)
.filter(DispatcherServlet.class::isInstance)
.count() == 1;
}
只有恰好一个 DispatcherServlet 且映射到 / 时,才设置 alwaysUseFullPath(true)。 原因:多 DispatcherServlet 场景下 servletPath 与 pathInfo 的划分需要区分, alwaysUseFullPath 会抹掉这个区分。
3.8 三个视图解析器
java
@Bean
@ConditionalOnMissingBean
InternalResourceViewResolver defaultViewResolver() {
InternalResourceViewResolver resolver = new InternalResourceViewResolver();
resolver.setPrefix(this.mvcProperties.getView().getPrefix());
resolver.setSuffix(this.mvcProperties.getView().getSuffix());
return resolver;
}
@Bean
@ConditionalOnBean(View.class)
@ConditionalOnMissingBean
BeanNameViewResolver beanNameViewResolver() {
BeanNameViewResolver resolver = new BeanNameViewResolver();
resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10);
return resolver;
}
@Bean
@ConditionalOnBean(ViewResolver.class)
@ConditionalOnMissingBean(name = "viewResolver", value = ContentNegotiatingViewResolver.class)
ContentNegotiatingViewResolver viewResolver(BeanFactory beanFactory) {
ContentNegotiatingViewResolver resolver = new ContentNegotiatingViewResolver();
resolver.setContentNegotiationManager(beanFactory.getBean(ContentNegotiationManager.class));
// ContentNegotiatingViewResolver uses all the other view resolvers to locate
// a view so it should have a high precedence
resolver.setOrder(Ordered.HIGHEST_PRECEDENCE);
return resolver;
}
三个 @Bean 方法在 WebMvcConfigurer 实现类里------这是 Boot 常见的"配置器顺带定义 bean"模式。
order 的排布:
| 解析器 | order | 理由 |
|---|---|---|
viewResolver(ContentNegotiating) |
HIGHEST_PRECEDENCE |
它要先拿到机会,因为它会委派给其他所有解析器 |
defaultViewResolver(InternalResource) |
未显式设置(默认 LOWEST_PRECEDENCE) |
兜底 |
beanNameViewResolver |
LOWEST_PRECEDENCE - 10 |
比兜底高一点 |
ContentNegotiatingViewResolver 的注释很关键:"uses all the other view resolvers to locate a view" ------ 它不是"替代"其他解析器,而是"统领"它们,所以必须排在最前。
beanNameViewResolver 的条件是 @ConditionalOnBean(View.class)------ 只有容器里有 View 类型的 bean 才注册 。纯 REST 应用(只有 @RestController) 不会注册它,省一个 bean。
ContentNegotiatingViewResolver 的 @ConditionalOnMissingBean(name = "viewResolver", value = ...) 再次用到了 name + value 的 AND (第 15 章)。注意 bean 方法名就叫 viewResolver, 与 @ConditionalOnMissingBean(name = "viewResolver") 自洽------自己在检查自己。
3.9 addResourceHandlers:webjars 与静态资源
java
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
if (!this.resourceProperties.isAddMappings()) {
logger.debug("Default resource handling disabled");
return;
}
addResourceHandler(registry, this.mvcProperties.getWebjarsPathPattern(),
"classpath:/META-INF/resources/webjars/");
addResourceHandler(registry, this.mvcProperties.getStaticPathPattern(), (registration) -> {
registration.addResourceLocations(this.resourceProperties.getStaticLocations());
if (this.servletContext != null) {
ServletContextResource resource = new ServletContextResource(this.servletContext, SERVLET_LOCATION);
registration.addResourceLocations(resource);
}
});
}
要点:
-
spring.web.resources.add-mappings=false是总开关 ,关掉后两行都不执行 (注意前缀是spring.web.resources,不是spring.mvc)。 -
webjars 先注册,静态资源后注册 ------因为
spring.mvc.static-path-pattern默认/**,registry.hasMappingForPattern(pattern)会让先注册的赢 。 如果反过来,/**会吃掉/webjars/**。 -
logger的 logger 名是WebMvcConfigurer.class:javaprivate static final Log logger = LogFactory.getLog(WebMvcConfigurer.class);不是本类!这意味着所有
WebMvcConfigurer实现的这类日志共用同一个 logger 名 , 你按类名配日志级别时要配org.springframework.web.servlet.config.annotation.WebMvcConfigurer。 我倾向于认为这是有意为之(让用户能统一开关"MVC 配置类"的日志), 但也可能是 copy-paste。未核实其意图。 -
ServletContextResource(servletContext, "/")把 webapp 根 也加为资源位置------ WAR 部署时/src/main/webapp下的文件能被访问。这是ServletContextAware的用处。
addResourceHandler 的私有重载里有个 @Contract:
java
@Contract("!null -> !null")
private @Nullable Integer getSeconds(@Nullable Duration cachePeriod) {
return (cachePeriod != null) ? (int) cachePeriod.getSeconds() : null;
}
@Contract("!null -> !null") 告诉 IDE(主要是 IntelliJ)"入参非 null 则返回值非 null", 这样调用处的 if (cachePeriod != null) 判断后 IDE 不会报 NPE 警告。 第 17 章讲 DefaultApplicationContextFactory 时见过同样的用法------ 这是 Boot 4 大量引入 @Contract 的又一个证据。
3.10 configureApiVersioning:Framework 7 的新能力
java
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
PropertyMapper map = PropertyMapper.get();
Apiversion properties = this.mvcProperties.getApiversion();
map.from(properties::getRequired).to(configurer::setVersionRequired);
map.from(properties::getDefaultVersion).to(configurer::setDefaultVersion);
map.from(properties::getSupported).to((supported) -> supported.forEach(configurer::addSupportedVersions));
map.from(properties::getDetectSupported).to(configurer::detectSupportedVersions);
configureApiVersioningUse(configurer, properties.getUse());
this.apiVersionResolvers.orderedStream().forEach(configurer::useVersionResolver);
this.apiVersionParser.ifAvailable(configurer::setVersionParser);
this.apiVersionDeprecationHandler.ifAvailable(configurer::setDeprecationHandler);
}
API 版本控制 是 Spring Framework 7.0 新增的一等公民能力 (ApiVersionConfigurer、ApiVersionResolver、ApiVersionParser、ApiVersionDeprecationHandler)。 Boot 4.1 把它接到了 spring.mvc.apiversion.* 上。
PropertyMapper 是 Boot 内部的"空值安全映射器"(第 08 章讲 Binder 时见过), map.from(x).to(y) 只在 x 非 null 时才调用 y------避免了满屏的 if (x != null)。
configureApiVersioningUse 里有个细节:
java
map.from(use::getQueryParameter).whenHasText().to(configurer::useQueryParam);
whenHasText() 是额外的中间守卫(非空且 非空白字符串), 而 use::getHeader 只用了默认的非空判断。这个不一致 可能是刻意的 (空字符串的 query param 名没有意义),也可能是遗漏。未核实。
3.11 requestContextFilter
java
@Bean
@ConditionalOnMissingBean({ RequestContextListener.class, RequestContextFilter.class })
@ConditionalOnMissingFilterBean
static RequestContextFilter requestContextFilter() {
return new OrderedRequestContextFilter();
}
@ConditionalOnMissingBean({ A.class, B.class }):两个都缺才匹配 (第 15 章讲的对称性------isAnyMatched)。@ConditionalOnMissingFilterBean:Boot 特有的条件,检查容器里是否已注册同名 Filter (包括通过FilterRegistrationBean注册的)。这是@ConditionalOnMissingBean覆盖不到的场景: 用户可能注册的是FilterRegistrationBean<RequestContextFilter>而不是RequestContextFilter本身。
@ConditionalOnMissingFilterBean的实现机制我未深入核实 ------ 它大概是通过检查 bean 定义里的Filter类型 + 名字去重。第 15 章的条件注解清单里我漏了它, 这里补上。
3.12 EnableWebMvcConfiguration:@EnableWebMvc 的等价物
java
/**
* Configuration equivalent to {@code @EnableWebMvc}.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(WebProperties.class)
@ImportRuntimeHints(MvcValidatorRuntimeHints.class)
static class EnableWebMvcConfiguration extends DelegatingWebMvcConfiguration
implements ResourceLoaderAware, EmbeddedValueResolverAware {
注释直说了:"等价于 @EnableWebMvc" 。 DelegatingWebMvcConfiguration 就是 @EnableWebMvc 导入的那个类 (在 Framework 里 @EnableWebMvc → @Import(DelegatingWebMvcConfiguration.class))。 Boot 不通过注解 而是通过继承 来复用它,好处是能在子类里覆写 create* 方法。
覆写了哪些:
| 覆写方法 | 作用 |
|---|---|
createRequestMappingHandlerAdapter() |
交给 WebMvcRegistrations |
createRequestMappingHandlerMapping() |
交给 WebMvcRegistrations |
createExceptionHandlerExceptionResolver() |
交给 WebMvcRegistrations |
getConfigurableWebBindingInitializer(...) |
优先用容器里的 bean |
extendHandlerExceptionResolvers(...) |
spring.mvc.log-resolved-exception |
mvcConversionService() |
用 WebConversionService |
mvcValidator() |
接 validation 模块 |
localeResolver() |
spring.web.locale / spring.web.locale-resolver |
flashMapManager() |
加 @ConditionalOnMissingBean(name=...) |
viewNameTranslator() |
同上 |
mvcApiVersionStrategy() |
加 @ConditionalOnMissingBean(name = "mvcApiVersionStrategy") |
后三个的写法值得单独看:
java
@Override
@Bean
@ConditionalOnMissingBean(name = DispatcherServlet.FLASH_MAP_MANAGER_BEAN_NAME)
public FlashMapManager flashMapManager() {
return super.flashMapManager();
}
覆写父类方法,只为加一个 @ConditionalOnMissingBean ,方法体原样调 super。 为什么?因为 DispatcherServlet 是按固定名字 查找这些组件的 (FLASH_MAP_MANAGER_BEAN_NAME = "flashMapManager"), Boot 希望用户能用自己的 bean 覆盖------但父类的方法是无条件创建的, 加上条件注解后,用户定义一个同名 bean 就能顶掉。
这又是一个"按名字协作"的案例 ,与 2.2 节的 multipartResolver 一脉相承。
mvcConversionService():
java
@Bean
@Override
public FormattingConversionService mvcConversionService() {
Format format = this.mvcProperties.getFormat();
WebConversionService conversionService = new WebConversionService(this.embeddedValueResolver,
new DateTimeFormatters().dateFormat(format.getDate())
.timeFormat(format.getTime())
.dateTimeFormat(format.getDateTime()));
addFormatters(conversionService);
return conversionService;
}
WebConversionService 是 Boot 的子类,增加了对 spring.mvc.format.date/time/date-time 的支持。 addFormatters(conversionService) 会回调所有 WebMvcConfigurer.addFormatters------ 包括本类自己的:
java
@Override
public void addFormatters(FormatterRegistry registry) {
ApplicationConversionService.addBeans(registry, this.beanFactory);
}
ApplicationConversionService.addBeans 把容器里所有 Converter/Formatter/GenericConverter 类型的 bean 注册进去。这是用户自定义类型转换器的入口 (第 08 章讲过 ApplicationConversionService)。
3.13 mvcValidator 的 ClassUtils.isPresent 双检
java
@Bean
@Override
public Validator mvcValidator() {
if (!ClassUtils.isPresent("jakarta.validation.Validator", getClass().getClassLoader())
|| !ClassUtils.isPresent("org.springframework.boot.validation.autoconfigure.ValidatorAdapter",
getClass().getClassLoader())) {
return super.mvcValidator();
}
ApplicationContext applicationContext = getApplicationContext();
Assert.state(applicationContext != null, "'applicationContext' must not be null");
return ValidatorAdapter.get(applicationContext, getValidator());
}
用字符串类名做 isPresent 检查 ,而不是 .class 字面量。 原因和 @AutoConfiguration.afterName 一样:webmvc 模块不依赖 validation 模块 。 如果用 .class,编译期就必须有依赖。
两个检查:
jakarta.validation.Validator------ Jakarta Bean Validation API 在不在org.springframework.boot.validation.autoconfigure.ValidatorAdapter------ Boot 的 validation 模块在不在
两个都满足 才走 Boot 的 ValidatorAdapter(它会用容器里的 LocalValidatorFactoryBean), 否则退回 Framework 的默认(一个什么都不校验的 OptionalValidatorFactoryBean 或类似)。
对应的 MvcValidatorRuntimeHints:
java
static class MvcValidatorRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
hints.reflection()
.registerType(TypeReference.of("org.springframework.boot.validation.autoconfigure.ValidatorAdapter"));
}
}
同样用字符串 (TypeReference.of(String))------为 GraalVM 注册反射提示。 因为这段代码路径是在运行时通过反射走的,native image 的静态分析看不到。
这里有个值得注意的问题:
registerHints无条件注册 , 即使 validation 模块不在 classpath 上。注册一个不存在的类型的反射提示会怎样? 推测:GraalVM 在运行时找不到该类型会忽略(或警告)。未核实。
3.14 WebMvcRegistrations:替换而非配置
java
public interface WebMvcRegistrations {
default @Nullable RequestMappingHandlerMapping getRequestMappingHandlerMapping() { return null; }
default @Nullable RequestMappingHandlerAdapter getRequestMappingHandlerAdapter() { return null; }
default @Nullable ExceptionHandlerExceptionResolver getExceptionHandlerExceptionResolver() { return null; }
}
Javadoc 说得很清楚:"A single instance of this component should be registered, otherwise making it impossible to choose from redundant MVC components."
对应代码:
java
this.mvcRegistrations = mvcRegistrationsProvider.getIfUnique();
getIfUnique() ------如果有多个 WebMvcRegistrations bean,返回 null (不是抛异常)。 所以"注册多个"的后果是全部失效,而不是报错。这与 Javadoc 的警告一致, 但"静默失效"这个选择值得留意。
与 WebMvcConfigurer 的区别 (Javadoc 明确说了): WebMvcRegistrations 提供的是未经 Boot/Framework 后续加工的实例 , "To participate in, and if desired, override that subsequent processing, WebMvcConfigurer should be used"。
也就是说:
- 想改配置 → 用
WebMvcConfigurer(有configure*回调) - 想换实现 → 用
WebMvcRegistrations(直接给一个实例)
WebMvcRegistrations 只有 3 个方法,覆盖面很窄------Boot 刻意限制了"可替换的组件"范围。
3.15 ResourceChainCustomizerConfiguration
java
@Configuration(proxyBeanMethods = false)
@ConditionalOnEnabledResourceChain
static class ResourceChainCustomizerConfiguration {
@Bean
ResourceChainResourceHandlerRegistrationCustomizer resourceHandlerRegistrationCustomizer(
WebProperties webProperties) {
return new ResourceChainResourceHandlerRegistrationCustomizer(webProperties.getResources());
}
}
@ConditionalOnEnabledResourceChain 是 Boot 特有的条件注解(在 org.springframework.boot.autoconfigure.web 包下,注意不在 condition 子包)。 它检查 spring.web.resources.chain.enabled(或旧名 spring.resources.chain.enabled)。
configureResourceChain 的逻辑:
java
private void configureResourceChain(Resources.Chain properties, ResourceChainRegistration chain) {
Strategy strategy = properties.getStrategy();
if (properties.isCompressed()) {
chain.addResolver(new EncodedResourceResolver());
}
if (strategy.getFixed().isEnabled() || strategy.getContent().isEnabled()) {
chain.addResolver(getVersionResourceResolver(strategy));
}
}
资源链 的作用是给静态资源加版本号(/js/app-abc123.js),配合 cache-period 做长效缓存。 EncodedResourceResolver 支持 gzip/brotli 预压缩文件。
⚠️ 未核实 :
spring.web.resources.chain.enabled的默认值。 从@ConditionalOnEnabledResourceChain的语义推断默认是关闭的(否则条件注解没意义), 但我没读它的实现确认。
3.16 ProblemDetailsErrorHandlingConfiguration
java
@Configuration(proxyBeanMethods = false)
@ConditionalOnBooleanProperty("spring.mvc.problemdetails.enabled")
static class ProblemDetailsErrorHandlingConfiguration {
@Bean
@ConditionalOnMissingBean(ResponseEntityExceptionHandler.class)
@Order(0)
ProblemDetailsExceptionHandler problemDetailsExceptionHandler() {
return new ProblemDetailsExceptionHandler();
}
}
spring.mvc.problemdetails.enabled=true 后,异常会按 RFC 9457(Problem Details) 格式返回:
json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/hello"
}
ProblemDetailsExceptionHandler 的实现极简:
java
@ControllerAdvice
class ProblemDetailsExceptionHandler extends ResponseEntityExceptionHandler {
}
一个空的 @ControllerAdvice ------全部行为继承自 Framework 的 ResponseEntityExceptionHandler。 Boot 只负责"注册它",开关由 @ConditionalOnBooleanProperty 控制, 用户自定义覆盖由 @ConditionalOnMissingBean(ResponseEntityExceptionHandler.class) 控制。
这是**"零代码自动配置"**的典型:Boot 加的能力 = 把框架已有类的一个实例放进容器。
4. ErrorMvcAutoConfiguration:错误处理
4.1 为什么要 before = WebMvcAutoConfiguration
java
// Load before the main WebMvcAutoConfiguration so that the error View is available
@AutoConfiguration(before = WebMvcAutoConfiguration.class)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class })
@EnableConfigurationProperties({ WebProperties.class, WebMvcProperties.class })
public final class ErrorMvcAutoConfiguration {
注释解释了 before 的理由 :"so that the error View is available" 。 WhitelabelErrorViewConfiguration 会注册一个名叫 error 的 View bean; WebMvcAutoConfiguration 里的 BeanNameViewResolver 需要看到 View 类型的 bean 存在 (@ConditionalOnBean(View.class))才能注册。 如果顺序反了,BeanNameViewResolver 可能因为看不到 error view 而不注册, 导致白标错误页找不到解析器。
严格说,
@ConditionalOnBean的求值发生在配置类解析 阶段, 而before只影响自动配置类的排序 。这个before是否真的必要, 取决于OnBeanCondition能否看到尚未解析的配置类里的 bean 定义------ 它不能 。所以before在这里是必需的,注释是正确的。
4.2 两个核心 bean
java
@Bean
@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
DefaultErrorAttributes errorAttributes() {
return new DefaultErrorAttributes();
}
@Bean
@ConditionalOnMissingBean(value = ErrorController.class, search = SearchStrategy.CURRENT)
BasicErrorController basicErrorController(ErrorAttributes errorAttributes,
ObjectProvider<ErrorViewResolver> errorViewResolvers) {
return new BasicErrorController(errorAttributes, this.webProperties.getError(),
errorViewResolvers.orderedStream().toList());
}
注意 search = SearchStrategy.CURRENT ------这是第 15 章重点讲过的搜索策略。 用 CURRENT 意味着只在当前上下文找,不去父上下文。
为什么?考虑 Actuator 有独立管理端口的场景:子上下文(Actuator)自己也需要 ErrorController。 如果用默认策略(ALL),子上下文会看到父上下文的 ErrorController 而不创建自己的 , 于是管理端口的错误处理会跑到主应用的 controller 上。用 CURRENT 保证每层上下文各管各的。
BasicErrorController 同时处理:
text/html→ 返回错误页面(走 view resolver)- 其他(
application/json)→ 返回{"timestamp":..., "status":..., "error":..., "path":...}
这个"一个 Controller,两种输出 "的设计靠 @RequestMapping(produces = MediaType.TEXT_HTML_VALUE) 与默认方法的方法级重载实现。
4.3 ErrorPageCustomizer 与 server.error.path 的联动
java
@Bean
ErrorPageCustomizer errorPageCustomizer(DispatcherServletPath dispatcherServletPath) {
return new ErrorPageCustomizer(this.webProperties, dispatcherServletPath);
}
ErrorPageCustomizer 是 ErrorPageRegistrar(Boot 的接口), 它把 /error 路径注册成容器的错误页 。它接收 DispatcherServletPath------ 因为错误页路径要相对于 DispatcherServlet 的路径 。 如果 spring.mvc.servlet.path=/app,错误页就是 /app/error。
⚠️ 未核实 :
ErrorPageCustomizer的完整实现(在文件末尾的ErrorPageRegistrar内部类里) 我只看到了它的 bean 定义,没读到getPath()的具体拼接逻辑。 推断它用了dispatcherServletPath.getPrefix() + errorPath,但未确认。
4.4 PreserveErrorControllerTargetClassPostProcessor
java
@Bean
static PreserveErrorControllerTargetClassPostProcessor preserveErrorControllerTargetClassPostProcessor() {
return new PreserveErrorControllerTargetClassPostProcessor();
}
一个 static 的 @Bean 方法------这是 BeanPostProcessor 的标准写法 (static 方法不需要先实例化配置类,避免 BPP 触发配置类的过早初始化,第 16 章讲过)。
从名字推断它的作用:保留 ErrorController 的 target class 。 BasicErrorController 有 @RequestMapping,如果被 AOP 代理(比如加了 @Transactional 或 Actuator 的某些切面),CGLIB 代理会丢失原始类的注解信息, 导致 RequestMappingHandlerMapping 扫不到映射。 这个 BPP 大概是把代理的 target class 记录下来供后续使用。
⚠️ 未核实 :
PreserveErrorControllerTargetClassPostProcessor的实现体我未读。 上面的解释是根据类名和 BeanPostProcessor 模式的推断 。 自测方法 :读该文件尾部的内部类定义,或搜索ErrorController与getTargetClass的联用点。
4.5 Whitelabel 的两个条件
java
@Configuration(proxyBeanMethods = false)
@ConditionalOnBooleanProperty(name = "spring.web.error.whitelabel.enabled", matchIfMissing = true)
@Conditional(ErrorTemplateMissingCondition.class)
protected static class WhitelabelErrorViewConfiguration {
private final StaticView defaultErrorView = new StaticView();
@Bean(name = "error")
@ConditionalOnMissingBean(name = "error")
View defaultErrorView() {
return this.defaultErrorView;
}
// If the user adds @EnableWebMvc then the bean name view resolver from
// WebMvcAutoConfiguration disappears, so add it back in to avoid disappointment.
@Bean
@ConditionalOnMissingBean
BeanNameViewResolver beanNameViewResolver() {
BeanNameViewResolver resolver = new BeanNameViewResolver();
resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10);
return resolver;
}
}
三个要点:
-
spring.web.error.whitelabel.enabled默认 true (matchIfMissing = true)。 注意前缀是spring.web.error,不是server.error。 -
@Conditional(ErrorTemplateMissingCondition.class)------ 自定义条件:javaTemplateAvailabilityProvider provider = providers.getProvider("error", context.getEnvironment(), classLoader, context.getResourceLoader()); if (provider != null) { return ConditionOutcome.noMatch(message.foundExactly("template from " + provider)); } return ConditionOutcome.match(message.didNotFind("error template view").atAll());只有当用户没有提供
error模板(Thymeleaf/Freemarker 等)时才显示白标页。 这是"有更好的就用更好的"逻辑。 -
beanNameViewResolver的注释非常有价值 : "If the user adds@EnableWebMvcthen the bean name view resolver fromWebMvcAutoConfigurationdisappears, so add it back in to avoid disappointment."这是 3.1 节
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)的补偿措施 。 用户加了@EnableWebMvc→WebMvcAutoConfiguration全部失效 →BeanNameViewResolver没了 →errorview 找不到解析器 → 白标页也显示不出来。 Boot 在错误处理里单独补一个BeanNameViewResolver, 保证即使@EnableWebMvc生效,白标错误页仍然工作。"to avoid disappointment"(免得让人失望)------Boot 源码里少见的口语化注释, 也侧面说明这是个事后补丁:先有人踩坑,再补的。
4.6 StaticView 与 response.isCommitted()
java
if (response.isCommitted()) {
String message = getMessage(model);
logger.error(message);
return; // ★ 已经提交,只能记日志
}
getMessage 的文案解释了后果:"Cannot render error page for request ... ... the response may have the wrong status code"。
这就是那个经典场景:Controller 已经写了 200 和一部分 body 然后抛异常, 状态码已发走改不了。日志里这句话是排查"为什么错误页没出来/状态码不对"的直接线索。
StaticView 的 HTML 是硬编码字符串拼接 ,所有插值都过 HtmlUtils.htmlEscape (防 XSS------message、trace 里可能含用户输入)。
4.7 DefaultErrorViewResolver
java
@Configuration(proxyBeanMethods = false)
static class DefaultErrorViewResolverConfiguration {
@Bean
@ConditionalOnBean(DispatcherServlet.class)
@ConditionalOnMissingBean(ErrorViewResolver.class)
DefaultErrorViewResolver conventionErrorViewResolver() {
return new DefaultErrorViewResolver(this.applicationContext, this.resources);
}
}
按约定 解析错误视图:先找 error/404.html、error/5xx.html(精确状态码 → 系列码), 再找 error.html。这是 Spring Boot 文档里"自定义错误页"的第一种方式 (第二种是实现 ErrorViewResolver)。
@ConditionalOnBean(DispatcherServlet.class) 保证只在 MVC 场景下生效。
5. 欢迎页:两个 HandlerMapping 的分工
5.1 WelcomePage.resolve 的优先级
java
static WelcomePage resolve(TemplateAvailabilityProviders templateAvailabilityProviders,
ApplicationContext applicationContext, @Nullable Resource indexHtmlResource, String staticPathPattern) {
if (indexHtmlResource != null && "/**".equals(staticPathPattern)) {
return new WelcomePage("forward:index.html", false); // ① 静态 index.html
}
if (templateAvailabilityProviders.getProvider("index", applicationContext) != null) {
return new WelcomePage("index", true); // ② 模板 index
}
return UNRESOLVED;
}
优先级:静态 index.html > 模板 index 。Javadoc 也明说: "If both a static and templated index page are available, the static page is preferred."
注意 "/**".equals(staticPathPattern) 这个硬编码判断 : 如果你改了 spring.mvc.static-path-pattern(比如改成 /static/**), 欢迎页功能会直接失效 (连模板分支也进不去?------不,模板分支在 if 外面,仍然有效)。 所以精确说:改了 static-path-pattern 后,静态 index.html 不再作为欢迎页 ,但模板 index 仍然可以。
这是一个容易踩的坑 :改了静态资源路径,首页突然 404, 而且日志里不会有明显提示(除非
WelcomePageHandlerMapping打了logger.info("Adding welcome page..."), 但那是"添加成功"的日志,"没添加"没有日志)。
5.2 两个 HandlerMapping
java
@Bean
WelcomePageHandlerMapping welcomePageHandlerMapping(ApplicationContext applicationContext,
FormattingConversionService mvcConversionService, ResourceUrlProvider mvcResourceUrlProvider) {
return createWelcomePageHandlerMapping(..., WelcomePageHandlerMapping::new);
}
@Bean
WelcomePageNotAcceptableHandlerMapping welcomePageNotAcceptableHandlerMapping(
ApplicationContext applicationContext, FormattingConversionService mvcConversionService,
ResourceUrlProvider mvcResourceUrlProvider) {
return createWelcomePageHandlerMapping(..., WelcomePageNotAcceptableHandlerMapping::new);
}
两个 bean 用同一个工厂方法创建,只有最后一个参数不同 ------ WelcomePageHandlerMappingFactory<T> 是函数式接口(T::new 作为构造器引用)。
WelcomePageHandlerMapping:正常处理,只接受Accept: text/html(见下)WelcomePageNotAcceptableHandlerMapping:当请求不接受 HTML 时, 返回406 Not Acceptable而不是 404/200
为什么需要后者?考虑 curl http://localhost:8080/ ------ curl 默认发 Accept: */*, 它"接受" HTML,所以会返回 index.html。但如果是 Accept: application/json 的请求打根路径, 语义上应该是"你要 JSON,但根路径没有 JSON 表示" → 406,而不是返回 HTML。
5.3 WelcomePageHandlerMapping 的 Accept 检查
java
@Override
public @Nullable Object getHandlerInternal(HttpServletRequest request) throws Exception {
return (!isHtmlTextAccepted(request)) ? null : super.getHandlerInternal(request);
}
private boolean isHtmlTextAccepted(HttpServletRequest request) {
for (MediaType mediaType : getAcceptedMediaTypes(request)) {
if (mediaType.includes(MediaType.TEXT_HTML)) {
return true;
}
}
return false;
}
private List<MediaType> getAcceptedMediaTypes(HttpServletRequest request) {
String acceptHeader = request.getHeader(HttpHeaders.ACCEPT);
if (StringUtils.hasText(acceptHeader)) {
try {
return MediaType.parseMediaTypes(acceptHeader);
}
catch (InvalidMediaTypeException ex) {
logger.warn("Received invalid Accept header. Assuming all media types are accepted",
logger.isDebugEnabled() ? ex : null);
}
}
return MEDIA_TYPES_ALL; // 无 Accept 头 → 视为接受一切
}
logger.warn(..., logger.isDebugEnabled() ? ex : null) 这个写法值得学习: 只在 DEBUG 开启时才把异常对象传进去(省去构造异常的开销/避免日志噪音), WARN 级别只打消息。这是 Boot 里处理"可容忍的解析失败"的标准姿势。
setOrder(2) 在构造函数里设置------欢迎页 HandlerMapping 的优先级是 2, 比 RequestMappingHandlerMapping(order=0)低 , 所以 @GetMapping("/") 的控制器优先于静态 index.html。
5.4 getIndexHtmlResource 的三级查找
java
private @Nullable Resource getIndexHtmlResource() {
for (String location : this.resourceProperties.getStaticLocations()) {
Resource indexHtml = getIndexHtmlResource(location);
if (indexHtml != null) {
return indexHtml; // ① 静态目录
}
}
ServletContext servletContext = getServletContext();
if (servletContext != null) {
return getIndexHtmlResource(new ServletContextResource(servletContext, SERVLET_LOCATION)); // ② webapp 根
}
return null;
}
private @Nullable Resource getIndexHtmlResource(Resource location) {
try {
Resource resource = location.createRelative("index.html");
if (resource.exists() && (resource.getURL() != null)) {
return resource;
}
}
catch (Exception ex) {
// Ignore
}
return null;
}
resource.getURL() != null 这个检查看起来多余(exists() 已经通过了), 但 getURL() 可能抛 IOException,这里一并 catch 了。 "Ignore" 式的 catch 在资源探测代码里很常见------探测失败 = 不存在。
6. HTTP 消息转换:4.x 的新模型
6.1 HttpMessageConvertersAutoConfiguration:5 个 @Import
java
@SuppressWarnings("removal")
@AutoConfiguration(afterName = { "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration",
"org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration",
"org.springframework.boot.jsonb.autoconfigure.JsonbAutoConfiguration",
"org.springframework.boot.gson.autoconfigure.GsonAutoConfiguration",
"org.springframework.boot.kotlinx.serialization.json.autoconfigure.KotlinxSerializationJsonAutoConfiguration" })
@ConditionalOnClass(HttpMessageConverter.class)
@Conditional(NotReactiveWebApplicationCondition.class)
@Import({ JacksonHttpMessageConvertersConfiguration.class, Jackson2HttpMessageConvertersConfiguration.class,
GsonHttpMessageConvertersConfiguration.class, JsonbHttpMessageConvertersConfiguration.class,
KotlinSerializationHttpMessageConvertersConfiguration.class })
public final class HttpMessageConvertersAutoConfiguration {
static final String PREFERRED_MAPPER_PROPERTY = "spring.http.converters.preferred-json-mapper";
五个 JSON 库全部用 afterName + @Import ------ 因为它们都是可选依赖 ,spring-boot-http-converter 模块不能在编译期引用它们的类 (只能用字符串名排序,只能用 @Import 引入在各自内部类里做 @ConditionalOnClass)。
这是一个统一的模式:
java
外层自动配置
├── afterName = "..."(字符串,表达顺序)
└── @Import(内部配置类)
内部配置类
├── @ConditionalOnClass(第三方库的类) ← 真正的依赖检查
├── @ConditionalOnBean(...) ← 检查对应的 mapper bean
└── @ConditionalOnProperty(...) ← 检查是否被选为 preferred
6.2 NotReactiveWebApplicationCondition:反向条件
java
static class NotReactiveWebApplicationCondition extends NoneNestedConditions {
NotReactiveWebApplicationCondition() {
super(ConfigurationPhase.PARSE_CONFIGURATION);
}
@ConditionalOnWebApplication(type = Type.REACTIVE)
private static final class ReactiveWebApplication {
}
}
NoneNestedConditions 是 Boot 的"取反"包装------内部类上的所有条件都不成立 时才匹配。 即:不是 reactive web 应用才生效。
这解释了为什么 WebFlux 应用里这个自动配置不生效------ reactive 栈有自己的 HttpMessageConverter/Codec 体系(第 20 章)。
注意 ConfigurationPhase.PARSE_CONFIGURATION:这个条件在配置解析阶段 求值, 而不是默认的 REGISTER_BEAN 阶段。原因是 @ConditionalOnWebApplication 需要看上下文类型, 而上下文类型在解析阶段就能确定。
6.3 两个默认定制器
java
@Bean
@Order(0)
@SuppressWarnings("deprecation")
ClientHttpMessageConvertersCustomizer clientConvertersCustomizer(
ObjectProvider<HttpMessageConverters> legacyConverters,
ObjectProvider<HttpMessageConverter<?>> converters) {
return new DefaultClientHttpMessageConvertersCustomizer(legacyConverters.getIfAvailable(),
converters.orderedStream().toList());
}
@Bean
@Order(0)
@SuppressWarnings("deprecation")
ServerHttpMessageConvertersCustomizer serverConvertersCustomizer(
ObjectProvider<HttpMessageConverters> legacyConverters,
ObjectProvider<HttpMessageConverter<?>> converters) {
return new DefaultServerHttpMessageConvertersCustomizer(legacyConverters.getIfAvailable(),
converters.orderedStream().toList());
}
@Order(0) + 两个 bean 都注入 HttpMessageConverters(已废弃的旧类型)。
6.4 DefaultServerHttpMessageConvertersCustomizer:legacy 分支的陷阱
java
@Override
public void customize(ServerBuilder builder) {
if (this.legacyConverters != null) {
this.legacyConverters.forEach(builder::addCustomConverter);
}
else {
builder.registerDefaults();
this.converters.forEach((converter) -> {
if (converter instanceof KotlinSerializationJsonHttpMessageConverter) {
builder.withKotlinSerializationJsonConverter(converter);
}
else {
builder.addCustomConverter(converter);
}
});
}
}
这段代码有一个非常重要的语义:
- 有 legacy
HttpMessageConvertersbean → 只 添加用户给的,不调registerDefaults() - 没有 → 调
registerDefaults()(Framework 的默认转换器集)+ 用户自定义的
为什么这样? 因为旧的 HttpMessageConverters 类的语义是"完整列表 "------ 用户在 Boot 2.x/3.x 里定义 HttpMessageConverters bean 时,通常是:
java
@Bean
HttpMessageConverters customConverters() {
return new HttpMessageConverters(new MyConverter()); // 内部已包含默认集
}
HttpMessageConverters 的构造器会自动合并默认的 。所以如果用户提供了它, 再调 registerDefaults() 会重复。
这是一个向后兼容的妥协 :新模型(Builder + 定制器)与旧模型(HttpMessageConverters 列表) 语义不同,Boot 用"if legacy != null"把它们分开,代价是新模型的"默认集"逻辑在 legacy 模式下失效。
KotlinSerializationJsonHttpMessageConverter 被单独拎出来用 withKotlinSerializationJsonConverter (而不是 addCustomConverter)------因为它需要用 builder 的专用槽位 (与 withJsonConverter 类似), 否则会被当成普通自定义转换器排在错误的位置。
6.5 JacksonHttpMessageConvertersConfiguration
java
@Configuration(proxyBeanMethods = false)
class JacksonHttpMessageConvertersConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(JsonMapper.class)
@ConditionalOnBean(JsonMapper.class)
@ConditionalOnProperty(name = HttpMessageConvertersAutoConfiguration.PREFERRED_MAPPER_PROPERTY,
havingValue = "jackson", matchIfMissing = true)
static class JacksonJsonHttpMessageConverterConfiguration {
@Bean
@Order(0)
@ConditionalOnMissingBean(value = JacksonJsonHttpMessageConverter.class,
ignoredType = { "org.springframework.hateoas.server.mvc.TypeConstrainedJacksonJsonHttpMessageConverter",
"org.springframework.data.rest.webmvc.alps.AlpsJacksonJsonHttpMessageConverter" })
JacksonJsonHttpMessageConvertersCustomizer jacksonJsonHttpMessageConvertersCustomizer(JsonMapper jsonMapper) {
return new JacksonJsonHttpMessageConvertersCustomizer(jsonMapper);
}
}
...
}
三重条件:
@ConditionalOnClass(JsonMapper.class)------ Jackson 3 在 classpath@ConditionalOnBean(JsonMapper.class)------ 容器里有 JsonMapper bean (由JacksonAutoConfiguration提供,这就是afterName排序的意义)@ConditionalOnProperty(preferred-json-mapper, havingValue="jackson", matchIfMissing=true)------ 默认就是 jackson
ignoredType 属性是这里的亮点:
java
@ConditionalOnMissingBean(value = JacksonJsonHttpMessageConverter.class,
ignoredType = { "org.springframework.hateoas.server.mvc.TypeConstrainedJacksonJsonHttpMessageConverter",
"org.springframework.data.rest.webmvc.alps.AlpsJacksonJsonHttpMessageConverter" })
ignoredType 接受字符串类名 ,作用是:检查 @ConditionalOnMissingBean 时, 忽略这些子类 。原因是 Spring HATEOAS 与 Spring Data REST 会提供 JacksonJsonHttpMessageConverter 的子类 bean, 如果不忽略,@ConditionalOnMissingBean 会因为"已有该类型的 bean"而不注册 Boot 的。
但问题是:HATEOAS/Data REST 的这些子类是"特化"的 (只处理某些类型), 不能替代通用的 JSON 转换器。所以 Boot 必须仍然注册自己的。
ignoredType是 2.x 就有的属性(我记得是@since 2.1左右),本章未核实具体版本。 它的实现在OnBeanCondition里:读取ignoredType的类名, 在getBeanNamesForType的结果里排除这些类型(含子类)。
XML 分支没有 preferred 检查:
java
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(XmlMapper.class)
@ConditionalOnBean(XmlMapper.class)
protected static class JacksonXmlHttpMessageConverterConfiguration {
@Bean
@Order(0)
@ConditionalOnMissingBean(JacksonXmlHttpMessageConverter.class)
JacksonXmlHttpMessageConvertersCustomizer jacksonXmlHttpMessageConvertersCustomizer(XmlMapper xmlMapper) { ... }
}
因为 XML 不参与"preferred json mapper"的选择------那是 JSON 库之间的竞争。
6.6 preferred-json-mapper 的五种取值
从 @Import 的五个配置类可以推出合法值(这是推断,不是从元数据文件读的):
spring.http.converters.preferred-json-mapper |
生效的配置 |
|---|---|
jackson(默认) |
JacksonHttpMessageConvertersConfiguration |
jackson2 |
Jackson2HttpMessageConvertersConfiguration |
gson |
GsonHttpMessageConvertersConfiguration |
jsonb |
JsonbHttpMessageConvertersConfiguration |
kotlinx-serialization |
KotlinSerializationHttpMessageConvertersConfiguration(推断的值名) |
⚠️ 未核实 :
kotlinx-serialization对应的havingValue字符串我没有读KotlinSerializationHttpMessageConvertersConfiguration确认。 元数据文件(additional-spring-configuration-metadata.json)里可能有 hints,未查。
6.7 整条链路的串联
java
JacksonAutoConfiguration
@Bean @Primary JsonMapper ← jsonMapperBuilder(prototype) ← 被所有 JsonMapperBuilderCustomizer 定制
│
HttpMessageConvertersAutoConfiguration │(@Order(0) 的 DefaultServerHttpMessageConvertersCustomizer)
└─ ServerBuilder.registerDefaults() + addCustomConverter(...)
│
JacksonHttpMessageConvertersConfiguration│(@Order(0))
└─ builder.withJsonConverter(new JacksonJsonHttpMessageConverter(jsonMapper))
│
WebMvcAutoConfigurationAdapter.configureMessageConverters(ServerBuilder)
└─ httpMessageConvertersCustomizerProvider.orderedStream().forEach(c -> c.customize(builder))
│
Framework: RequestMappingHandlerAdapter ← 从 ServerBuilder 拿最终列表
两个定制器都是 @Order(0),相同 order 时按 bean 定义顺序排------ 默认集先装、Jackson 转换器后装,后者才会生效。
⚠️ 未核实 :
withJsonConverter是"替换默认 JSON 转换器"还是"追加"。 从withvsadd的命名推断是替换,但未确认。
6.8 StringHttpMessageConverter 的字符集
java
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(HttpMessageConvertersProperties.class)
protected static class StringHttpMessageConverterConfiguration {
@Bean
@ConditionalOnMissingBean(StringHttpMessageConverter.class)
StringHttpMessageConvertersCustomizer stringHttpMessageConvertersCustomizer(
HttpMessageConvertersProperties properties) {
return new StringHttpMessageConvertersCustomizer(properties);
}
}
static class StringHttpMessageConvertersCustomizer
implements ClientHttpMessageConvertersCustomizer, ServerHttpMessageConvertersCustomizer {
StringHttpMessageConverter converter;
StringHttpMessageConvertersCustomizer(HttpMessageConvertersProperties properties) {
this.converter = new StringHttpMessageConverter(properties.getStringEncodingCharset());
this.converter.setWriteAcceptCharset(false);
}
...
}
spring.http.converters.string-encoding-charset 默认 UTF-8 , setWriteAcceptCharset(false) 关掉了 Accept-Charset 响应头 (这个头在现代浏览器里已无意义,且会泄露信息)。
注意 converter 字段是包可见(非 private)------Boot 源码里这类"随意"的可见性不少见。
7. Jackson 3:一次包坐标的断裂
7.1 两个模块、两套坐标
gradle.properties:
properties
jackson2Version=2.21.5
jacksonVersion=3.1.5
buildSrc/build.gradle:
groovy
implementation(platform("com.fasterxml.jackson:jackson-bom:${jackson2Version}"))
implementation(platform("tools.jackson:jackson-bom:${jacksonVersion}"))
Jackson 3 的 Maven groupId 从 com.fasterxml.jackson 变成了 tools.jackson ------ 这是 Jackson 3 最显眼的变化,也是兼容性上最痛的一点: 同一个 classpath 上可以同时存在 Jackson 2 和 Jackson 3(因为包名不同), 但你的代码只能选一套。
Boot 4.1 的做法是两个模块并存:
| 模块 | Jackson 版本 | 包 | 状态 |
|---|---|---|---|
spring-boot-jackson |
3.1.5 | org.springframework.boot.jackson |
默认 |
spring-boot-jackson2 |
2.21.5 | org.springframework.boot.jackson2 |
@Deprecated forRemoval,4.3.0 移除 |
对应的注解也全部改名:
| Jackson 2(旧) | Jackson 3(新) |
|---|---|
@JsonComponent |
@JacksonComponent |
@JsonMixin |
@JacksonMixin |
JsonObjectSerializer |
ObjectValueSerializer |
JsonObjectDeserializer |
ObjectValueDeserializer |
ObjectMapper |
JsonMapper |
Module |
JacksonModule |
注意 JsonMapper 这个名字:Jackson 3 把"JSON 专用的 mapper"从 ObjectMapper 改名为 JsonMapper,而 ObjectMapper 这个名字在 Jackson 3 里变成了......
⚠️ 未核实 :Jackson 3 里
ObjectMapper是否还存在(可能是MapperBuilder的基类 或已被移除)。从 Boot 代码看,JsonMapper.builder(JsonFactory)是入口,CBORMapper.builder(...)、XmlMapper.builder(...)并列------ 说明 Jackson 3 里每种格式有自己的 Mapper 类 ,不再有一个"万能的 ObjectMapper"。 这个推断与AbstractMapperBuilderCustomizer<B extends MapperBuilder<?, ?>>的泛型边界一致。
7.2 JacksonAutoConfiguration 的 bean 图
java
@AutoConfiguration
@ConditionalOnClass(JsonMapper.class)
public final class JacksonAutoConfiguration {
@Bean
JacksonComponentModule jsonComponentModule() { return new JacksonComponentModule(); }
@Bean
@ConditionalOnMissingBean
JsonFactory jsonFactory(ObjectProvider<JsonFactoryBuilderCustomizer> customizers) {
JsonFactoryBuilder builder = JsonFactory.builder();
customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
return builder.build();
}
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@ConditionalOnMissingBean
JsonMapper.Builder jsonMapperBuilder(ObjectProvider<JsonMapperBuilderCustomizer> customizers,
JsonFactory jsonFactory) {
JsonMapper.Builder builder = JsonMapper.builder(jsonFactory);
customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
return builder;
}
@Bean
@Primary
@ConditionalOnMissingBean
JsonMapper jacksonJsonMapper(JsonMapper.Builder builder) {
return builder.build();
}
...
}
四层结构:
scss
JsonFactoryBuilder --(JsonFactoryBuilderCustomizer)--> JsonFactory
│
▼
JsonMapper.Builder(JsonFactory) --(JsonMapperBuilderCustomizer)--> JsonMapper [@Primary]
JsonFactory 与 JsonMapper 分离 是 Jackson 3 的设计: JsonFactory 管流式读写 (token 层),JsonMapper 管对象绑定 (databind 层)。 Jackson 2 里 ObjectMapper 两者合一。分离后同一个 factory 可以被多个 mapper 复用。
为什么 JsonMapper.Builder 是 prototype?
java
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@ConditionalOnMissingBean
JsonMapper.Builder jsonMapperBuilder(...)
因为 JsonMapper.Builder 是有状态的 :builder.build() 之后, 这个 builder 里的配置可能已经被消费/冻结。如果它是单例, 第二次有人注入 JsonMapper.Builder(比如 Actuator 要建一个隔离的 mapper) 会拿到一个已被 build 过的 builder。
))
⚠️ 未核实 :prototype bean 每次创建时
customizers.orderedStream()是否重新解析一遍 定制器链。推断是会的 (每次走一遍@Bean方法),频繁创建会有开销。 自测:在定制器里打日志或计数。
7.3 定制器的两层:Factory vs Mapper
java
@FunctionalInterface
public interface JsonFactoryBuilderCustomizer {
void customize(JsonFactoryBuilder builder);
}
@FunctionalInterface
public interface JsonMapperBuilderCustomizer {
void customize(JsonMapper.Builder builder);
}
为什么要两层? 因为配置项天然分两类:
- Factory 层 (流式):
spring.jackson.factory.constraints.*------ 文档长度、嵌套深度、字符串长度等安全约束 - Mapper 层 (绑定):
spring.jackson.*的绝大部分 ------ 日期格式、命名策略、序列化特性等
AbstractFactoryBuilderCustomizer 的泛型边界:
java
abstract static class AbstractFactoryBuilderCustomizer<B extends DecorableTSFBuilder<?, ?>> implements Ordered {
DecorableTSFBuilder 是 Jackson 3 的 TokenStreamFactory builder 基类 (JsonFactoryBuilder、CBORFactoryBuilder、XmlFactoryBuilder 都是它的子类)。 所以同一个抽象类能给 JSON/CBOR/XML 三种格式复用 ------ 这也是为什么 StandardJsonFactoryBuilderCustomizer、StandardCborFactoryBuilderCustomizer、 StandardXmlFactoryBuilderCustomizer 三个类的内容几乎一模一样:
java
static final class StandardXmlFactoryBuilderCustomizer
extends AbstractFactoryBuilderCustomizer<XmlFactoryBuilder> implements XmlFactoryBuilderCustomizer {
StandardXmlFactoryBuilderCustomizer(JacksonProperties jacksonProperties) {
super(jacksonProperties);
}
@Override
public void customize(XmlFactoryBuilder xmlFactoryBuilder) {
super.customize(xmlFactoryBuilder);
}
}
方法体只有 super.customize(...) ------ 纯粹为了绑定泛型 + 实现接口 。 Java 没有"泛型别名",只能这样写。这三个类是"样板代码", 但也是类型安全的必要代价。
7.4 AbstractFactoryBuilderCustomizer:安全约束
java
@Override
public int getOrder() {
return 0;
}
protected void customize(B builder) {
Constraints constraints = this.jacksonProperties.getFactory().getConstraints();
builder.streamReadConstraints(readConstraintsFrom(constraints.getRead()));
builder.streamWriteConstraints(writeConstraintsFrom(constraints.getWrite()));
}
private StreamReadConstraints readConstraintsFrom(Read read) {
PropertyMapper map = PropertyMapper.get();
StreamReadConstraints.Builder constraintsBuilder = StreamReadConstraints.builder();
map.from(read::getMaxDocumentLength).to(constraintsBuilder::maxDocumentLength);
map.from(read::getMaxNameLength).to(constraintsBuilder::maxNameLength);
map.from(read::getMaxNestingDepth).to(constraintsBuilder::maxNestingDepth);
map.from(read::getMaxNumberLength).to(constraintsBuilder::maxNumberLength);
map.from(read::getMaxStringLength).to(constraintsBuilder::maxStringLength);
map.from(read::getMaxTokenCount).to(constraintsBuilder::maxTokenCount);
return constraintsBuilder.build();
}
private StreamWriteConstraints writeConstraintsFrom(Write write) {
PropertyMapper map = PropertyMapper.get();
StreamWriteConstraints.Builder constraintsBuilder = StreamWriteConstraints.builder();
map.from(write::getMaxNestingDepth).to(constraintsBuilder::maxNestingDepth);
return constraintsBuilder.build();
}
StreamReadConstraints 是 Jackson 2.15+ 引入的 DoS 防护 (对应历史上 Jackson 的循环引用/深度嵌套导致的 OOM 问题)。 Boot 把 6 个读约束 + 1 个写约束暴露成 spring.jackson.factory.constraints.*。
注意 StreamWriteConstraints 只有 maxNestingDepth 一个------ 写方向的攻击面小得多(写是你自己的对象,读是外部输入)。
PropertyMapper 在这里又一次出现(3.10 节见过): map.from(x).to(y) 只在 x 非 null 时调用 y, 所以未配置的约束保持 Jackson 自己的默认值。
7.5 AbstractMapperBuilderCustomizer 的定制顺序
customize(B builder) 里依次执行(我一开始把 7 个 configureFeatures 算成一步,数成 14 步,实为 20 步):
arduino
① useJackson2Defaults ② findAndAddModules ③ defaultPropertyInclusion
④ timeZone ⑤ handlerInstantiator ⑥ visibility
⑦ deserialization ⑧ serialization ⑨ mapper ⑩ datatype.datetime ⑪ datatype.enum
⑫ datatype.jsonnode ⑬ read ⑭ write ← 7 个 configureFeatures
⑮ dateFormat ⑯ propertyNamingStrategy ⑰ modules ⑱ locale
⑲ defaultLeniency ⑳ constructorDetector
三处顺序是有意义的:
- ① 在最前 ------
useJackson2Defaults是"基线",后续配置覆盖它。 - ⑤ 在 ⑰ 之前 ------
handlerInstantiator必须先装,因为模块注册的 serializer/deserializer 在实例化时会用到它。 - ⑰ 模块在最后 ------ 用户模块最后加载,能覆盖前面的一切。
configureFeatures 本身很简单(features.forEach((f, v) -> { if (v != null) configure.accept(f, v); })), null 值跳过------所以 EnumMap 里没配的特性不会被动到。
7.6 useJackson2Defaults:迁移开关
java
if (this.jacksonProperties.isUseJackson2Defaults()) {
builder.configureForJackson2()
.disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS, DateTimeFeature.WRITE_DURATIONS_AS_TIMESTAMPS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(MapperFeature.DEFAULT_VIEW_INCLUSION);
}
这是 Boot 4 里最重要的一个迁移开关。Jackson 3 改变了若干默认值, Boot 通过一个 flag 让用户"先保持旧行为,再逐步迁移"。
被显式改回来的四项:
| 特性 | Jackson 3 默认 | Jackson 2 / Boot 默认 | 影响 |
|---|---|---|---|
WRITE_DATES_AS_TIMESTAMPS |
? | false(写成 ISO 字符串) | 日期序列化格式 |
WRITE_DURATIONS_AS_TIMESTAMPS |
? | false | Duration 序列化格式 |
FAIL_ON_UNKNOWN_PROPERTIES |
? | false(忽略未知字段) | 反序列化容错 |
DEFAULT_VIEW_INCLUSION |
? | false | @JsonView 行为 |
configureForJackson2() 是 Jackson 3 提供的批量切换 API, 后面三个 disable 是在它之上的额外调整 ------ 说明 configureForJackson2() 并不完全等价于 Boot 2.x/3.x 的默认集, Boot 又补了三项。
⚠️ 未核实 :
configureForJackson2()具体改了哪些默认值, 以及上表中"Jackson 3 默认"那一列的问号到底是什么值。 Jackson 3 的源码不在本仓库。自测方法:
javaSystem.out.println(JsonMapper.builder().build().serializationConfig() .isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS));分别在有/无
spring.jackson.use-jackson2-defaults=true时打印对比。可以确定的一件事:
FAIL_ON_UNKNOWN_PROPERTIES在 Jackson 3 里默认是true(Jackson 3 的口号之一就是"更严格"),所以 Boot 关掉它------ 否则升级到 Boot 4 后大量接口会因为多一个字段就 400。 这条是我从disable(...)反推的,置信度较高但仍标注为推断。
7.7 findAndAddModules 与 SpringBeanHandlerInstantiator
java
if (this.jacksonProperties.isFindAndAddModules()) {
builder.findAndAddModules(getClass().getClassLoader());
}
spring.jackson.find-and-add-modules 默认 true 。 findAndAddModules 用 ServiceLoader 扫描 classpath 上的 Jackson 模块 (META-INF/services/tools.jackson.databind.JacksonModule)。 这是 jackson-datatype-jsr310(Java 8 时间)、jackson-module-kotlin 等自动生效的原因。
它的代价 :classpath 扫描 + 反射,在 GraalVM native 下需要 hints。 所以默认是开的对开发友好,对 native 不友好------Boot 的取舍是先保证功能正确。
java
this.handlerInstantiator = new SpringBeanHandlerInstantiator(beanFactory);
...
builder.handlerInstantiator(this.handlerInstantiator);
SpringBeanHandlerInstantiator 让 Jackson 的 serializer/deserializer/TypeIdResolver 等 从 Spring 容器创建 (beanFactory.createBean(deserClass)), 于是它们能用 @Autowired。
java
/**
* Package-private adaptation of Spring Framework's
* {@link org.springframework.http.support.JacksonHandlerInstantiator}. Allows Spring Boot
* to offer bean-based handler instantiation without requiring {@code spring-web}.
*/
class SpringBeanHandlerInstantiator extends HandlerInstantiator {
注释点明了为什么要复制一份 :"without requiring spring-web" 。 spring-boot-jackson 模块不依赖 spring-web (它是一个纯数据绑定模块, WebFlux / 非 Web 应用也要用),所以不能用 Framework 里那个位于 org.springframework.http.support 的实现。
这是 Boot 模块化改造后的常见模式:为了切断模块依赖,宁可复制一小段代码。 第 03 章讲 4.x 模块化时提到过这个取舍。
7.8 configureDateFormat:"先当类名,再当格式串"
java
private void configureDateFormat(MapperBuilder<?, ?> builder) {
// We support a fully qualified class name extending DateFormat or a date
// pattern string value
String dateFormat = this.jacksonProperties.getDateFormat();
if (dateFormat != null) {
try {
Class<?> dateFormatClass = ClassUtils.forName(dateFormat, null);
builder.defaultDateFormat((DateFormat) BeanUtils.instantiateClass(dateFormatClass));
}
catch (ClassNotFoundException ex) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat);
// Since Jackson 2.6.3 we always need to set a TimeZone (see gh-4170). If none
// in our properties fallback to Jackson's default
TimeZone timeZone = this.jacksonProperties.getTimeZone();
if (timeZone == null) {
timeZone = new ObjectMapper().serializationConfig().getTimeZone();
}
simpleDateFormat.setTimeZone(timeZone);
builder.defaultDateFormat(simpleDateFormat);
}
}
}
用 ClassNotFoundException 做分支------这是"重载语义"的经典实现: 一个字符串,要么是全限定类名,要么是日期格式串。
new ObjectMapper().serializationConfig().getTimeZone() 这一句很妙: 为了拿到"Jackson 自己的默认时区",临时创建一个 ObjectMapper 只读它的配置。 注释引用了 gh-4170(一个真实的 GitHub issue)------ Boot 源码里这类 gh-xxxx 引用是最好的考古线索。
configurePropertyNamingStrategy 用的是同一套模式,但更进一步:
java
private void configurePropertyNamingStrategyField(MapperBuilder<?, ?> builder, String fieldName) {
// Find the field (this way we automatically support new constants
// that may be added by Jackson in the future)
Field field = findPropertyNamingStrategyField(fieldName);
try {
builder.propertyNamingStrategy((PropertyNamingStrategy) field.get(null));
}
...
}
private Field findPropertyNamingStrategyField(String fieldName) {
Field field = ReflectionUtils.findField(PropertyNamingStrategies.class, fieldName,
PropertyNamingStrategy.class);
Assert.state(field != null, () -> "Constant named '" + fieldName + "' not found");
return field;
}
不用 switch 枚举常量,而是反射找字段 ------注释解释了理由: "this way we automatically support new constants that may be added by Jackson in the future"。
这是"反射优于硬编码枚举"的一个正当理由:Boot 不想在 Jackson 加新常量时 被迫改代码。代价是需要 AOT hints(见下)。
7.9 CBOR 与 XML:三种格式的对称结构
java
JacksonAutoConfiguration
├── JacksonMixinConfiguration (mixin 扫描)
├── JacksonJsonCustomizerConfiguration (JsonFactory / JsonMapper)
│ ├── StandardJsonFactoryBuilderCustomizer
│ └── StandardJsonMapperBuilderCustomizer
├── JsonProblemDetailsConfiguration (@ConditionalOnClass(ProblemDetail))
├── CborConfiguration (@ConditionalOnClass(CBORMapper))
│ ├── CBORFactory / CBORMapper.Builder / CBORMapper
│ ├── StandardCborFactoryBuilderCustomizer
│ ├── StandardCborMapperBuilderCustomizer
│ └── (read/write 来自 JacksonCborProperties)
└── XmlConfiguration (@ConditionalOnClass(XmlMapper))
├── XmlFactory / XmlMapper.Builder / XmlMapper
├── StandardXmlFactoryBuilderCustomizer
├── StandardXmlMapperBuilderCustomizer
└── XmlProblemDetailsConfiguration (嵌套的 ProblemDetail mixin)
CBOR 是 Boot 4 新增的支持(Jackson 3 把它提到与 XML 同等地位)。 CBOR 是二进制 JSON,常用于 IoT / 高性能场景。
注意 CBORMapper 没有 @Primary:
java
@Bean
@ConditionalOnMissingBean
CBORMapper cborMapper(CBORMapper.Builder builder) {
return builder.build();
}
而 JsonMapper 有 @Primary。因为 JsonMapper 最常被注入,多候选时要有主; CBOR/XML 很少有多个候选。
XmlConfiguration 里的 XmlProblemDetailsConfiguration 是嵌套的 , 而 JSON 的 JsonProblemDetailsConfiguration 是平级 的。 这个不对称没有明显的功能理由(可能只是历史原因)。
7.10 ProblemDetail mixin
java
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ProblemDetail.class)
static class JsonProblemDetailsConfiguration {
@Bean
ProblemDetailJsonMapperBuilderCustomizer problemDetailJsonMapperBuilderCustomizer() {
return new ProblemDetailJsonMapperBuilderCustomizer();
}
static final class ProblemDetailJsonMapperBuilderCustomizer implements JsonMapperBuilderCustomizer {
@Override
public void customize(JsonMapper.Builder builder) {
builder.addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class);
}
}
}
ProblemDetail 是 RFC 9457 的 Java 模型(Framework 6+ 有)。 Jackson 序列化它时需要特殊处理(ProblemDetail 里有 Map<String,Object> properties 需要展开成顶层字段),所以用 mixin。
@ConditionalOnClass(ProblemDetail.class) ------ ProblemDetail 在 spring-web 里, 所以 jackson 模块在纯数据绑定场景(无 spring-web)下不会注册这个 mixin。
7.11 JacksonAutoConfigurationRuntimeHints
java
static class JacksonAutoConfigurationRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
if (ClassUtils.isPresent("tools.jackson.databind.PropertyNamingStrategy", classLoader)) {
registerPropertyNamingStrategyHints(hints.reflection());
}
}
private void registerPropertyNamingStrategyHints(ReflectionHints hints, Class<?> type) {
Stream.of(type.getDeclaredFields())
.filter(this::isPropertyNamingStrategyField)
.forEach(hints::registerField);
}
private boolean isPropertyNamingStrategyField(Field candidate) {
return ReflectionUtils.isPublicStaticFinal(candidate)
&& candidate.getType().isAssignableFrom(PropertyNamingStrategy.class);
}
}
为 7.8 节的反射查找补 hints :把 PropertyNamingStrategies 里所有 public static final PropertyNamingStrategy 字段注册为可反射访问。
isPublicStaticFinal + isAssignableFrom 的双重过滤 与 7.8 节 ReflectionUtils.findField(PropertyNamingStrategies.class, fieldName, PropertyNamingStrategy.class) 的过滤条件保持一致 ------这是一个必须对齐的地方: AOT 时注册的 hints 必须与运行时反射的目标集合一致 , 否则 native 下会 NoSuchFieldException。
注意
candidate.getType().isAssignableFrom(PropertyNamingStrategy.class)的方向:X.isAssignableFrom(Y)意思是"X 能否装下 Y"。这里是字段类型.isAssignableFrom(PropertyNamingStrategy.class), 即"PropertyNamingStrategy 能否赋值给这个字段类型"------方向是对的 (字段类型是PropertyNamingStrategy或其父类)。 但PropertyNamingStrategies里的常量实际上是PropertyNamingStrategy的子类 (如SnakeCaseStrategy),它们的字段类型是PropertyNamingStrategy。 所以这个判断能命中。若有人声明SnakeCaseStrategy类型的常量,就命不中了 ------ 这可能是一个 bug,也可能 Jackson 的常量都是基类类型。未核实。
8. @JacksonComponent 与 @JacksonMixin
8.1 JacksonComponentModule:遍历父工厂
java
public class JacksonComponentModule extends SimpleModule implements BeanFactoryAware, InitializingBean {
@Override
public void afterPropertiesSet() {
registerJacksonComponents();
}
public void registerJacksonComponents() {
BeanFactory beanFactory = this.beanFactory;
while (beanFactory != null) {
if (beanFactory instanceof ListableBeanFactory listableBeanFactory) {
addJacksonComponentBeans(listableBeanFactory);
}
beanFactory = (beanFactory instanceof HierarchicalBeanFactory hierarchicalBeanFactory)
? hierarchicalBeanFactory.getParentBeanFactory() : null;
}
}
while (beanFactory != null) 向上遍历父工厂 ------ 这让 Actuator 子上下文里定义的 @JacksonComponent 也能被主上下文的 mapper 看到?
不,方向是反的:主上下文的 JsonMapper 里的 JacksonComponentModule 只能向上找 (主上下文没有父工厂)。子 上下文的 mapper 才能看到主的。 所以准确说是:子上下文(Actuator 管理端口)能复用主上下文定义的 Jackson 组件。
addJacksonComponentBean 按类型分派:
java
if (bean instanceof ValueSerializer<?> jsonSerializer) {
addValueSerializerBean(jsonSerializer, scope, types);
}
else if (bean instanceof ValueDeserializer<?> jsonDeserializer) {
addValueDeserializerBean(jsonDeserializer, types);
}
else if (bean instanceof KeyDeserializer keyDeserializer) {
addKeyDeserializerBean(keyDeserializer, types);
}
for (Class<?> innerClass : bean.getClass().getDeclaredClasses()) {
if (isSuitableInnerClass(innerClass)) {
Object innerInstance = BeanUtils.instantiateClass(innerClass);
addJacksonComponentBean(innerInstance, types, scope);
}
}
最后那个循环是"内部类扫描"------支持把多个 serializer 写成一个类的静态内部类:
java
@JacksonComponent
public class MyJsonComponents {
public static class FooSerializer extends ObjectValueSerializer<Foo> { ... }
public static class BarDeserializer extends ObjectValueDeserializer<Bar> { ... }
}
BeanUtils.instantiateClass(innerClass) 要求内部类有无参构造器 (且通常是 static 的,否则构造会失败)。
注意:ValueSerializer/ValueDeserializer 是 Jackson 3 的类型 (Jackson 2 里是 JsonSerializer/JsonDeserializer)。
8.2 JacksonMixinModuleEntries:扫描与 AOT 硬编码
java
@Bean
static JacksonMixinModuleEntries jacksonMixinModuleEntries(ApplicationContext context) {
List<String> packages = AutoConfigurationPackages.has(context) ? AutoConfigurationPackages.get(context)
: Collections.emptyList();
return JacksonMixinModuleEntries.scan(context, packages);
}
AutoConfigurationPackages ------ 第 13 章讲过,它记录 @SpringBootApplication 所在的包。@JacksonMixin 的扫描只在自动配置包范围内,不是全 classpath。
scan 用 JacksonMixinComponentScanner(一个 ClassPathScanningCandidateComponentProvider 的子类)找候选,再用 MergedAnnotations 读 @JacksonMixin(type = {...}):
java
private static void registerMixinClass(Builder builder, Class<?> mixinClass) {
MergedAnnotation<JacksonMixin> annotation = MergedAnnotations
.from(mixinClass, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY)
.get(JacksonMixin.class);
Class<?>[] types = annotation.getClassArray("type");
Assert.state(!ObjectUtils.isEmpty(types),
() -> "@JacksonMixin annotation on class '" + mixinClass.getName() + "' does not specify any types");
for (Class<?> type : types) {
builder.and(type, mixinClass);
}
}
Assert.state 给出了一条明确的错误消息 :"@JacksonMixin annotation on class X does not specify any types"。 这是一个必填属性 的编译期外校验------注解的 type 属性没有默认值, 理论上不写会编译失败,但如果是通过元注解/组合注解间接标注,就可能为空。
MergedAnnotations.SearchStrategy.TYPE_HIERARCHY 让它支持继承 ------ @JacksonMixin 可以被元注解组合。
8.3 AOT:把扫描结果变成硬编码
java
/**
* {@link BeanRegistrationAotProcessor} that replaces any
* {@link JacksonMixinModuleEntries} with a hard-coded equivalent. This has the effect of
* disabling scanning at runtime.
*/
class JacksonMixinModuleEntriesBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor {
Javadoc 一句话说清了目的 :"disabling scanning at runtime"。
AOT 阶段(编译期),扫描已经跑过一次了,结果已知。 native image 里不能做 classpath 扫描(没有 classpath 概念), 所以把结果写成 Java 源码:
java
code.add("return $T.create(", JacksonMixinModuleEntries.class).beginControlFlow("(mixins) ->");
entries.doWithEntry(this.classLoader, (type, mixin) -> addEntryCode(code, type, mixin));
code.endControlFlow(")");
生成的代码长这样(示意):
java
private static JacksonMixinModuleEntries getInstance() {
return JacksonMixinModuleEntries.create((mixins) -> {
mixins.and(com.example.Foo.class, com.example.FooMixin.class);
mixins.and("com.example.Bar", "com.example.BarMixin"); // 非 public 时用字符串
});
}
addEntryCode 的可见性处理:
java
AccessControl accessForTypes = AccessControl.lowest(AccessControl.forClass(type), AccessControl.forClass(mixin));
if (accessForTypes.isPublic()) {
code.addStatement("$L.and($T.class, $T.class)", "mixins", type, mixin);
}
else {
code.addStatement("$L.and($S, $S)", "mixins", type.getName(), mixin.getName());
}
AccessControl.lowest(...) 取两者中可见性更低的那个 ; 只要有一个不是 public,就用字符串类名(因为生成的源码在别的包里, 访问不了包可见的类)。
Builder.and(String, String) 这个重载就是为此存在的------ 对应 8.2 节 resolveClassNameIfNecessary:
java
private Class<?> resolveClassNameIfNecessary(Object nameOrType, @Nullable ClassLoader classLoader) {
return (nameOrType instanceof Class<?> type) ? type
: ClassUtils.resolveClassName((String) nameOrType, classLoader);
}
Map<Object, Object> 的 key/value 既可以是 Class 也可以是 String ------ 用 Object 类型装两种可能,在 doWithEntry 时统一解析。 这是一个刻意的设计(不是偷懒),为的就是支持 AOT 的字符串形式。
同时还要注册反射 hints:
java
private void contributeHints(RuntimeHints runtimeHints, JacksonMixinModuleEntries entries) {
Set<Class<?>> mixins = new LinkedHashSet<>();
entries.doWithEntry(this.classLoader, (type, mixin) -> mixins.add(mixin));
new BindingReflectionHintsRegistrar().registerReflectionHints(runtimeHints.reflection(),
mixins.toArray(Class<?>[]::new));
}
注意只注册了 mixin,没注册 type ------因为 mixin 类需要被 Jackson 反射读取其注解, 而 type 只是作为 map 的 key(Jackson 会自己处理)。
9. Jackson 2 的退场
9.1 @Deprecated(since = "4.0.0", forRemoval = true)
java
/**
* ...
* @since 4.0.0
* @deprecated since 4.0.0 for removal in 4.3.0 in favor of Jackson 3.
*/
@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
@Deprecated(since = "4.0.0", forRemoval = true)
@SuppressWarnings("removal")
public final class Jackson2AutoConfiguration {
这是 Boot 4 里最重要的一个"退场时间表" : Jackson 2 支持从 4.0.0 开始标记废弃,4.3.0 移除。
@SuppressWarnings("removal") 是因为类内部大量使用了 forRemoval = true 的 API (被 Spring Framework 7 标记为待移除的 Jackson2ObjectMapperBuilder 等)。 注意它用的是全限定名:
java
org.springframework.http.converter.json.Jackson2ObjectMapperBuilder
而不是 import------因为 Jackson2ObjectMapperBuilder 本身在 Framework 7 里已被 @Deprecated(forRemoval = true),import 会产生编译警告。 用全限定名可以抑制 import 位置的警告(虽然这个理由有点牵强)。
9.2 结构对比
Jackson 3 (JacksonAutoConfiguration) |
Jackson 2 (Jackson2AutoConfiguration) |
|
|---|---|---|
| 核心类型 | JsonMapper / JsonMapper.Builder / JsonFactory |
ObjectMapper / Jackson2ObjectMapperBuilder |
| 格式支持 | JSON / CBOR / XML(三个并列嵌套配置) | 只有 JSON(XML 由 createXmlMapper(false) 关闭) |
| 约束配置 | spring.jackson.factory.constraints.* |
无(StreamReadConstraints 需手动) |
| 定制器 | 两层(Factory + Mapper) | 一层(Jackson2ObjectMapperBuilderCustomizer) |
| 模块发现 | findAndAddModules(默认开) |
ObjectProvider<Module>(容器里的 bean) |
| 状态 | 默认 | @Deprecated forRemoval |
9.3 FEATURE_DEFAULTS:硬编码的默认覆盖
java
private static final Map<?, Boolean> FEATURE_DEFAULTS;
static {
Map<Object, Boolean> featureDefaults = new HashMap<>();
featureDefaults.put(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
featureDefaults.put(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, false);
FEATURE_DEFAULTS = Collections.unmodifiableMap(featureDefaults);
}
configureFeatures(builder, FEATURE_DEFAULTS) 在 customize 里最先调用:
java
public void customize(org.springframework.http.converter.json.Jackson2ObjectMapperBuilder builder) {
if (this.jacksonProperties.getDefaultPropertyInclusion() != null) { ... }
if (this.jacksonProperties.getTimeZone() != null) { ... }
configureFeatures(builder, FEATURE_DEFAULTS); // ★
configureVisibility(builder, this.jacksonProperties.getVisibility());
configureFeatures(builder, this.jacksonProperties.getDeserialization());
...
}
两个 WRITE_*_AS_TIMESTAMPS = false 是 Boot 3.x 就有的默认 ------ 让日期序列化成 "2026-10-17T12:00:00" 而不是 1729166400000。
有意思的是:Jackson 3 侧没有这两行的硬编码 , 而是靠 useJackson2Defaults(默认 false!)时才启用。 也就是说 Boot 4 默认下,Jackson 3 的日期序列化格式取决于 Jackson 3 自己的默认值。
⚠️ 重要推断(未实测) :如果 Jackson 3 默认
WRITE_DATES_AS_TIMESTAMPS = true, 那么 Boot 4.1 默认行为会是日期输出为时间戳 ,与 Boot 3.x 不同------ 这将是一个静默的行为变更 。 但 Jackson 3 的设计目标之一是"更合理的默认值", 我倾向于认为它默认就是 ISO-8601。这条必须实测,见 7.6 节的自测方法。
9.4 迁移对照
| 你要做的事 | Jackson 2 | Jackson 3 |
|---|---|---|
| 自定义序列化器 | @JsonComponent + JsonObjectSerializer<T> |
@JacksonComponent + ObjectValueSerializer<T> |
| 给第三方类加注解 | @JsonMixin(type = X.class) |
@JacksonMixin(type = X.class) |
| 注册模块 | Module bean |
JacksonModule bean |
| 配置 mapper | Jackson2ObjectMapperBuilderCustomizer |
JsonMapperBuilderCustomizer |
| 注入 mapper | ObjectMapper |
JsonMapper(@Primary) |
| 配置属性前缀 | spring.jackson.* |
spring.jackson.*(相同) |
配置属性前缀保持不变 是一个明智的迁移设计------ spring.jackson.* 在两代之间复用,只是语义略有增减。
10. 观测:WebMvcObservationAutoConfiguration
java
@AutoConfiguration(afterName = { "...CompositeMeterRegistryAutoConfiguration",
"...MetricsAutoConfiguration", "...SimpleMetricsExportAutoConfiguration",
"...ObservationAutoConfiguration" })
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ DispatcherServlet.class, Observation.class, ObservationProperties.class })
@ConditionalOnBean(ObservationRegistry.class)
@EnableConfigurationProperties(ObservationProperties.class)
public final class WebMvcObservationAutoConfiguration {
@Bean
@ConditionalOnMissingFilterBean
FilterRegistrationBean<ServerHttpObservationFilter> webMvcObservationFilter(ObservationRegistry registry,
ObjectProvider<ServerRequestObservationConvention> customConvention,
ObservationProperties observationProperties) {
String name = observationProperties.getHttp().getServer().getRequests().getName();
ServerRequestObservationConvention convention = customConvention
.getIfAvailable(() -> new DefaultServerRequestObservationConvention(name));
ServerHttpObservationFilter filter = new ServerHttpObservationFilter(registry, convention);
FilterRegistrationBean<ServerHttpObservationFilter> registration = new FilterRegistrationBean<>(filter);
registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ASYNC);
return registration;
}
要点:
- 用 Filter 而不是 HandlerInterceptor ------ 因为要观测所有请求, 包括那些没有匹配到 handler 的(404 也要有指标)。
setOrder(HIGHEST_PRECEDENCE + 1)------ 几乎最外层, 这样观测能包住其他 filter 的耗时。setDispatcherTypes(REQUEST, ASYNC)------ 不包括ERROR和FORWARD, 避免同一请求被观测两次(forward 到/error时)。@ConditionalOnMissingFilterBean(3.11 节见过)------ 用户可以自己注册同名 filter 覆盖。
MeterFilterConfiguration 里注册了 MaximumAllowableTagsMeterFilter:
java
@Bean
@Order(0)
MaximumAllowableTagsMeterFilter metricsHttpServerUriTagFilter(ObservationProperties observationProperties,
MetricsProperties metricsProperties) {
String meterNamePrefix = observationProperties.getHttp().getServer().getRequests().getName();
int maxUriTags = metricsProperties.getWeb().getServer().getMaxUriTags();
return new MaximumAllowableTagsMeterFilter(meterNamePrefix, "uri", maxUriTags);
}
这是防"指标基数爆炸"的关键:/users/1、/users/2... 每个 URI 一个 tag, 如果 URI 里带 ID,指标数量会无限增长(内存 OOM)。 MaximumAllowableTagsMeterFilter 限制 uri tag 的最大取值个数, 超出的会被丢弃或归到 "other"。
第 26 章讲 Micrometer 时会再展开。
11. 事实清单
本章从源码直接确认的事实(挑最容易被忽略的):
DispatcherServletAutoConfiguration的两个常量dispatcherServlet/dispatcherServletRegistration是它的对外契约,全部逻辑围绕"名字是否被占用"展开。- 真值表第 ④ 行:有
DispatcherServlet类型 bean 但名字不叫dispatcherServlet时, Boot 仍然会创建默认的------否则用户那个永远注册不进容器。 multipartResolver的"改名"技巧依赖@Bean方法名等于目标名字 (方法体return resolver)。getServletUrlMapping()把/app变成/app/*(前缀匹配),不是精确匹配。@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)=@EnableWebMvc让整个WebMvcAutoConfiguration失效。HiddenHttpMethodFilter默认关、FormContentFilter默认开;@ConditionalOnBooleanProperty(@since 3.5.0)的havingValue默认就是"true"。WebMvcAutoConfigurationAdapter的 logger 名是WebMvcConfigurer.class,不是本类。EnableWebMvcConfiguration覆写flashMapManager()/viewNameTranslator()只为加@ConditionalOnMissingBean(name = ...),方法体是super.xxx()。mvcValidator()用字符串类名做ClassUtils.isPresent双检(webmvc 不依赖 validation 模块)。WebMvcRegistrations用getIfUnique()------多个实现时全部失效,不抛异常。- 改了
spring.mvc.static-path-pattern会让静态 index.html 不再作为欢迎页 (WelcomePage.resolve里硬编码判断"/**".equals(staticPathPattern))。 WelcomePageHandlerMapping.setOrder(2)>RequestMappingHandlerMapping的 0, 所以@GetMapping("/")优先于 index.html。DefaultServerHttpMessageConvertersCustomizer:有 legacyHttpMessageConvertersbean 时 不调registerDefaults()(旧类型语义是"完整列表")。@ConditionalOnMissingBean(..., ignoredType = {...})忽略 HATEOAS / Data REST 的特化子类, 否则 Boot 的通用 JSON 转换器不会注册。- Jackson 3 的 groupId 是
tools.jackson(3.1.5),Jackson 2 是com.fasterxml.jackson(2.21.5)。 JsonMapper.Builder是 prototype ,JsonMapper是单例 +@Primary------"共享配置,独享实例"。- 三个
Standard*FactoryBuilderCustomizer的方法体只有super.customize(...), 纯粹为了绑定泛型 + 实现接口。 SpringBeanHandlerInstantiator是 Framework 同类的复制品 ,理由是 "without requiringspring-web"。Jackson2AutoConfiguration是@Deprecated(since = "4.0.0", forRemoval = true),4.3.0 移除。spring.jackson.*前缀在两代 Jackson 之间保持一致。
12. 本章未逐行核实 / 待你实测的项
以下条目我没有从源码确认(按验证成本排序): Jackson 3 的 WRITE_DATES_AS_TIMESTAMPS 默认值与 configureForJackson2() 的完整改动 (打印 serializationConfig().isEnabled(...) 对比开关即可); ServerBuilder.withJsonConverter 是"替换默认"还是"追加"; AutoConfigureWebServer.imports 的消费方; @Order(LOWEST_PRECEDENCE - 10) 对 SpringBootCondition 的实际作用; PreserveErrorControllerTargetClassPostProcessor 与 ErrorPageCustomizer 的实现体; spring.web.resources.chain.enabled 的默认值; Kotlin 序列化对应的 havingValue 字符串; DispatcherServletPath.getPrefix() / getRelativePath() 的调用方; JacksonAutoConfigurationRuntimeHints 无条件注册 ValidatorAdapter 在 native 下是否安全; isPropertyNamingStrategyField 用 isAssignableFrom 是否漏掉子类常量; prototype bean 上 orderedStream() 是否每次重跑; ConditionalOnMissingFilterBean 的实现机制; 3.10 节 whenHasText() 的不一致是否刻意(可查 git blame)。 第 17、18 章遗留的 WAR 部署两次调用问题仍未解决。
13. 方法论:本章沉淀的三条定律
13.1 第十九定律:按名字协作的地方,条件就按名字写
当框架侧用固定 bean 名查找组件时(
DispatcherServlet的multipartResolver、flashMapManager、localeResolver),Boot 的条件注解就必须写成name = ...; 而"补名字"的解决方案,往往是把 bean 再注册一遍并让@Bean方法名等于这个名字。
本章出现了三次: multipartResolver(补名字)、flashMapManager/viewNameTranslator(覆写只为加 name 条件)、 viewResolver(@ConditionalOnMissingBean(name = "viewResolver") 自己检查自己)。
判据 :看到 @ConditionalOnMissingBean(name = ...) 里的名字是一个框架常量 (如 DispatcherServlet.Xxx_BEAN_NAME),就说明这是"名字契约"场景, 用户的 bean 必须同名 才能生效------这也解释了为什么 DefaultDispatcherServletCondition 要专门处理"名字被占用"这个分支。
13.2 第二十定律:afterName + @Import = 可选依赖的标准写法
一个模块不能编译期引用另一个可选模块的类时,Boot 的固定组合是: 外层用
afterName = "..."(字符串)表达顺序, 用@Import(内层配置类)引入,内层配置类自己用@ConditionalOnClass做真正的依赖检查。
本章出现三次: WebMvcAutoConfiguration.afterName = ValidationAutoConfiguration、 HttpMessageConvertersAutoConfiguration.afterName = 5 个 JSON 库、 JacksonAutoConfiguration 的 ClassUtils.isPresent("...") 字符串检查。
配套的两个技巧:
- 复制一小段代码而非引入依赖 (
SpringBeanHandlerInstantiator) @Contract与字符串类名代替类型引用
判据 :@AutoConfiguration 里出现 afterName 或 beforeName, 基本可以断定对应的模块是可选依赖。反过来,如果你要写一个依赖可选模块的自动配置, 就应该照抄这套。
13.3 第二十一定律:AOT 的贡献 = "把运行时决策提前到编译期并硬编码"
BeanRegistrationAotProcessor的典型形态不是"优化",而是"替换": 用一份编译期已知的结果,替换掉运行时的动态行为(扫描、反射、ServiceLoader)。
本章的证据是 JacksonMixinModuleEntriesBeanRegistrationAotProcessor, 它的 Javadoc 直接写了 "disabling scanning at runtime"。
三个必须对齐的点:
- 生成的代码要与运行时行为等价 (
ClassvsString两种形式) - 可见性要降级处理 (
AccessControl.lowest→ 非 public 用字符串) - 反射目标要补 hints (
BindingReflectionHintsRegistrar)
判据 :看到某个 bean 的构造依赖 classpath 扫描或 ServiceLoader, 去同包下找 *BeanRegistrationAotProcessor------大概率有一个。 第 28 章会系统展开 AOT。
14. 下一章预告
第 20 章进入 WebFlux 与 Reactor Netty:响应式栈与编解码。 核心问题包括:
WebFluxAutoConfiguration与 MVC 版的结构差异(两者是"平行宇宙"还是共享抽象?)CodecCustomizer与ServerCodecConfigurer------为什么 reactive 侧不用HttpMessageConverterReactiveWebServerFactory与第 18 章的WebServer如何衔接NettyReactiveWebServerFactory的HttpServer配置链(NettyRouteProvider的完整机制)- 为什么
HttpMessageConvertersAutoConfiguration用NotReactiveWebApplicationCondition把自己排除
第 18 章遗留的 NettyRouteProvider、DelayedInitializationHttpHandler 会在那里接上。