Spring Boot 4.1.1 深度源码解析-第 19 章 · Spring MVC 自动配置:DispatcherServlet、消息转换

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          ← ★ 只有这一个

三个信息点:

  1. 只有 3 个 是普通自动配置,另外 3 个是 Actuator 相关(挂在 .actuate. 包下,第 25 章会展开)。
  2. ManagementContextConfiguration.imports 是 Actuator 子上下文专用的通道------这是一个独立于 AutoConfiguration.imports 的注册点 ,第 14 章讲 AutoConfigurationImportSelector 时没覆盖到它。
  3. 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");
    }
}

三个设计点:

  1. 双身份 :既是 servlet 注册器,又是 DispatcherServletPath 的实现------ 注册完自己就能对外提供"我映射在哪个路径"的信息。
  2. 构造时算好 mapping :super.addUrlMappings(getServletUrlMapping()) 在构造函数里调用, getServletUrlMapping() 是 DispatcherServletPath 的 default 方法,此时 this.path 已赋值。 这里有个隐患 :在构造函数里调用一个可被覆写的 default 方法。 若子类覆写 getServletUrlMapping() 并依赖子类字段,会拿到 null。 DispatcherServletRegistrationBean 是 public 的,理论上可被继承。 但 path 是 private final,子类拿不到,也只能覆写逻辑------风险可控但确实存在。
  3. 封死修改入口 :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 {

逐条解读:

  1. after = { DispatcherServletAutoConfiguration, TaskExecutionAutoConfiguration } ------ 前者好理解;后者是因为 configureAsyncSupport 需要 applicationTaskExecutor bean 存在 (下面 3.6 节)。
  2. afterName(字符串形式)引用 ValidationAutoConfiguration ------ 因为 webmvc 模块不依赖 validation 模块 ,不能用 .class 字面量 (第 14 章讲过 afterName 就是为这种"可选依赖"准备的)。
  3. @ConditionalOnMissingBean(WebMvcConfigurationSupport.class) ------ 这是最关键的一条 。 WebMvcConfigurationSupport 是 @EnableWebMvc 导入的那个类。 所以:只要用户写了 @EnableWebMvc,整个 WebMvcAutoConfiguration 全部失效。 这是 Boot "让位给用户"的最大粒度开关。
  4. @AutoConfigureOrder(HIGHEST_PRECEDENCE + 10) = Integer.MIN_VALUE + 10, 紧跟在 DispatcherServletAutoConfiguration(+0)之后。
  5. @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 to true" 。 也就是 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);
        }
    });
}

要点:

  1. spring.web.resources.add-mappings=false 是总开关 ,关掉后两行都不执行 (注意前缀是 spring.web.resources,不是 spring.mvc)。

  2. webjars 先注册,静态资源后注册 ------因为 spring.mvc.static-path-pattern 默认 /**, registry.hasMappingForPattern(pattern) 会让先注册的赢 。 如果反过来,/** 会吃掉 /webjars/**。

  3. logger 的 logger 名是 WebMvcConfigurer.class :

    java 复制代码
    private static final Log logger = LogFactory.getLog(WebMvcConfigurer.class);

    不是本类!这意味着所有 WebMvcConfigurer 实现的这类日志共用同一个 logger 名 , 你按类名配日志级别时要配 org.springframework.web.servlet.config.annotation.WebMvcConfigurer。 我倾向于认为这是有意为之(让用户能统一开关"MVC 配置类"的日志), 但也可能是 copy-paste。未核实其意图。

  4. 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,编译期就必须有依赖。

两个检查:

  1. jakarta.validation.Validator ------ Jakarta Bean Validation API 在不在
  2. 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;
    }
}

三个要点:

  1. spring.web.error.whitelabel.enabled 默认 true (matchIfMissing = true)。 注意前缀是 spring.web.error,不是 server.error。

  2. @Conditional(ErrorTemplateMissingCondition.class) ------ 自定义条件:

    java 复制代码
    TemplateAvailabilityProvider 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 等)时才显示白标页。 这是"有更好的就用更好的"逻辑。

  3. beanNameViewResolver 的注释非常有价值 : "If the user adds @EnableWebMvc then the bean name view resolver from WebMvcAutoConfiguration disappears, so add it back in to avoid disappointment."

    这是 3.1 节 @ConditionalOnMissingBean(WebMvcConfigurationSupport.class) 的补偿措施 。 用户加了 @EnableWebMvc → WebMvcAutoConfiguration 全部失效 → BeanNameViewResolver 没了 → error view 找不到解析器 → 白标页也显示不出来。 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 HttpMessageConverters bean → 只 添加用户给的,不调 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);
        }
    }
    ...
}

三重条件:

  1. @ConditionalOnClass(JsonMapper.class) ------ Jackson 3 在 classpath
  2. @ConditionalOnBean(JsonMapper.class) ------ 容器里有 JsonMapper bean (由 JacksonAutoConfiguration 提供,这就是 afterName 排序的意义)
  3. @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 转换器"还是"追加"。 从 with vs add 的命名推断是替换,但未确认。

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

三处顺序是有意义的:

  1. ① 在最前 ------ useJackson2Defaults 是"基线",后续配置覆盖它。
  2. ⑤ 在 ⑰ 之前 ------ handlerInstantiator 必须先装,因为模块注册的 serializer/deserializer 在实例化时会用到它。
  3. ⑰ 模块在最后 ------ 用户模块最后加载,能覆盖前面的一切。

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 的源码不在本仓库。自测方法:

java 复制代码
System.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;
    }

要点:

  1. 用 Filter 而不是 HandlerInterceptor ------ 因为要观测所有请求, 包括那些没有匹配到 handler 的(404 也要有指标)。
  2. setOrder(HIGHEST_PRECEDENCE + 1) ------ 几乎最外层, 这样观测能包住其他 filter 的耗时。
  3. setDispatcherTypes(REQUEST, ASYNC) ------ 不包括 ERROR 和 FORWARD , 避免同一请求被观测两次(forward 到 /error 时)。
  4. @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. 事实清单

本章从源码直接确认的事实(挑最容易被忽略的):

  1. DispatcherServletAutoConfiguration 的两个常量 dispatcherServlet / dispatcherServletRegistration 是它的对外契约,全部逻辑围绕"名字是否被占用"展开。
  2. 真值表第 ④ 行:有 DispatcherServlet 类型 bean 但名字不叫 dispatcherServlet 时, Boot 仍然会创建默认的------否则用户那个永远注册不进容器。
  3. multipartResolver 的"改名"技巧依赖 @Bean 方法名等于目标名字 (方法体 return resolver)。
  4. getServletUrlMapping() 把 /app 变成 /app/*(前缀匹配),不是精确匹配。
  5. @ConditionalOnMissingBean(WebMvcConfigurationSupport.class) = @EnableWebMvc 让整个 WebMvcAutoConfiguration 失效。
  6. HiddenHttpMethodFilter 默认关、FormContentFilter 默认开; @ConditionalOnBooleanProperty(@since 3.5.0)的 havingValue 默认就是 "true"。
  7. WebMvcAutoConfigurationAdapter 的 logger 名是 WebMvcConfigurer.class,不是本类。
  8. EnableWebMvcConfiguration 覆写 flashMapManager()/viewNameTranslator() 只为加 @ConditionalOnMissingBean(name = ...) ,方法体是 super.xxx()。
  9. mvcValidator() 用字符串类名做 ClassUtils.isPresent 双检(webmvc 不依赖 validation 模块)。
  10. WebMvcRegistrations 用 getIfUnique()------多个实现时全部失效,不抛异常。
  11. 改了 spring.mvc.static-path-pattern 会让静态 index.html 不再作为欢迎页 (WelcomePage.resolve 里硬编码判断 "/**".equals(staticPathPattern))。
  12. WelcomePageHandlerMapping.setOrder(2) > RequestMappingHandlerMapping 的 0, 所以 @GetMapping("/") 优先于 index.html。
  13. DefaultServerHttpMessageConvertersCustomizer:有 legacy HttpMessageConverters bean 时 不调 registerDefaults()(旧类型语义是"完整列表")。
  14. @ConditionalOnMissingBean(..., ignoredType = {...}) 忽略 HATEOAS / Data REST 的特化子类, 否则 Boot 的通用 JSON 转换器不会注册。
  15. Jackson 3 的 groupId 是 tools.jackson (3.1.5),Jackson 2 是 com.fasterxml.jackson(2.21.5)。
  16. JsonMapper.Builder 是 prototype ,JsonMapper 是单例 + @Primary------"共享配置,独享实例"。
  17. 三个 Standard*FactoryBuilderCustomizer 的方法体只有 super.customize(...), 纯粹为了绑定泛型 + 实现接口。
  18. SpringBeanHandlerInstantiator 是 Framework 同类的复制品 ,理由是 "without requiring spring-web"。
  19. Jackson2AutoConfiguration 是 @Deprecated(since = "4.0.0", forRemoval = true),4.3.0 移除。
  20. 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"。

三个必须对齐的点:

  1. 生成的代码要与运行时行为等价 (Class vs String 两种形式)
  2. 可见性要降级处理 (AccessControl.lowest → 非 public 用字符串)
  3. 反射目标要补 hints (BindingReflectionHintsRegistrar)

判据 :看到某个 bean 的构造依赖 classpath 扫描或 ServiceLoader, 去同包下找 *BeanRegistrationAotProcessor------大概率有一个。 第 28 章会系统展开 AOT。


14. 下一章预告

第 20 章进入 WebFlux 与 Reactor Netty:响应式栈与编解码。 核心问题包括:

  • WebFluxAutoConfiguration 与 MVC 版的结构差异(两者是"平行宇宙"还是共享抽象?)
  • CodecCustomizer 与 ServerCodecConfigurer------为什么 reactive 侧不用 HttpMessageConverter
  • ReactiveWebServerFactory 与第 18 章的 WebServer 如何衔接
  • NettyReactiveWebServerFactory 的 HttpServer 配置链(NettyRouteProvider 的完整机制)
  • 为什么 HttpMessageConvertersAutoConfiguration 用 NotReactiveWebApplicationCondition 把自己排除

第 18 章遗留的 NettyRouteProvider、DelayedInitializationHttpHandler 会在那里接上。

相关推荐
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 章节复盘:微服务骨架与手机号验证码登录
java·微服务·交友
data analyse 4561 小时前
Session数怎么计算?明确会话切分、超时与跨页规则
java·服务器·数据库
for_ever_love__1 小时前
Agentic RAG 实战:让 Agent 自己决定怎么检索、检索几次
人工智能·后端·agent·控制·自适应·rag·检索策略
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 28 章 AOT 与 GraalVM Native与序列化模型
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 27 章 · 可执行归档与 Loader:`JarLauncher` 与嵌套 jar
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 25 章 · Actuator 与健康:端点机制、`HealthIndicator`、管理端口子上下文
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 16 章 · 排序、元数据与报告:`AutoConfigurationSorter`、配置元数据、条件报告
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 14 章 · 自动配置引擎:`AutoConfigurationImportSelector` 全流程
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 24 章 · 日志系统:`LoggingApplicationListener` 与结构化日志
java·后端·开源