Spring Boot 4.1.1 深度源码解析-第 20 章 · WebFlux 与 Reactor Netty:响应式栈与编解码

Spring Boot 4.1.1 深度源码解析-第 20 章 · WebFlux 与 Reactor Netty:响应式栈与编解码

本章定位 :卷五 · Web 栈的第四站。第 19 章讲了 servlet 栈,本章讲与之平行的响应式栈。

这两章的关系不是"上下篇",而是"镜像 "。Spring Boot 对 MVC 和 WebFlux 的自动配置 结构高度对称:同样的三层嵌套、同样的 @ConditionalOnMissingBean(WebFluxConfigurationSupport.class)、 同样的 @AutoConfigureOrder(HIGHEST_PRECEDENCE + 10)、同样的 afterName = ValidationAutoConfiguration。

但差异才是重点:HttpMessageConverter 在 reactive 栈里根本不存在 , 取而代之的是 Encoder/Decoder 体系;没有 DispatcherServlet ,取而代之的是 HttpHandler; 没有错误 Controller ,取而代之的是 ErrorWebExceptionHandler。

源码坐标 :module/spring-boot-webflux、module/spring-boot-http-codec、 module/spring-boot-reactor-netty、module/spring-boot-web-server(reactive 部分)。


0. 引子:同一个注解,两条完全不同的管线

java 复制代码
@RestController
public class HelloController {
    @GetMapping("/hello")
    public Mono<Map<String, String>> hello() {
        return Mono.just(Map.of("msg", "hi"));
    }
}

把这段代码放进 servlet 应用(第 19 章)和 reactive 应用,注解一个字都不用改, 但底下走的是两套完全不同的代码:

scss 复制代码
servlet 栈:
  Tomcat → Filter 链 → DispatcherServlet
        → DispatcherServlet.doDispatch()
        → RequestMappingHandlerMapping.getHandler(request)   ← 阻塞式查找
        → HandlerAdapter.handle(request, response, handler)
        → HttpMessageConverter.write(...)                    ← OutputStream
        → response.getOutputStream().write(...)

reactive 栈:
  Netty → ReactorHttpHandlerAdapter
        → HttpHandler.handle(request, response)              ← 返回 Mono<Void>
        → DispatcherHandler(不是 DispatcherServlet!)
        → RequestMappingHandlerMapping.getHandler(exchange)  ← Mono<Handler>
        → HandlerResultHandler.write(...)                    ← Publisher<DataBuffer>

最本质的差别 :servlet 栈的 handle 返回 ModelAndView, reactive 栈的 handle 返回 Mono<Void> ------ 它不"返回结果",它"订阅一个副作用"。

这个差别一路传导到最上层:

  • servlet 用 ServletOutputStream.write(byte[]),阻塞
  • reactive 用 Publisher<DataBuffer>,背压
  • servlet 的转换器接口是 HttpMessageConverter<T>(一次性读写)
  • reactive 的编解码器是 Encoder<T> / Decoder<T>(流式、可分片)

所以"为什么 reactive 侧不用 HttpMessageConverter"不是设计偏好,而是物理约束。


1. spring-boot-webflux 模块全景

第一定律(先查 *.imports):

vbnet 复制代码
module/spring-boot-webflux/src/main/resources/META-INF/spring/
├── org.springframework.boot.autoconfigure.AutoConfiguration.imports  (9 条)
│   ├── HttpHandlerAutoConfiguration
│   ├── ReactiveMultipartAutoConfiguration
│   ├── WebFluxAutoConfiguration
│   ├── WebFluxObservationAutoConfiguration
│   ├── WebSessionIdResolverAutoConfiguration
│   ├── actuate.endpoint.web.WebFluxHealthEndpointExtensionAutoConfiguration
│   ├── actuate.web.exchanges.WebFluxHttpExchangesAutoConfiguration
│   ├── actuate.web.mappings.WebFluxMappingsAutoConfiguration
│   └── error.ErrorWebFluxAutoConfiguration
└── org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports
    ├── actuate.web.WebFluxEndpointManagementContextConfiguration
    └── actuate.web.WebFluxManagementChildContextConfiguration

与 webmvc 对比:

webmvc webflux
普通自动配置 3 5
Actuator 相关 3 4
测试专用通道(AutoConfigureWebServer.imports) 有 无
合计 .imports 条目 6 9

webflux 没有 AutoConfigureWebServer.imports ------ 第 19 章那个只有 DispatcherServletAutoConfiguration 的测试通道是 servlet 特有的。 reactive 的 @AutoConfigureWebTestClient 走的是另一条路(未核实其注册点)。

webflux 多出来的两个:

  • HttpHandlerAutoConfiguration ------ servlet 栈没有对应物(DispatcherServlet 由 servlet 容器驱动)
  • WebSessionIdResolverAutoConfiguration ------ session 机制的差异

模块文件数(不含测试):webmvc 48 / webflux 51(第 18 章统计过)。


2. 结构对照:平行宇宙的两个自动配置

2.1 类级注解逐条对比

java 复制代码
// MVC
@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 {
java 复制代码
// WebFlux
@AutoConfiguration(
        after = { ReactiveMultipartAutoConfiguration.class, WebSessionIdResolverAutoConfiguration.class,
                CodecsAutoConfiguration.class },
        afterName = "org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration")
@ConditionalOnWebApplication(type = Type.REACTIVE)
@ConditionalOnClass(WebFluxConfigurer.class)
@ConditionalOnMissingBean({ WebFluxConfigurationSupport.class })
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
@ImportRuntimeHints(WebResourcesRuntimeHints.class)
public final class WebFluxAutoConfiguration {

逐条对照:

项 MVC WebFlux 说明
after DispatcherServletAutoConfiguration、TaskExecutionAutoConfiguration ReactiveMultipartAutoConfiguration、WebSessionIdResolverAutoConfiguration、CodecsAutoConfiguration 都表达了"我依赖的东西"
afterName ValidationAutoConfiguration ValidationAutoConfiguration 完全相同
@ConditionalOnWebApplication SERVLET REACTIVE 唯一的本质差异
@ConditionalOnClass 3 个类 1 个类 (WebFluxConfigurer) WebFlux 不需要 servlet API
@ConditionalOnMissingBean WebMvcConfigurationSupport WebFluxConfigurationSupport 对称 :@EnableWebFlux 让整个配置失效
@AutoConfigureOrder HIGHEST_PRECEDENCE + 10 HIGHEST_PRECEDENCE + 10 完全相同
@ImportRuntimeHints WebResourcesRuntimeHints WebResourcesRuntimeHints 完全相同(同一个类!)

@ConditionalOnClass(WebFluxConfigurer.class) 只有 1 个类 ------ 这反映了 WebFlux 的一个特点:它不依赖 servlet API , spring-webflux 的依赖树里没有 jakarta.servlet。 第 17 章讲 WebApplicationType 时说过:SERVLET 的判定需要 jakarta.servlet.Servlet 且 ConfigurableWebApplicationContext------ WebFlux 应用两个都不满足,正好落入 NONE 兜底,靠 WebFluxWebApplicationTypeDeducer 判成 REACTIVE。

WebResourcesRuntimeHints 是同一个类 (都在 org.springframework.boot.web 包下)------ 这是两个栈共享基础设施 的证据。第 03 章讲的 4.x 模块化改造把这类公共部分抽到了 spring-boot-web-server / 核心包。

2.2 内部结构的对称

java 复制代码
MVC                                          WebFlux
─────────────────────────────────────────    ─────────────────────────────────────────
WebMvcAutoConfiguration                      WebFluxAutoConfiguration
├── hiddenHttpMethodFilter  @Bean            ├── hiddenHttpMethodFilter  @Bean
├── formContentFilter       @Bean            │   (无对应,reactive 无此需求)
├── WebMvcAutoConfigurationAdapter           ├── WebFluxConfig
│   implements WebMvcConfigurer              │   implements WebFluxConfigurer
│   @Order(0) @Import(Enable...)             │   @Order(0) @Import(Enable...)
├── EnableWebMvcConfiguration                ├── EnableWebFluxConfiguration
│   extends DelegatingWebMvcConfiguration    │   extends DelegatingWebFluxConfiguration
├── ResourceChainCustomizerConfiguration     ├── ResourceChainCustomizerConfiguration
└── ProblemDetailsErrorHandlingConfiguration └── ProblemDetailsErrorHandlingConfiguration
                                             ├── WelcomePageConfiguration  ← 多一个

连 configureApiVersioning 的实现都逐行相同:

java 复制代码
// MVC
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
    PropertyMapper map = PropertyMapper.get();
    Apiversion properties = this.mvcProperties.getApiversion();
    map.from(properties::getRequired).to(configurer::setVersionRequired);
    ...
}

// WebFlux
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
    PropertyMapper map = PropertyMapper.get();
    Apiversion properties = this.webFluxProperties.getApiversion();
    map.from(properties::getRequired).to(configurer::setVersionRequired);
    ...
}

只有 properties 的来源不同 (WebMvcProperties vs WebFluxProperties), 其余一字不差 。这说明 API 版本控制是 Framework 层的公共能力, Boot 只是各接一份配置属性。

这也提示了一个重构机会:这段代码理论上可以抽成一个共享的 ApiVersioningCustomizer。Boot 没这么做,可能是为了保持两个模块的独立性 (第 03 章讲的模块化目标之一)。这是我的推测,不是源码事实。

2.3 五个关键差异

# 差异点 MVC WebFlux
1 转换器接口 HttpMessageConverter<T> Encoder<T> / Decoder<T>
2 配置回调 configureMessageConverters(ServerBuilder) configureHttpMessageCodecs(ServerCodecConfigurer)
3 入口组件 DispatcherServlet(容器驱动) HttpHandler(Boot 造 bean)
4 错误处理 BasicErrorController(@Controller) ErrorWebExceptionHandler(@Order(-1) bean)
5 异步执行器 configureAsyncSupport configureBlockingExecution

第 5 点值得细看:

java 复制代码
// MVC
@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
    if (this.beanFactory.containsBean(APPLICATION_TASK_EXECUTOR_BEAN_NAME)) {
        Object taskExecutor = this.beanFactory.getBean(APPLICATION_TASK_EXECUTOR_BEAN_NAME);
        if (taskExecutor instanceof AsyncTaskExecutor asyncTaskExecutor) {
            configurer.setTaskExecutor(asyncTaskExecutor);
        }
    }
    ...
}

// WebFlux
@Override
public void configureBlockingExecution(BlockingExecutionConfigurer configurer) {
    if (Threading.VIRTUAL.isActive(this.environment) && this.beanFactory
        .containsBean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)) {
        Object taskExecutor = this.beanFactory.getBean(APPLICATION_TASK_EXECUTOR_BEAN_NAME);
        if (taskExecutor instanceof AsyncTaskExecutor asyncTaskExecutor) {
            configurer.setExecutor(asyncTaskExecutor);
        }
    }
}

WebFlux 多了一个 Threading.VIRTUAL.isActive(this.environment) 条件。

Threading.VIRTUAL 是 Framework 6.1/7 的枚举,isActive(Environment) 检查 spring.threads.virtual.enabled(默认是否开启我未核实)。

语义差异:

  • MVC 的 configureAsyncSupport 是"异步请求 (@Async、Callable 返回值)用什么线程池"
  • WebFlux 的 configureBlockingExecution 是"阻塞式 handler(返回非 Publisher 的 controller 方法) 在什么线程上执行"

WebFlux 里 controller 方法默认应该在 event loop 线程上执行,绝不能阻塞 。 但如果用户写了一个返回 String(而非 Mono<String>)的方法, Framework 需要把它调度到别的线程池------这就是 BlockingExecutionConfigurer 的用途。

只在虚拟线程开启时才配置,因为:

  • 不开虚拟线程 → Framework 用默认的 Schedulers.boundedElastic()(Reactor 的线程池)
  • 开虚拟线程 → 用 Boot 的 applicationTaskExecutor(虚拟线程执行器), 这样阻塞调用不会耗尽平台线程

这是一个**"虚拟线程与响应式共存"**的设计,第 30 章会展开 spring.threads.virtual.*。

2.4 为什么 WebFlux 没有 DispatcherServletAutoConfiguration 的对应物?

servlet 栈需要单独的 DispatcherServletAutoConfiguration 是因为: DispatcherServlet 必须注册进 servlet 容器 (通过 ServletRegistrationBean), 且这个注册涉及 URL mapping、load-on-startup、multipart config 等容器级概念。

reactive 栈没有容器级的 servlet 注册 ------HttpHandler 只是一个 bean, 由 HttpHandlerAutoConfiguration 造出来,然后被 ReactiveWebServerApplicationContext 直接交给 ReactiveWebServerFactory.getWebServer(httpHandler)。

所以 WebFlux 侧的对应物是 HttpHandlerAutoConfiguration,但它简单得多 (只有一个 @Bean 方法,没有那些复杂的条件类)。

这是一个很有说服力的对比 :servlet 栈的复杂度很大一部分来自 "servlet 容器的 API 很笨重"(第 18 章反复看到的"阻止端口提前监听"也是这个原因)。


3. WebFluxAutoConfiguration 详解

3.1 hiddenHttpMethodFilter:只有一个

java 复制代码
@Bean
@ConditionalOnMissingBean(HiddenHttpMethodFilter.class)
@ConditionalOnBooleanProperty("spring.webflux.hiddenmethod.filter.enabled")
OrderedHiddenHttpMethodFilter hiddenHttpMethodFilter() {
    return new OrderedHiddenHttpMethodFilter();
}

注意属性前缀是 spring.webflux.hiddenmethod.filter.enabled , MVC 那边是 spring.mvc.hiddenmethod.filter.enabled。 两个前缀不对称 (spring.mvc.* vs spring.webflux.*)------ 这是历史遗留:MVC 的配置一直在 spring.mvc 下, 而 WebFlux 出现时已经晚了,用了 spring.webflux。

OrderedHiddenHttpMethodFilter 在 webflux 模块里有自己的实现 (org.springframework.boot.webflux.filter.OrderedHiddenHttpMethodFilter), 而不是复用 webmvc 的那个------因为 reactive 的 filter 是 WebFilter, 不是 servlet 的 Filter。类型不同,只能各写一份。

3.2 WelcomePageConfiguration:一个返回 @Nullable 的 @Bean

java 复制代码
@Configuration(proxyBeanMethods = false)
static class WelcomePageConfiguration {

    @Bean
    @Nullable RouterFunctionMapping welcomePageRouterFunctionMapping(ApplicationContext applicationContext,
            WebFluxProperties webFluxProperties, WebProperties webProperties) {
        String[] staticLocations = webProperties.getResources().getStaticLocations();
        WelcomePageRouterFunctionFactory factory = new WelcomePageRouterFunctionFactory(
                new TemplateAvailabilityProviders(applicationContext), applicationContext, staticLocations,
                webFluxProperties.getStaticPathPattern());
        RouterFunction<ServerResponse> routerFunction = factory.createRouterFunction();
        if (routerFunction != null) {
            RouterFunctionMapping routerFunctionMapping = new RouterFunctionMapping(routerFunction);
            routerFunctionMapping.setOrder(1);
            return routerFunctionMapping;
        }
        return null;                      // ★ 返回 null
    }
}

一个 @Bean 方法可以返回 null ------这在 Boot 源码里很罕见。 返回 null 意味着不注册这个 bean。

为什么 MVC 那边不这么做?看第 19 章:MVC 的 welcomePageHandlerMapping 总是创建 , 即使 WelcomePage.UNRESOLVED,也是创建一个"没有 root handler"的空 mapping。 因为 WelcomePageHandlerMapping 的构造函数里, if (welcomePage != WelcomePage.UNRESOLVED) 才 setRootHandler------ 创建一个空的 mapping 是无害的。

而 reactive 侧如果创建了空的 RouterFunctionMapping,可能会干扰路由匹配 (推测 :空 router function 可能匹配所有请求或产生奇怪行为)。 所以 Boot 选择不注册。

⚠️ 未核实 :返回 null 的 @Bean 在 Spring 里的确切行为。 我知道 @Bean 返回 null 会导致"不注册"(Framework 的 ConfigurationClassBeanDefinitionReader 里有 if (beanInstance != null) 之类的判断),但没有逐行确认。 另外 @Nullable 在这里是给 NullAway / IDE 看的(Boot 4 大量使用 JSpecify 的 @Nullable), 不是给 Spring 看的。

routerFunctionMapping.setOrder(1)------对比 MVC 的 WelcomePageHandlerMapping.setOrder(2)。 数值不同(1 vs 2),因为两边的 handler mapping 体系里其他组件的 order 不同, 不能简单类比。

3.3 WebFluxConfig:注入了 Environment

java 复制代码
static class WebFluxConfig implements WebFluxConfigurer {

    private static final Log logger = LogFactory.getLog(WebFluxConfig.class);
    // ★ 注意:这里用的是 WebFluxConfig.class,不是 WebFluxConfigurer.class

    private final Environment environment;
    private final Resources resourceProperties;
    private final WebFluxProperties webFluxProperties;
    private final ListableBeanFactory beanFactory;
    private final ObjectProvider<HandlerMethodArgumentResolver> argumentResolvers;
    private final ObjectProvider<CodecCustomizer> codecCustomizers;
    private final ObjectProvider<ResourceHandlerRegistrationCustomizer> resourceHandlerRegistrationCustomizers;
    private final ObjectProvider<ViewResolver> viewResolvers;
    private final ObjectProvider<ApiVersionResolver> apiVersionResolvers;
    private final ObjectProvider<ApiVersionParser<?>> apiVersionParser;
    private final ObjectProvider<ApiVersionDeprecationHandler> apiVersionDeprecationHandler;

两处与 MVC 不同:

  1. logger 用的是本类 WebFluxConfig.class ,而 MVC 用的是 WebMvcConfigurer.class。 第 19 章我标注了 MVC 那个"可能是 copy-paste 的瑕疵"------ WebFlux 这里用对了,这反而佐证了 MVC 那边确实是不小心。
  2. 多注入了一个 Environment ------就为了 2.3 节的 Threading.VIRTUAL.isActive(environment)。

3.4 configureHttpMessageCodecs:编解码器的装配点

java 复制代码
@Override
public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) {
    this.codecCustomizers.orderedStream().forEach((customizer) -> customizer.customize(configurer));
}

与 MVC 的 configureMessageConverters(ServerBuilder builder) 结构完全一样------ 都是"把定制器链转发给 Framework 的 builder"。

区别只是 builder 的类型:

  • MVC: ServerBuilder(org.springframework.http.converter)
  • WebFlux: ServerCodecConfigurer(org.springframework.http.codec)

这是"平行宇宙"最直接的证据:Boot 在这两个地方做的事情一模一样, 只是类型不同。真正复杂的逻辑在 Framework 侧。

configureArgumentResolvers 是 WebFlux 独有的(MVC 没有这个回调):

java 复制代码
@Override
public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) {
    this.argumentResolvers.orderedStream().forEach(configurer::addCustomResolver);
}

用户可以注册 HandlerMethodArgumentResolver bean 来扩展参数解析。

3.5 addResourceHandlers:两个独立的 if

java 复制代码
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    if (!this.resourceProperties.isAddMappings()) {
        logger.debug("Default resource handling disabled");
        return;
    }
    List<ResourceHandlerRegistrationCustomizer> resourceHandlerRegistrationCustomizers =
        this.resourceHandlerRegistrationCustomizers.orderedStream().toList();
    String webjarsPathPattern = this.webFluxProperties.getWebjarsPathPattern();
    if (!registry.hasMappingForPattern(webjarsPathPattern)) {
        ResourceHandlerRegistration registration = registry.addResourceHandler(webjarsPathPattern)
            .addResourceLocations("classpath:/META-INF/resources/webjars/");
        configureResourceCaching(registration);
        resourceHandlerRegistrationCustomizers.forEach((customizer) -> customizer.customize(registration));
    }
    String staticPathPattern = this.webFluxProperties.getStaticPathPattern();
    if (!registry.hasMappingForPattern(staticPathPattern)) {
        ResourceHandlerRegistration registration = registry.addResourceHandler(staticPathPattern)
            .addResourceLocations(this.resourceProperties.getStaticLocations());
        configureResourceCaching(registration);
        resourceHandlerRegistrationCustomizers.forEach((customizer) -> customizer.customize(registration));
    }
}

结构差异(对比 MVC):

MVC WebFlux
重复检查 抽成 addResourceHandler(registry, pattern, ...) 私有方法,方法内 if (registry.hasMappingForPattern(pattern)) return; 两处独立的 if
定制器获取 构造期 getIfAvailable()(单个) 使用期 orderedStream().toList()(多个)
webapp 根 加 ServletContextResource(servletContext, "/") 无(没有 servlet context)

MVC 抽了私有方法,WebFlux 写了两遍 ------这又是一处不对称。 WebFlux 版把定制器链放在方法内 获取且支持多个 , 比 MVC 版的"构造期取单个"更合理。推测 WebFlux 版是后来写的、写得更好, 但 MVC 版没跟着改(改动会影响公开行为,Boot 很保守)。

3.6 configureResourceCaching:cachePeriod 到 maxAge 的回退

java 复制代码
private void configureResourceCaching(ResourceHandlerRegistration registration) {
    Duration cachePeriod = this.resourceProperties.getCache().getPeriod();
    WebProperties.Resources.Cache.Cachecontrol cacheControl = this.resourceProperties.getCache().getCachecontrol();
    if (cachePeriod != null && cacheControl.getMaxAge() == null) {
        cacheControl.setMaxAge(cachePeriod);
    }
    CacheControl httpCacheControl = cacheControl.toHttpCacheControl();
    if (httpCacheControl != null) {
        registration.setCacheControl(httpCacheControl);
    }
    registration.setUseLastModified(this.resourceProperties.getCache().isUseLastModified());
}

关键逻辑 :cachePeriod != null && cacheControl.getMaxAge() == null ------ cachePeriod 是旧属性,cachecontrol.max-age 是新属性, 旧的只在新的没配时生效。这是标准的"废弃属性兼容"模式。

对比 MVC 版:

java 复制代码
Integer cachePeriod = getSeconds(this.resourceProperties.getCache().getPeriod());
if (cachePeriod != null) {
    registration.setCachePeriod(cachePeriod);       // ← 直接 setCachePeriod
}
CacheControl cacheControl = this.resourceProperties.getCache().getCachecontrol().toHttpCacheControl();
if (cacheControl != null) {
    registration.setCacheControl(cacheControl);
}

MVC 版是先 setCachePeriod 再 setCacheControl(后者覆盖前者) , 而 WebFlux 版是在数据层做回退 (把 cachePeriod 写进 cacheControl.maxAge)。 两种实现都能达到"新属性优先"的效果,但 WebFlux 版更清晰: 只调一次 setter,不会有两个 setter 互相覆盖的隐含顺序。

registration.setCachePeriod(...) 是 deprecated API (Framework 7 待移除), 所以 WebFlux 版干脆不用它------又一次看到 forRemoval 驱动的实现差异。

3.7 EnableWebFluxConfiguration:与 MVC 版的高度对称

java 复制代码
/**
 * Configuration equivalent to {@code @EnableWebFlux}.
 */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({ WebProperties.class, ServerProperties.class })
@ImportRuntimeHints(WebFluxValidatorRuntimeHints.class)
static class EnableWebFluxConfiguration extends DelegatingWebFluxConfiguration
        implements EmbeddedValueResolverAware {

连 Javadoc 都一样 ("Configuration equivalent to @EnableWebFlux", MVC 是 "equivalent to @EnableWebMvc")。

覆写的方法:

方法 MVC 版 WebFlux 版
createRequestMappingHandlerAdapter() ✅ ✅
createRequestMappingHandlerMapping() ✅ ✅
createExceptionHandlerExceptionResolver() ✅ ❌ 没有
webFluxConversionService() / mvcConversionService() ✅ ✅
webFluxValidator() / mvcValidator() ✅ ✅(逻辑逐行相同)
localeContextResolver() / localeResolver() ✅ ✅
webSessionManager() ❌ 无 ✅ 有
webFluxApiVersionStrategy() / mvcApiVersionStrategy() ✅ ✅

两个独有/缺失:

  1. WebFlux 没有 createExceptionHandlerExceptionResolver() ------ 因为 reactive 栈的异常处理走 WebExceptionHandler(不是 HandlerExceptionResolver), 而且 ExceptionHandlerExceptionResolver 是 servlet 专用类型。
  2. WebFlux 有 webSessionManager() ------ servlet 的 session 由容器 管理, reactive 的 session 必须由应用 管理(WebSessionManager bean)。

WebFluxRegistrations 也比 WebMvcRegistrations 少一个方法 : 只有 getRequestMappingHandlerMapping / getRequestMappingHandlerAdapter, 没有 getExceptionHandlerExceptionResolver------一一对应。

3.8 webSessionManager:MaxIdleTimeInMemoryWebSessionStore

java 复制代码
@Bean
@ConditionalOnMissingBean(name = WebHttpHandlerBuilder.WEB_SESSION_MANAGER_BEAN_NAME)
WebSessionManager webSessionManager(ObjectProvider<WebSessionIdResolver> webSessionIdResolver) {
    DefaultWebSessionManager webSessionManager = new DefaultWebSessionManager();
    Duration timeout = this.serverProperties.getReactive().getSession().getTimeout();
    int maxSessions = this.serverProperties.getReactive().getSession().getMaxSessions();
    MaxIdleTimeInMemoryWebSessionStore sessionStore = new MaxIdleTimeInMemoryWebSessionStore(timeout);
    sessionStore.setMaxSessions(maxSessions);
    webSessionManager.setSessionStore(sessionStore);
    webSessionIdResolver.ifAvailable(webSessionManager::setSessionIdResolver);
    return webSessionManager;
}

又一次"按名字协作" (第 19 章的第十九定律): @ConditionalOnMissingBean(name = WebHttpHandlerBuilder.WEB_SESSION_MANAGER_BEAN_NAME)------ WebHttpHandlerBuilder 按固定名字找 session manager。

MaxIdleTimeInMemoryWebSessionStore 是一个内部静态类:

java 复制代码
static final class MaxIdleTimeInMemoryWebSessionStore extends InMemoryWebSessionStore {

    private final Duration timeout;

    private MaxIdleTimeInMemoryWebSessionStore(Duration timeout) {
        this.timeout = timeout;
    }

    @Override
    public Mono<WebSession> createWebSession() {
        return super.createWebSession().doOnSuccess(this::setMaxIdleTime);
    }

    private void setMaxIdleTime(WebSession session) {
        session.setMaxIdleTime(this.timeout);
    }
}

为什么要这个子类? 因为 InMemoryWebSessionStore 的 setMaxIdleTime 是全局的 (store 级别),而 Boot 想让它来自 server.reactive.session.timeout。 其实直接用 store.setMaxIdleTime(timeout) 也行......

⚠️ 推测 :真正的差别是------用子类可以在每个 session 创建时 设置, 而 InMemoryWebSessionStore 是否有 setMaxIdleTime(Duration) 方法我未确认。 如果它有,这个子类就是多余的;如果它没有(只有 per-session 的 API), 子类就是必需的。自测方法 :读 InMemoryWebSessionStore 的 API。

另一个可能的理由:doOnSuccess 的写法保证只在成功时设置, 而构造期 setMaxIdleTime 会在 store 还没就绪时被调用。

注意 server.reactive.session.*(不是 spring.*)------ session 配置属于服务器 配置,与第 18 章的 ServerProperties 同源。

3.9 localeContextResolver:又一处按名字

java 复制代码
@Bean
@Override
@ConditionalOnMissingBean(name = WebHttpHandlerBuilder.LOCALE_CONTEXT_RESOLVER_BEAN_NAME)
public LocaleContextResolver localeContextResolver() {
    Locale locale = this.webProperties.getLocale();
    if (this.webProperties.getLocaleResolver() == WebProperties.LocaleResolver.FIXED) {
        Assert.state(locale != null, "'locale' must not be null");
        return new FixedLocaleContextResolver(locale);
    }
    AcceptHeaderLocaleContextResolver localeContextResolver = new AcceptHeaderLocaleContextResolver();
    localeContextResolver.setDefaultLocale(locale);
    return localeContextResolver;
}

与 MVC 的 localeResolver() 逻辑相同 ,但类型是 LocaleContextResolver(reactive 版)。 WebProperties.LocaleResolver.FIXED 枚举是两个栈共享的。

Assert.state(locale != null, "'locale' must not be null") ------ FIXED 模式下 spring.web.locale 必填,否则启动失败。 这是一个配置完整性检查,比让它静默用系统默认更好。


4. HttpHandlerAutoConfiguration:WebFlux 的"DispatcherServlet"

4.1 只有一个 @Bean

java 复制代码
@AutoConfiguration(after = { WebFluxAutoConfiguration.class })
@ConditionalOnClass({ DispatcherHandler.class, HttpHandler.class })
@ConditionalOnWebApplication(type = Type.REACTIVE)
@ConditionalOnMissingBean(HttpHandler.class)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
public final class HttpHandlerAutoConfiguration {

    @Configuration(proxyBeanMethods = false)
    static class AnnotationConfig {

        private final ApplicationContext applicationContext;

        AnnotationConfig(ApplicationContext applicationContext) {
            this.applicationContext = applicationContext;
        }

        @Bean
        HttpHandler httpHandler(ObjectProvider<WebFluxProperties> propsProvider,
                ObjectProvider<WebHttpHandlerBuilderCustomizer> handlerBuilderCustomizers) {
            WebFluxProperties properties = propsProvider.getIfAvailable();
            WebHttpHandlerBuilder handlerBuilder = WebHttpHandlerBuilder.applicationContext(this.applicationContext);
            if (properties != null) {
                handlerBuilder.defaultHtmlEscape(properties.getDefaultHtmlEscape());
            }
            handlerBuilderCustomizers.orderedStream().forEach((customizer) -> customizer.customize(handlerBuilder));
            HttpHandler httpHandler = handlerBuilder.build();
            if (properties != null && StringUtils.hasText(properties.getBasePath())) {
                Map<String, HttpHandler> handlersMap = Collections.singletonMap(properties.getBasePath(), httpHandler);
                return new ContextPathCompositeHandler(handlersMap);
            }
            return httpHandler;
        }
    }
}

四个要点:

  1. @ConditionalOnMissingBean(HttpHandler.class) 在类级 ------ 用户可以自己提供一个 HttpHandler bean 完全接管。这是 WebFlux 的"最大粒度开关" (对应 MVC 的 @ConditionalOnMissingBean(WebMvcConfigurationSupport.class))。

  2. WebHttpHandlerBuilder.applicationContext(applicationContext) ------ 这是关键:Framework 从容器里 收集 WebExceptionHandler、WebFilter、 WebSessionManager、LocaleContextResolver 等,组装成一个 HttpHandler。 所以这些 bean 的名字必须正确(3.8、3.9 节的按名字协作就是这个原因)。

  3. propsProvider.getIfAvailable() 而不是直接注入 ------ 因为 WebFluxProperties 可能没被 @EnableConfigurationProperties 注册 (比如用户排除了某些配置)。用了 ObjectProvider + 两处 null 检查。

  4. basePath 用 ContextPathCompositeHandler 包装 ------ 这是 reactive 版的 server.servlet.context-path:

    java 复制代码
    Map<String, HttpHandler> handlersMap = Collections.singletonMap(properties.getBasePath(), httpHandler);
    return new ContextPathCompositeHandler(handlersMap);

    把"路径前缀 → handler"的映射交给一个路由型 handler。

4.2 basePath 的清洗

WebFluxProperties 里有一个 cleanBasePath 方法(第 74 行):

java 复制代码
private @Nullable String cleanBasePath(@Nullable String basePath) { ... }

未读实现 ,但从名字推断是去掉首尾斜杠、处理空串。 与第 19 章 DispatcherServletPath.getServletUrlMapping() 的四条规则呼应------ 两个栈都有"把用户给的路径规范化"的逻辑。

⚠️ 未核实 :cleanBasePath 的具体实现与调用点(应该在 getBasePath() 里)。

4.3 defaultHtmlEscape

java 复制代码
handlerBuilder.defaultHtmlEscape(properties.getDefaultHtmlEscape());

spring.webflux.default-html-escape 控制视图渲染时是否 HTML 转义, 默认 null(即不强制,交给视图引擎)。这是一个安全相关的默认值------ Boot 没有默认开启,因为会让已经自己转义的应用出现双重转义。

4.4 WebHttpHandlerBuilderCustomizer:唯一的用户扩展点

java 复制代码
@FunctionalInterface
public interface WebHttpHandlerBuilderCustomizer {
    void customize(WebHttpHandlerBuilder webHttpHandlerBuilder);
}

这是 WebFlux 栈最深层的 用户扩展点------能改 WebHttpHandlerBuilder 的一切 (加 filter、换 session manager、换 exception handler)。

对比 servlet 栈:没有完全对应的东西(DispatcherServlet 的定制要通过 WebMvcConfigurer 或 WebMvcRegistrations)。 reactive 栈的扩展点反而更集中------因为它的入口就是一个 bean。


5. CodecsAutoConfiguration:为什么不用 HttpMessageConverter

5.1 概念差异

servlet 栈 reactive 栈
接口 HttpMessageConverter<T> Encoder<T> / Decoder<T>
读方法 read(Class, HttpInputMessage) → T decode(Publisher<DataBuffer>, ...) → Flux<T> / Mono<T>
写方法 write(T, MediaType, HttpOutputMessage) → void encode(Publisher<T>, ...) → Flux<DataBuffer>
返回类型 阻塞值 / void Publisher
背压 无 有(通过 Publisher)

核心区别 :HttpMessageConverter.write 的签名是 void + HttpOutputMessage, 它必须自己把字节写完 ;Encoder.encode 返回 Flux<DataBuffer>, 它只是"声明"数据流,由订阅者决定何时消费。

所以 HttpMessageConverter 在 reactive 栈里根本没法用------ 第 0 节说的"物理约束"就是这个。

5.2 CodecCustomizer:函数式接口 + lambda 实现

java 复制代码
@FunctionalInterface
public interface CodecCustomizer {
    void customize(CodecConfigurer configurer);
}

Boot 自己用它时直接返 lambda:

java 复制代码
@Bean
@Order(0)
@ConditionalOnBean(JsonMapper.class)
CodecCustomizer jacksonCodecCustomizer(JsonMapper jsonMapper) {
    return (configurer) -> {
        CodecConfigurer.DefaultCodecs defaults = configurer.defaultCodecs();
        defaults.jacksonJsonDecoder(new JacksonJsonDecoder(jsonMapper));
        defaults.jacksonJsonEncoder(new JacksonJsonEncoder(jsonMapper));
    };
}

对比第 19 章 JacksonJsonHttpMessageConvertersCustomizer(一个具名类) ------ reactive 侧用 lambda,servlet 侧用具名类。 原因:CodecCustomizer 是 @FunctionalInterface 且实现很短; 而 servlet 侧的 customizer 要同时实现两个接口 (ClientHttpMessageConvertersCustomizer + ServerHttpMessageConvertersCustomizer), lambda 做不到。这是接口设计导致的差异,不是随意的。

5.3 三个 JSON 配置类

kotlin 复制代码
CodecsAutoConfiguration
├── JacksonJsonCodecConfiguration          @ConditionalOnClass(JsonMapper)
│   └── @ConditionalOnProperty(preferred-json-mapper=jackson, matchIfMissing=true)
├── Jackson2JsonCodecConfiguration         @ConditionalOnClass(ObjectMapper)
│   ├── @Conditional(NoJacksonOrJackson2Preferred)          ← ★ 关键
│   └── @Deprecated(since="4.0.0", forRemoval=true)
├── KotlinxSerializationJsonCodecConfiguration  @ConditionalOnClass(Json)
└── DefaultCodecsConfiguration

注意 JacksonJsonCodecConfiguration 用的是 @ConditionalOnBean(JsonMapper.class) 放在 @Bean 方法上,而不是像 servlet 侧那样放在内部类上:

java 复制代码
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(JsonMapper.class)
@ConditionalOnProperty(name = "spring.http.codecs.preferred-json-mapper", havingValue = "jackson",
        matchIfMissing = true)
static class JacksonJsonCodecConfiguration {

    @Bean
    @Order(0)
    @ConditionalOnBean(JsonMapper.class)        // ← 在方法级
    CodecCustomizer jacksonCodecCustomizer(JsonMapper jsonMapper) { ... }
}

可以 :@ConditionalOnBean 在方法级也能生效(检查时该 bean 定义已注册, 因为 CodecsAutoConfiguration 排在 JacksonAutoConfiguration 之后)。 servlet 侧放在类级只是风格不同。

5.4 NoJacksonOrJackson2Preferred:AnyNestedCondition 的 OR 语义

java 复制代码
static class NoJacksonOrJackson2Preferred extends AnyNestedCondition {

    NoJacksonOrJackson2Preferred() {
        super(ConfigurationPhase.PARSE_CONFIGURATION);
    }

    @ConditionalOnMissingClass("tools.jackson.databind.json.JsonMapper")
    static class NoJackson {
    }

    @ConditionalOnProperty(name = "spring.http.codecs.preferred-json-mapper", havingValue = "jackson2")
    static class Jackson2Preferred {
    }
}

AnyNestedCondition = 任一内部类条件成立即匹配 (第 15 章讲过 NoneNestedConditions 是取反, AnyNestedCondition 是或,AllNestedConditions 是与)。

所以 Jackson2 的 codec 配置生效条件是:

ini 复制代码
(classpath 上没有 Jackson 3)  或   (preferred-json-mapper = jackson2)

这个逻辑很关键:

  • 只有 Jackson 2 在 classpath → 第一项成立 → Jackson2 codec 生效 ✅
  • 两个都在,默认(不配 preferred)→ 第一项不成立,第二项不成立 → Jackson2 codec 不生效 ✅
  • 两个都在,显式 preferred-json-mapper=jackson2 → 第二项成立 → 生效 ✅

@ConditionalOnMissingClass("tools.jackson.databind.json.JsonMapper") 用字符串 ------ 因为 CodecsAutoConfiguration 不能编译期引用 Jackson 3 的类 (Jackson 3 是可选依赖)。这又是第 19 章第二十定律的应用。

注意字符串是 "tools.jackson.databind.json.JsonMapper"(带 .json.) , 而不是第 19 章常见 "tools.jackson.databind.JsonMapper"。

⚠️ 未核实 :Jackson 3 的 JsonMapper 全限定名到底是哪个。 从 JsonMapper.builder() 的 import 推断应该是 tools.jackson.databind.json.JsonMapper (Jackson 3 把 json 专用 mapper 放到了 json 子包)。这与 7.1 节的推断一致。

5.5 KotlinxSerializationJsonCodecConfiguration 的 hasAnyJsonSupport

java 复制代码
@Bean
@Order(0)
@ConditionalOnBean(Json.class)
CodecCustomizer kotlinxJsonCodecCustomizer(Json json, ResourceLoader resourceLoader) {
    ClassLoader classLoader = resourceLoader.getClassLoader();
    boolean hasAnyJsonSupport = ClassUtils.isPresent("tools.jackson.databind.json.JsonMapper", classLoader)
            || ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader)
            || ClassUtils.isPresent("com.google.gson.Gson", classLoader);

    return (configurer) -> {
        CodecConfigurer.DefaultCodecs defaults = configurer.defaultCodecs();
        defaults.kotlinSerializationJsonEncoder(hasAnyJsonSupport ? new KotlinSerializationJsonEncoder(json)
                : new KotlinSerializationJsonEncoder(json, (type) -> true));
        defaults.kotlinSerializationJsonDecoder(hasAnyJsonSupport ? new KotlinSerializationJsonDecoder(json)
                : new KotlinSerializationJsonDecoder(json, (type) -> true));
    };
}

这段代码的意图 :如果 classpath 上已经有别的 JSON 库 (Jackson 3 / Jackson 2 / Gson), 那么 kotlinx.serialization 的 codec 应该只处理它能处理的类型 (默认行为); 如果没有任何其他 JSON 库 ,那么它要接管所有类型 ((type) -> true)。

KotlinSerializationJsonEncoder(json, (type) -> true) 的第二个参数是一个 **"我能不能处理这个类型"**的谓词------传 (type) -> true 表示"什么都行"。

这是一个典型的"兜底"逻辑:kotlinx 序列化在没有竞争对手时包揽一切, 有竞争对手时退居二线。

注意 ClassUtils.isPresent 用的三个字符串类名------Gson 也是可选依赖, 同样只能用字符串检查。

5.6 DefaultCodecsConfiguration:两个全局设置

java 复制代码
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(HttpCodecsProperties.class)
static class DefaultCodecsConfiguration {

    @Bean
    DefaultCodecCustomizer defaultCodecCustomizer(HttpCodecsProperties httpCodecProperties) {
        return new DefaultCodecCustomizer(httpCodecProperties.isLogRequestDetails(),
                httpCodecProperties.getMaxInMemorySize());
    }

    static final class DefaultCodecCustomizer implements CodecCustomizer, Ordered {

        private final boolean logRequestDetails;
        private final @Nullable DataSize maxInMemorySize;

        @Override
        public void customize(CodecConfigurer configurer) {
            PropertyMapper map = PropertyMapper.get();
            CodecConfigurer.DefaultCodecs defaultCodecs = configurer.defaultCodecs();
            defaultCodecs.enableLoggingRequestDetails(this.logRequestDetails);
            map.from(this.maxInMemorySize).asInt(DataSize::toBytes).to(defaultCodecs::maxInMemorySize);
        }

        @Override
        public int getOrder() {
            return 0;
        }
    }
}

spring.http.codecs.* 只有两个属性:

属性 默认 作用
log-request-details false DEBUG 打印表单数据、TRACE 打印请求头(可能泄露敏感信息)
max-in-memory-size null(各 codec 默认,通常 256K) 聚合输入流时可缓冲的最大字节数

maxInMemorySize 是一个 DoS 防护 ------ 对应 servlet 侧 Jackson 的 StreamReadConstraints(第 19 章 7.4 节)。 两个栈都有"限制单次请求能缓冲多少"的配置,但放在不同前缀下:

  • servlet: spring.jackson.factory.constraints.*(Jackson 层)
  • reactive: spring.http.codecs.max-in-memory-size(codec 层)

map.from(...).asInt(DataSize::toBytes) ------ PropertyMapper 的 asInt 转换 (DataSize.toBytes() 返回 long,需要转 int)。若超过 Integer.MAX_VALUE 会怎样? 未核实 (asInt 大概会抛异常或截断)。

DefaultCodecCustomizer 没有 @Order 注解但有 getOrder() == 0 ------ 实现 Ordered 接口而非用注解。与 JacksonJsonCodecCustomizer 的 @Order(0) 混用。

5.7 为什么 servlet 的配置叫 spring.http.converters,reactive 叫 spring.http.codecs?

因为它们的类型不同 :一个是"转换器"(converter),一个是"编解码器"(codec)。 Boot 选择用属性前缀区分 ,而不是共用一个 spring.http.json.*。

这带来一个实践上的坑 : spring.http.converters.preferred-json-mapper 与 spring.http.codecs.preferred-json-mapper 是两个不同的属性,一个应用里如果同时有 servlet 和 reactive(罕见但可能), 要配两次。


5.8 CodecsAutoConfiguration 同时服务 server 与 client

回头看它的类级条件:

java 复制代码
@AutoConfiguration(afterName = { "...JacksonAutoConfiguration", "...Jackson2AutoConfiguration" })
@ConditionalOnClass({ CodecConfigurer.class, WebClient.class })
public final class CodecsAutoConfiguration {

WebClient.class 出现在 @ConditionalOnClass 里 ------ 这说明 CodecsAutoConfiguration 不只是给 WebFlux 服务器用的, 它还要给 WebClient(HTTP 客户端)用。

CodecCustomizer 的 Javadoc 明确写了这一点:

java 复制代码
/**
 * Callback interface that can be used to customize codecs configuration for an HTTP
 * client and/or server with a {@link CodecConfigurer}.
 */

CodecConfigurer 有两个子类:

子类 用途 由谁创建
ServerCodecConfigurer WebFlux 服务端(读写请求/响应) Framework 的 WebFluxConfigurationSupport
ClientCodecConfigurer WebClient 客户端 WebClient.builder() 内部

Boot 的 CodecCustomizer 接口用的是父类型 CodecConfigurer , 所以同一个定制器既能定制服务端也能定制客户端。

这带来一个实践上非常重要的后果:

yaml 复制代码
spring:
  http:
    codecs:
      max-in-memory-size: 10MB

这一行同时影响:

  1. WebFlux 服务器解码请求体时能缓冲多少
  2. WebClient 接收响应时能缓冲多少

第 2 点是很多人的痛点:WebClient 的默认限制是 256KB, 超过会抛 DataBufferLimitException: Exceeded limit on max bytes to buffer : 262144。 解决办法就是配 spring.http.codecs.max-in-memory-size ------ 哪怕你的应用根本不是 WebFlux 应用 (纯 servlet 应用里用 WebClient 调外部服务,照样生效)。

⚠️ 这一点极易被忽略 :很多人以为要自己 WebClient.builder().codecs(...) 手动配, 其实 Boot 的自动配置已经做了------前提是 CodecsAutoConfiguration 生效, 而它的条件只是"CodecConfigurer 和 WebClient 在 classpath", 与 WebApplicationType 无关 (没有 @ConditionalOnWebApplication 注解)。

也就是说:在 servlet 应用里,CodecsAutoConfiguration 也会生效 , 只是它的 ServerCodecConfigurer 部分没人用(WebFluxAutoConfiguration 不生效), 而 ClientCodecConfigurer 部分被 WebClient 用上了。

为什么 configureHttpMessageCodecs 的参数是 ServerCodecConfigurer(子类型)?

java 复制代码
// WebFluxConfig
@Override
public void configureHttpMessageCodecs(ServerCodecConfigurer configurer) {
    this.codecCustomizers.orderedStream().forEach((customizer) -> customizer.customize(configurer));
}

因为 WebFluxConfigurer 的这个回调只管服务端 。客户端侧的装配点在别处 (WebClientCustomizer 或 WebClient.Builder 的自动配置)。

⚠️ 未核实 :WebClient.Builder 的自动配置在哪里、它是否也应用了 CodecCustomizer 链。 从第 19 章 HttpMessageConvertersAutoConfiguration 同时有 ClientHttpMessageConvertersCustomizer 和 ServerHttpMessageConvertersCustomizer 来看,客户端侧应该也有类似的机制。自测方法 :在 servlet 应用里配 spring.http.codecs.max-in-memory-size=10MB,然后用 WebClient 下载一个 1MB 的响应看是否还报错。

5.9 三个 CodecCustomizer 的 order 全是 0 意味着什么

本章出现了三个(四个,如果算上用户自定义的)CodecCustomizer:

定制器 @Bean order 作用
DefaultCodecCustomizer 无注解,实现 Ordered getOrder() == 0 logRequestDetails + maxInMemorySize
jacksonCodecCustomizer @Order(0) 0 换掉 JSON 的 encoder/decoder
jackson2CodecCustomizer @Order(0) 0 同上(Jackson 2)
kotlinxJsonCodecCustomizer @Order(0) 0 同上(kotlinx)
defaultPartHttpMessageReaderCustomizer @Order(0) 0 multipart reader 的限制

全是 0。 相同 order 时 orderedStream() 按什么排?

按 bean 定义的注册顺序 (AnnotationAwareOrderComparator 是稳定的, OrderComparator 对相等的元素保持原序)。而 bean 定义顺序又取决于:

  1. 自动配置类的排序(AutoConfigurationSorter)
  2. 同一配置类内 @Bean 方法的声明顺序

这里有一个潜在问题 :DefaultCodecCustomizer 设置 maxInMemorySize, 而 jacksonCodecCustomizer 替换 JSON 的 decoder。 如果顺序反了会怎样?

java 复制代码
defaultCodecs.jacksonJsonDecoder(new JacksonJsonDecoder(jsonMapper));   // 替换 decoder
...
defaultCodecs.maxInMemorySize(size);                                    // 设置缓冲上限

maxInMemorySize 是 DefaultCodecs 上的一个"全局"设置 ------ 它会被应用到之后注册的 decoder 上(Framework 的实现细节)。 如果先设 maxInMemorySize 再换 decoder,新 decoder 可能拿不到这个值。

⚠️ 未核实 :CodecConfigurer.DefaultCodecs.maxInMemorySize(int) 的语义 是"记录一个值并在后续 jacksonJsonDecoder(...) 时应用"还是"遍历已有 decoder 设置"。 这两种实现下,顺序的敏感性是相反的:

  • 若是"记录值,后续应用" → 必须先 maxInMemorySize 后 换 decoder
  • 若是"遍历已有 decoder" → 必须先 换 decoder 后 maxInMemorySize

Boot 能正常工作说明实际顺序是对的 。 自测方法 :配置 spring.http.codecs.max-in-memory-size=10MB, 然后 POST 一个 1MB 的 JSON,看是否报 DataBufferLimitException。

这给我们一个方法论提醒 : 当多个定制器作用于同一个有状态对象 时,即使 order 相同也可能有隐含的顺序依赖。 Boot 用「全部 @Order(0)」表达"这些定制器之间没有优先级差异"------ 但"没有优先级"不等于"顺序无关"。

5.10 一个实践配方:给 WebFlux 换掉 JSON 库

把本章与第 19 章的知识组合起来,假设你想让 WebFlux 用 Gson 而不是 Jackson:

yaml 复制代码
spring:
  http:
    codecs:
      preferred-json-mapper: gson      # ← 如果 Boot 支持的话

⚠️ 未核实 :CodecsAutoConfiguration 里没有 Gson 的分支! 我读到的只有 Jackson(3)、Jackson2(2)、kotlinx 三个。 对比第 19 章 servlet 侧的 HttpMessageConvertersAutoConfiguration 有 5 个 @Import(含 GsonHttpMessageConvertersConfiguration)。

所以 reactive 栈的 preferred-json-mapper 可能只支持 jackson / jackson2 / kotlinx (kotlinx 那条还没有 preferred 检查)。 这是一个真实的能力差异 ,值得单独记一笔: servlet 栈支持 5 种 JSON 库,reactive 栈只支持 3 种。

替代方案 :自己注册一个 CodecCustomizer bean:

java 复制代码
@Bean
CodecCustomizer gsonCodecCustomizer(Gson gson) {
    return (configurer) -> {
        configurer.defaultCodecs().jacksonJsonEncoder(...)  // 没有 gson 的 API
    };
}

实际上 ServerCodecConfigurer.DefaultCodecs 只提供了 jacksonJsonEncoder/Decoder、kotlinSerializationJsonEncoder/Decoder 等固定槽位, 没有"任意 codec"的槽位 。要加自定义 codec 得用 customCodecs():

java 复制代码
@Bean
CodecCustomizer myCodecCustomizer() {
    return (configurer) -> configurer.customCodecs().writer(new MyEncoder());
}

⚠️ 未核实 :customCodecs() 注册的 writer 与默认 writer 的优先级关系。 通常是"自定义优先",但未确认。

6. ReactiveMultipartAutoConfiguration:configureDefaultCodec 的类型分派

java 复制代码
@AutoConfiguration
@ConditionalOnClass({ DefaultPartHttpMessageReader.class, WebFluxConfigurer.class })
@ConditionalOnWebApplication(type = Type.REACTIVE)
@EnableConfigurationProperties(ReactiveMultipartProperties.class)
public final class ReactiveMultipartAutoConfiguration {

    @Bean
    @Order(0)
    CodecCustomizer defaultPartHttpMessageReaderCustomizer(ReactiveMultipartProperties multipartProperties) {
        return (configurer) -> configurer.defaultCodecs().configureDefaultCodec((codec) -> {
            if (codec instanceof DefaultPartHttpMessageReader defaultPartHttpMessageReader) {
                PropertyMapper map = PropertyMapper.get();
                map.from(multipartProperties::getMaxInMemorySize)
                    .asInt(DataSize::toBytes)
                    .to(defaultPartHttpMessageReader::setMaxInMemorySize);
                map.from(multipartProperties::getMaxHeadersSize)
                    .asInt(DataSize::toBytes)
                    .to(defaultPartHttpMessageReader::setMaxHeadersSize);
                map.from(multipartProperties::getMaxDiskUsagePerPart)
                    .as(DataSize::toBytes)
                    .to(defaultPartHttpMessageReader::setMaxDiskUsagePerPart);
                map.from(multipartProperties::getMaxParts).to(defaultPartHttpMessageReader::setMaxParts);
                map.from(multipartProperties::getFileStorageDirectory)
                    .as(Paths::get)
                    .to((dir) -> configureFileStorageDirectory(defaultPartHttpMessageReader, dir));
                map.from(multipartProperties::getHeadersCharset).to(defaultPartHttpMessageReader::setHeadersCharset);
            }
            else if (codec instanceof PartEventHttpMessageReader partEventHttpMessageReader) {
                ... // 类似,但没有 fileStorageDirectory,且 maxDiskUsagePerPart → setMaxPartSize
            }
        });
    }

configureDefaultCodec(Consumer<HttpMessageReader<?>>) 是 Framework 提供的 "遍历所有默认 codec,对匹配类型的做处理"的 API。Boot 用它做类型分派 ------ 因为同一个配置(spring.webflux.multipart.*)要应用到两种不同的 reader 实现:

reader 特点 差异
DefaultPartHttpMessageReader 一次性把整个 part 读进来 有 setMaxDiskUsagePerPart、支持 fileStorageDirectory
PartEventHttpMessageReader 流式,按 PartEvent 分片 只有 setMaxPartSize、无 fileStorageDirectory

为什么两种都存在? PartEventHttpMessageReader 支持 @RequestPart Flux<PartEvent> 这种流式处理大文件的写法, DefaultPartHttpMessageReader 支持传统的 Mono<FilePart>。 Framework 的 ServerCodecConfigurer 默认两个都注册 ,所以 Boot 要都配置。

PropertyMapper 的三种转换:

java 复制代码
map.from(x).asInt(DataSize::toBytes)     // → int
map.from(x).as(DataSize::toBytes)        // → long
map.from(x).as(Paths::get)               // → Path

asInt 与 as 的区别是目标类型(int vs long)。 as(Paths::get) 把字符串路径转成 Path------这是属性绑定之外的二次转换 , Binder 本身能做 String → Path 吗?大概能(Boot 有 Path 的 converter), 但这里显式写了,可能是为了统一在这里处理异常。

⚠️ 未核实 :configureFileStorageDirectory 的实现(方法体被截断了)。 从名字推断它会创建目录并校验可写性。自测方法:读该方法剩余部分。

spring.webflux.multipart.* 的四个限制(max-in-memory-size / max-headers-size / max-disk-usage-per-part / max-parts)全部是 DoS 防护------ 文件上传是经典的攻击面(zip bomb、大量小 part 耗尽内存)。


7. WebSessionIdResolverAutoConfiguration:Cookie 的 PropertyMapper

java 复制代码
@AutoConfiguration
@ConditionalOnWebApplication(type = Type.REACTIVE)
@ConditionalOnClass({ WebSessionManager.class, Mono.class })
@EnableConfigurationProperties({ WebFluxProperties.class, ServerProperties.class })
public final class WebSessionIdResolverAutoConfiguration {

    private final ServerProperties serverProperties;

    WebSessionIdResolverAutoConfiguration(ServerProperties serverProperties, WebFluxProperties webFluxProperties) {
        this.serverProperties = serverProperties;
    }

    @Bean
    @ConditionalOnMissingBean
    WebSessionIdResolver webSessionIdResolver() {
        CookieWebSessionIdResolver resolver = new CookieWebSessionIdResolver();
        String cookieName = this.serverProperties.getReactive().getSession().getCookie().getName();
        if (StringUtils.hasText(cookieName)) {
            resolver.setCookieName(cookieName);
        }
        resolver.addCookieInitializer(this::initializeCookie);
        return resolver;
    }

    private void initializeCookie(ResponseCookieBuilder builder) {
        Cookie cookie = this.serverProperties.getReactive().getSession().getCookie();
        PropertyMapper map = PropertyMapper.get();
        map.from(cookie::getDomain).to(builder::domain);
        map.from(cookie::getPath).to(builder::path);
        map.from(cookie::getHttpOnly).to(builder::httpOnly);
        map.from(cookie::getSecure).to(builder::secure);
        map.from(cookie::getMaxAge).to(builder::maxAge);
        map.from(cookie::getPartitioned).to(builder::partitioned);
        map.from(cookie::getSameSite).as(SameSite::attributeValue).always().to(builder::sameSite);
    }
}

两个值得注意的点:

  1. 构造函数注入了 webFluxProperties 但没用 (字段都没保存)。 声明它只是为了触发 WebFluxProperties 的 @EnableConfigurationProperties ? 不对------@EnableConfigurationProperties 已经声明了,不需要注入。 这看起来是一个无用参数。

    可能的原因:早期版本用过,后来删了逻辑但没删参数。 未核实。这种"注入但不使用"在 Boot 里不多见,值得记一笔。

  2. map.from(cookie::getSameSite).as(SameSite::attributeValue).always().to(builder::sameSite) ------ 只有这一行有 .always()。

    PropertyMapper 的 always() 表示"即使源值为 null 也调用目标 "。 为什么 SameSite 要 always?因为 SameSite 可能有一个"未设置"的语义 需要显式传给 builder(比如把 SameSite 设为 null 表示"不输出 SameSite 属性")。 而其他属性(domain、path 等)null 时不调用即可(保持 builder 默认)。

    ⚠️ 未核实 :always() 在这里的确切动机。 从 API 语义推断如上,但未读 SameSite 与 ResponseCookieBuilder.sameSite 的实现。

spring.reactive.session.cookie.* 支持 partitioned(CHIPS)与 same-site------ 这些是较新的浏览器安全特性,说明 Boot 4.1 的 cookie 配置跟上了标准演进。


8. 错误处理:ErrorWebFluxAutoConfiguration

8.1 只有两个 bean,但 @Order(-1)

java 复制代码
@AutoConfiguration(before = WebFluxAutoConfiguration.class)
@ConditionalOnWebApplication(type = Type.REACTIVE)
@ConditionalOnClass(WebFluxConfigurer.class)
@EnableConfigurationProperties(WebProperties.class)
public final class ErrorWebFluxAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(search = SearchStrategy.CURRENT)
    @Order(-1)
    ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes, WebProperties webProperties,
            ObjectProvider<ViewResolver> viewResolvers, ServerCodecConfigurer serverCodecConfigurer,
            ApplicationContext applicationContext) {
        DefaultErrorWebExceptionHandler exceptionHandler = new DefaultErrorWebExceptionHandler(errorAttributes,
                webProperties.getResources(), webProperties.getError(), applicationContext);
        exceptionHandler.setViewResolvers(viewResolvers.orderedStream().toList());
        exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters());
        exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders());
        return exceptionHandler;
    }

    @Bean
    @ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
    DefaultErrorAttributes errorAttributes() {
        return new DefaultErrorAttributes();
    }
}

注意 errorWebExceptionHandler 的 @ConditionalOnMissingBean 没有 value ------ 它检查的是方法返回类型 ErrorWebExceptionHandler , 即"容器里没有 ErrorWebExceptionHandler 类型的 bean 时才创建"。

@Order(-1) 的含义 :WebExceptionHandler 是有顺序的链, -1 让它排在几乎最前面 (Framework 的 HttpWebHandlerAdapter 按 order 排序调用)。 这样异常能被它捕获并处理,而不是冒泡到 Netty 变成 500 空响应。

对比 MVC 的 BasicErrorController:它是一个 @Controller , 靠 /error 路径和容器的 error page 机制工作。 两个栈的错误处理机制完全不同:

MVC WebFlux
机制 错误页面(容器转发到 /error) WebExceptionHandler 链
类型 @Controller @Order(-1) 的 bean
注册 需要 ErrorPageRegistrar 把 /error 注册给容器 不需要 (就是个 bean,被 WebHttpHandlerBuilder 收集)
输出 view 或 JSON(靠 produces) 靠 setMessageWriters 注入 codec

setMessageWriters(serverCodecConfigurer.getWriters()) ------ 错误处理器自己也需要序列化 JSON,所以它直接借用 ServerCodecConfigurer 的 writer 列表。 这是"reactive 栈里 codec 是共享资源"的又一体现。

8.2 before = WebFluxAutoConfiguration 与 MVC 一致

java 复制代码
@AutoConfiguration(before = WebFluxAutoConfiguration.class)

MVC 那边也是 @AutoConfiguration(before = WebMvcAutoConfiguration.class), 注释写"so that the error View is available"。 WebFlux 这边没有注释 ------因为 reactive 侧没有 BeanNameViewResolver 的依赖问题 (view resolver 是通过 ObjectProvider<ViewResolver> 注入的,不是条件 bean)。

这是一个"同样的 before,不同的理由" ------ 提示我们:看到两个栈有相同的注解时,先确认理由是否相同。

8.3 SearchStrategy.CURRENT(与 MVC 一致)

两个 bean 都用 search = SearchStrategy.CURRENT, 理由与第 19 章 4.2 节相同:让 Actuator 子上下文有自己的错误处理。


9. Reactor Netty:从 HttpHandler 到端口

9.1 NettyReactiveWebServerAutoConfiguration

java 复制代码
@AutoConfiguration
@ConditionalOnClass({ ReactiveHttpInputMessage.class, HttpServer.class })
@ConditionalOnWebApplication(type = Type.REACTIVE)
@EnableConfigurationProperties(NettyServerProperties.class)
@Import({ ReactiveWebServerConfiguration.class, ReactorResourceFactoryConfiguration.class })
public final class NettyReactiveWebServerAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(ReactiveWebServerFactory.class)
    NettyReactiveWebServerFactory nettyReactiveWebServerFactory(ReactorResourceFactory resourceFactory,
            ObjectProvider<NettyRouteProvider> routes, ObjectProvider<NettyServerCustomizer> serverCustomizers) {
        NettyReactiveWebServerFactory serverFactory = new NettyReactiveWebServerFactory();
        serverFactory.setResourceFactory(resourceFactory);
        routes.orderedStream().forEach(serverFactory::addRouteProviders);
        serverFactory.getServerCustomizers().addAll(serverCustomizers.orderedStream().toList());
        return serverFactory;
    }

    @Bean
    NettyReactiveWebServerFactoryCustomizer nettyWebServerFactoryFactoryCustomizer(Environment environment,
            ServerProperties serverProperties, NettyServerProperties nettyProperties) {
        return new NettyReactiveWebServerFactoryCustomizer(environment, serverProperties, nettyProperties);
    }
}

@Import({ ReactiveWebServerConfiguration.class, ReactorResourceFactoryConfiguration.class }) ------ 与第 18 章 servlet 侧的 ServletWebServerConfiguration 对应。 ReactiveWebServerConfiguration 是 spring-boot-web-server 模块里的类 (第 18 章读过),它注册 ReactiveWebServerFactoryCustomizer 等。

三个"收集用户扩展"的注入:

注入 作用 扩展点类型
ReactorResourceFactory 共享的 Netty 资源(event loop、buffer pool) 可替换
ObjectProvider<NettyRouteProvider> 额外路由(gRPC 等) 4.x 新增
ObjectProvider<NettyServerCustomizer> 直接改 HttpServer 逃生舱

routes.orderedStream().forEach(serverFactory::addRouteProviders) ------ 注意 addRouteProviders(NettyRouteProvider...) 是可变参数, 在循环里一次加一个 (而不是 addRouteProviders(stream.toArray()))。 效果一样,但后者只调一次。这是风格问题,不影响行为。

9.2 ReactorResourceFactory:共享资源

java 复制代码
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ReactorNettyProperties.class)
public static class ReactorResourceFactoryConfiguration {

    @Bean
    @ConditionalOnMissingBean
    ReactorResourceFactory reactorResourceFactory(ReactorNettyProperties configurationProperties) {
        ReactorResourceFactory reactorResourceFactory = new ReactorResourceFactory();
        if (configurationProperties.getShutdownQuietPeriod() != null) {
            reactorResourceFactory.setShutdownQuietPeriod(configurationProperties.getShutdownQuietPeriod());
        }
        return reactorResourceFactory;
    }
}

spring.reactor.netty.shutdown-quiet-period 只有一个属性------ ReactorNettyProperties 是我在本系列里见过的最小的 @ConfigurationProperties 类。

为什么要共享 ReactorResourceFactory? 因为 Netty 的 event loop group 和 buffer pool 是重量级资源 (每个 JVM 一套就够)。 如果主应用和 Actuator 子上下文各建一套,会翻倍占用线程。 共享一个 bean 让两边复用。

这是"子上下文复用主上下文资源 "的一个例子, 与第 19 章 8.1 节 JacksonComponentModule 向上遍历父 BeanFactory 的思路呼应。

ReactorNettyConfigurations 的 Javadoc:

java 复制代码
/**
 * Configurations for Reactor Netty. Those should be {@code @Import} in a regular
 * auto-configuration class.
 */
public final class ReactorNettyConfigurations {
    private ReactorNettyConfigurations() { }
    ...
}

"should be @Import in a regular auto-configuration class" ------ 这是一个给外部模块用的工具类 :如果第三方想写一个基于 Reactor Netty 的服务器模块, 可以 @Import(ReactorResourceFactoryConfiguration.class) 复用这套配置。

这与第 19 章的 JacksonAutoConfiguration 内部类不同------ 那些是 static class(包可见),这个是 public static class。 可见性差异反映了"是否对外提供"。

9.3 createHttpServer() 的七步

java 复制代码
private HttpServer createHttpServer() {
    HttpServer server = HttpServer.create().bindAddress(this::getListenAddress);   // ①
    Ssl ssl = getSsl();
    if (Ssl.isEnabled(ssl)) {
        server = customizeSslConfiguration(server, ssl);                            // ②
    }
    if (getCompression() != null && getCompression().getEnabled()) {
        CompressionCustomizer compressionCustomizer = new CompressionCustomizer(getCompression());
        server = compressionCustomizer.apply(server);                               // ③
    }
    server = server.protocol(listProtocols()).forwarded(this.useForwardHeaders);    // ④
    return applyCustomizers(server);                                                // ⑤
}

① bindAddress(this::getListenAddress) ------ 传的是一个 Supplier<InetSocketAddress>, 不是地址本身。这允许延迟解析(配置可能在之后才就绪)。

java 复制代码
private InetSocketAddress getListenAddress() {
    if (getAddress() != null) {
        return new InetSocketAddress(getAddress().getHostAddress(), getPort());
    }
    return new InetSocketAddress(getPort());
}

注意 getAddress().getHostAddress() ------ getAddress() 返回 InetAddress, 再取它的 getHostAddress() 字符串。为什么不直接 new InetSocketAddress(addr, port)? 因为那样会用 InetAddress 的主机名做解析。用字符串避免了一次反向 DNS 查找。

这个推断的置信度中等。未核实。

② SSL :SslServerCustomizer + addBundleUpdateHandler。

java 复制代码
private void addBundleUpdateHandler(@Nullable String serverName, @Nullable String bundleName,
        SslServerCustomizer customizer) {
    if (StringUtils.hasText(bundleName)) {
        SslBundles sslBundles = getSslBundles();
        Assert.state(sslBundles != null, "'sslBundles' must not be null");
        sslBundles.addBundleUpdateHandler(bundleName,
                (sslBundle) -> customizer.updateSslBundle(serverName, sslBundle));
    }
}

SSL Bundle 热更新 (第 30 章的主题)------证书轮换时不用重启服务器。 customizer.updateSslBundle(serverName, sslBundle) 把新证书推进去。 serverName 对应 SNI:一个服务器可以有多套证书(按域名区分)。

④ protocol(listProtocols()):

java 复制代码
private HttpProtocol[] listProtocols() {
    List<HttpProtocol> protocols = new ArrayList<>();
    protocols.add(HttpProtocol.HTTP11);
    if (getHttp2() != null && getHttp2().isEnabled()) {
        if (getSsl() != null && getSsl().isEnabled()) {
            protocols.add(HttpProtocol.H2);        // HTTP/2 over TLS
        }
        else {
            protocols.add(HttpProtocol.H2C);       // HTTP/2 over cleartext
        }
    }
    return protocols.toArray(new HttpProtocol[0]);
}

H2 vs H2C 的选择完全取决于 SSL 是否启用 ------ 这是 HTTP/2 规范的要求:浏览器只支持 h2(带 TLS), h2c(明文)主要用于内部服务间通信。

HTTP/1.1 总是被加入 (protocols.add(HttpProtocol.HTTP11))------ 因为 Netty 用 ALPN 协商,需要 HTTP/1.1 作为降级选项。

⑤ applyCustomizers:

java 复制代码
private HttpServer applyCustomizers(HttpServer server) {
    for (NettyServerCustomizer customizer : this.serverCustomizers) {
        server = customizer.apply(server);
    }
    return server;
}

serverCustomizers 是 LinkedHashSet------保序 + 去重。 去重意味着同一个 customizer 实例不会被应用两次。

9.4 getWebServer(HttpHandler):与第 18 章的衔接

java 复制代码
@Override
public WebServer getWebServer(HttpHandler httpHandler) {
    HttpServer httpServer = createHttpServer();
    ReactorHttpHandlerAdapter handlerAdapter = new ReactorHttpHandlerAdapter(httpHandler);
    NettyWebServer webServer = createNettyWebServer(httpServer, handlerAdapter, this.lifecycleTimeout,
            getShutdown());
    webServer.setRouteProviders(this.routeProviders);
    return webServer;
}

ReactorHttpHandlerAdapter 是关键的一层: 它把 Framework 的 HttpHandler(Mono<Void> handle(ServerHttpRequest, ServerHttpResponse)) 适配成 Reactor Netty 的 BiFunction<HttpServerRequest, HttpServerResponse, Mono<Void>>。

这就是 servlet 栈里 DispatcherServlet 的位置------ 一个"框架模型 ↔ 服务器模型"的适配器。

webServer.setRouteProviders(this.routeProviders) 在构造后 再设置------ 因为 NettyWebServer 的构造函数参数里没有它(第 18 章读过: new NettyWebServer(httpServer, handlerAdapter, lifecycleTimeout, shutdown, resourceFactory))。

这是一个可以吐槽的设计 :为什么不把 routeProviders 也放进构造参数? 可能是因为 NettyRouteProvider 是 4.0 新增的,加构造参数会破坏兼容性 (NettyWebServer 是 public 类)。推测,未核实。

9.5 NettyRouteProvider:route vs handle

第 18 章读过 NettyWebServer 的:

java 复制代码
DisposableServer startHttpServer() {
    HttpServer server = this.httpServer;
    if (this.routeProviders.isEmpty()) {
        server = server.handle(this.handler);                 // 所有请求走同一个 handler
    }
    else {
        server = server.route(this::applyRouteProviders);     // 允许注册自定义路由
    }
    ...
}

NettyRouteProvider 的定义:

java 复制代码
/**
 * Function that can add new routes to an {@link HttpServerRoutes} instance.
 *
 * @since 4.0.0
 * @see NettyReactiveWebServerFactory
 */
@FunctionalInterface
public interface NettyRouteProvider extends Function<HttpServerRoutes, HttpServerRoutes> {
}

它直接继承了 JDK 的 Function !所以 @FunctionalInterface 其实是冗余的 (Function 本身就是函数式接口)------但写上能让 IDE 更明确地提示。

handle vs route 的区别(Reactor Netty API):

  • handle(BiFunction<request, response, Publisher<Void>>): 所有请求交给同一个函数
  • route(Consumer<HttpServerRoutes>): 可以按路径/方法/头部注册多个路由,不匹配的走默认

为什么默认用 handle? 因为 WebFlux 的 DispatcherHandler 自己做路由 , Netty 层不需要再路由一次(双重路由 = 浪费 )。 只有当用户要注册绕过 WebFlux 的 路由时(比如 gRPC 的 HTTP/2 端点、 Actuator 的特殊路径)才需要 route 模式。

这是第 18 章第十八定律(多实现的差异点 = 底层 API 能力差)的一个补充案例 : Tomcat/Jetty 的 servlet 模型没有 "绕过 servlet 容器直接注册路由"的能力, 所以只有 Netty 有 NettyRouteProvider。

9.6 NettyReactiveWebServerFactoryCustomizer

java 复制代码
@Override
public void customize(NettyReactiveWebServerFactory factory) {
    factory.setUseForwardHeaders(getOrDeduceUseForwardHeaders());
    PropertyMapper map = PropertyMapper.get();
    map.from(this.nettyProperties::getConnectionTimeout)
        .to((connectionTimeout) -> customizeConnectionTimeout(factory, connectionTimeout));
    map.from(this.nettyProperties::getIdleTimeout).to((idleTimeout) -> customizeIdleTimeout(factory, idleTimeout));
    map.from(this.nettyProperties::getMaxKeepAliveRequests)
        .to((maxKeepAliveRequests) -> customizeMaxKeepAliveRequests(factory, maxKeepAliveRequests));
    if (this.serverProperties.getHttp2().isEnabled()) {
        map.from(this.serverProperties.getMaxHttpRequestHeaderSize())
            .to((size) -> customizeHttp2MaxHeaderSize(factory, size.toBytes()));
    }
    customizeRequestDecoder(factory, map);
}

注意它把配置转成 NettyServerCustomizer 加进 factory,而不是直接 setter:

java 复制代码
private void customizeConnectionTimeout(NettyReactiveWebServerFactory factory, Duration connectionTimeout) {
    factory.addServerCustomizers((httpServer) -> httpServer.option(ChannelOption.CONNECT_TIMEOUT_MILLIS,
            (int) connectionTimeout.toMillis()));
}

因为 Netty 的这些选项(ChannelOption.*、httpRequestDecoder 等) 不在 AbstractReactiveWebServerFactory 的抽象属性里 ------ 它们是 Netty 特有的。所以 Boot 用 NettyServerCustomizer 把它们"降级存储", 在 applyCustomizers 阶段才应用。

这是一个通用的模式:抽象层没有的属性 → 用 customizer 承载。

getOrDeduceUseForwardHeaders() 的推断逻辑:

java 复制代码
private boolean getOrDeduceUseForwardHeaders() {
    if (this.serverProperties.getForwardHeadersStrategy() == null) {
        CloudPlatform platform = CloudPlatform.getActive(this.environment);
        return platform != null && platform.isUsingForwardHeaders();
    }
    return this.serverProperties.getForwardHeadersStrategy().equals(ServerProperties.ForwardHeadersStrategy.NATIVE);
}

三态:

  1. server.forward-headers-strategy 未配 → 推断:看当前云平台是否用 forward headers
  2. 配了 NATIVE → true
  3. 配了别的(FRAMEWORK/NONE)→ false

CloudPlatform.getActive(environment) 是第 11 章讲过的云平台检测 (spring.main.cloud-platform 或自动检测 K8s/CF/Heroku)。 isUsingForwardHeaders() 表示"该平台会在请求里注入 X-Forwarded-* 头"。

这是一个很实用的默认行为 :在 K8s/云平台上,应用通常在反向代理后面, X-Forwarded-* 必须被信任,否则生成的 URL(重定向、HATEOAS 链接)会是内网地址。 Boot 自动开启,省了一堆"为什么重定向到了 10.x.x.x"的问题。

9.7 两个前缀的不对称:server.netty vs spring.reactor.netty

java 复制代码
@ConfigurationProperties("server.netty")
public class NettyServerProperties { ... }        // connection-timeout / idle-timeout / max-keep-alive-requests

@ConfigurationProperties("spring.reactor.netty")
public class ReactorNettyProperties { ... }       // shutdown-quiet-period

两个 Netty 相关配置用了不同的前缀 :一个是 server.netty(在 server.* 命名空间下, 与其他服务器配置同源),另一个是 spring.reactor.netty(在 spring.* 下)。

这个不对称大概有这样的历史/语义依据:

  • server.netty.* 是服务器实例相关的(连接超时、空闲超时)
  • spring.reactor.netty.* 是 Reactor Netty 库本身相关的(全局资源的关闭静默期)

但从使用者角度看这很容易记混 。我把它列出来是因为: Boot 的配置前缀不是完全正交的,server.* 与 spring.* 的分界有历史包袱。 第 19 章也见过:spring.web.resources.*(静态资源)与 server.error.*(错误页)分属两边。


9.8 ReactiveWebServerConfiguration:reactive 侧的服务器装配

第 18 章读过 servlet 侧的 ServletWebServerConfiguration,这里是它的 reactive 对照:

java 复制代码
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ServerProperties.class)
@Import(ReactiveWebServerConfiguration.BeanPostProcessorsRegistrar.class)
public class ReactiveWebServerConfiguration {

    @Bean
    ReactiveWebServerFactoryCustomizer reactiveWebServerFactoryCustomizer(ServerProperties serverProperties,
            ObjectProvider<SslBundles> sslBundles) {
        return new ReactiveWebServerFactoryCustomizer(serverProperties, sslBundles.getIfAvailable());
    }

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(name = "server.forward-headers-strategy", havingValue = "framework")
    ForwardedHeaderTransformer forwardedHeaderTransformer() {
        return new ForwardedHeaderTransformer();
    }

    /**
     * Registers a {@link WebServerFactoryCustomizerBeanPostProcessor}. Registered via
     * {@link ImportBeanDefinitionRegistrar} for early registration.
     */
    static class BeanPostProcessorsRegistrar implements ImportBeanDefinitionRegistrar, BeanFactoryAware { ... }
}

BeanPostProcessorsRegistrar 与 servlet 版完全一样 (同样的注释、同样的 registerSyntheticBeanIfMissing)------第 18 章 13.3 节讲过为什么用 ImportBeanDefinitionRegistrar 而不是 @Bean(BPP 必须早于其他 bean 就位)。

ReactiveWebServerFactoryCustomizer 的 7 个映射:

java 复制代码
@Override
public void customize(ConfigurableReactiveWebServerFactory factory) {
    PropertyMapper map = PropertyMapper.get();
    map.from(this.serverProperties::getPort).to(factory::setPort);
    map.from(this.serverProperties::getAddress).to(factory::setAddress);
    map.from(this.serverProperties::getSsl).to(factory::setSsl);
    map.from(this.serverProperties::getCompression).to(factory::setCompression);
    map.from(this.serverProperties::getHttp2).to(factory::setHttp2);
    map.from(this.serverProperties.getShutdown()).to(factory::setShutdown);
    map.from(() -> this.sslBundles).to(factory::setSslBundles);
}

注意最后一行 map.from(() -> this.sslBundles) ------ 用 lambda 而不是方法引用,因为 sslBundles 是字段 而非 getter。 PropertyMapper.from(Supplier<T>) 接受任意 Supplier。

对比 servlet 版的 ServletWebServerFactoryCustomizer (第 18 章读过): 它还有 contextPath、displayName、session.*、tomcat.* 等更多映射, 因为 servlet 容器的可配项多得多。reactive 侧只有 7 项------ 又一次看到"servlet 的复杂度来自容器 API 笨重"。

9.9 forward-headers-strategy 的三种取值:NONE / NATIVE / FRAMEWORK

这是本章最值得单独记住的一个配置项。三种策略分布在两个地方:

取值 生效位置 机制
NATIVE NettyReactiveWebServerFactoryCustomizer.getOrDeduceUseForwardHeaders() 交给服务器 (Netty)处理:server.forwarded(true),由 Netty 改写 request 的 scheme/host/port
FRAMEWORK ReactiveWebServerConfiguration.forwardedHeaderTransformer() 交给 Framework :注册 ForwardedHeaderTransformer bean,在 WebFlux 的 filter 链里改写
NONE 都不注册 不处理 X-Forwarded-*,生成的 URL 用内网地址

为什么要有两种?

  • NATIVE:性能好 (在协议层就改了),但只对 Netty 有效 (Tomcat/Jetty 有没有对应的 native 支持?未核实)
  • FRAMEWORK:通用(不依赖服务器),但多一层处理

而且 NATIVE 是 Netty 的推断默认值 : 9.6 节的 getOrDeduceUseForwardHeaders() 在未显式配置时, 若检测到云平台会用 forward headers,就返回 true------即默认按 NATIVE 处理。

这就形成了一个有趣的状态: 不配 server.forward-headers-strategy 时,Netty 走推断(可能 NATIVE); 显式配 framework 时走 Framework。 两者可以同时生效(Netty 改一遍,Framework 再改一遍)------

⚠️ 未核实 :同时生效会不会导致重复改写(比如 URL 里出现两次协议前缀)。 从 getOrDeduceUseForwardHeaders() 的逻辑看, 显式配了 FRAMEWORK 时它返回 false(因为不等于 NATIVE), 所以不会 同时生效。这个推断置信度较高。

10. 完整时序:从 run() 到第一个响应

把本章与第 17、18 章的内容串起来,一个 WebFlux + Netty 应用的完整启动与首个请求:

scss 复制代码
═════════════ 启动阶段 ═════════════

① SpringApplication.run()
     ② WebApplicationType.deduce() → REACTIVE
        (靠 WebFluxWebApplicationTypeDeducer,核心兜底只给 SERVLET/NONE)   ← 第 17 章
     ③ ApplicationContextFactory 创建
        AnnotationConfigReactiveWebServerApplicationContext                   ← 第 17 章
     ④ refresh() → onRefresh() → createWebServer()

⑤ createWebServer()
     ⑥ 拿 ReactiveWebServerFactory bean
        = NettyReactiveWebServerFactory(由 NettyReactiveWebServerAutoConfiguration 创建)
        (已被 NettyReactiveWebServerFactoryCustomizer 定制过)
     ⑦ 拿 HttpHandler bean
        = ContextPathCompositeHandler 或 WebHttpHandlerBuilder.build() 的结果
        (由 HttpHandlerAutoConfiguration 创建,内部收集了 DispatcherHandler、
          WebExceptionHandler、WebFilter、WebSessionManager...)
     ⑧ factory.getWebServer(httpHandler)
        → createHttpServer()(七步)
        → new ReactorHttpHandlerAdapter(httpHandler)
        → new NettyWebServer(...)
        → webServer.setRouteProviders(...)

═════════════ refresh() 后半程 ═════════════

⑨ WebServerManager 初始化(DelayedInitializationHttpHandler 装真实 handler)
     ⑩ WebServerStartStopLifecycle.start() → webServer.start()
        → startHttpServer() → bindNow()      ← ★ 端口在这里才绑定
        → 打印 "Netty started on port 8080"                                  ← 第 18 章

═════════════ 第一个请求 ═════════════

⑪ Netty event loop 收到字节 → HttpServerRequest
⑫ routeProviders 为空 → server.handle(ReactorHttpHandlerAdapter)
⑬ ReactorHttpHandlerAdapter → HttpHandler.handle(...)
     ⑭ (若有 basePath)ContextPathCompositeHandler 按前缀路由
     ⑮ DispatcherHandler.handle(exchange)
         ⑯ RequestMappingHandlerMapping.getHandler(exchange) → Mono<Object>
         ⑰ HandlerAdapter.handle(...) → Mono<HandlerResult>
         ⑱ HandlerResultHandler → 用 ServerCodecConfigurer 的 writer 编码
             ⑲ JacksonJsonEncoder(包装了 JsonMapper)→ Flux<DataBuffer>
⑳ Netty 写回响应

对比 servlet 栈的关键差异:

环节 servlet reactive
谁驱动请求 servlet 容器(Tomcat 的线程池) Netty event loop
入口组件 DispatcherServlet(容器持有) HttpHandler(一个 bean)
路由 HandlerMapping(同步查找) HandlerMapping(返回 Mono)
编码 HttpMessageConverter.write(..., OutputStream) Encoder → Flux<DataBuffer>
背压 无 Flux 天然支持

DelayedInitializationHttpHandler (第 18 章读过)在这里的作用是: 第 ⑦ 步时 HttpHandler 可能还没完全就绪(bean 还在创建中), 所以先放一个哨兵,到第 ⑨ 步才装真实的。 这解决的是"循环依赖" :WebServerFactory 需要 HttpHandler, 而 HttpHandler 的构建需要整个容器。


11. 观测:WebFluxObservationAutoConfiguration------没有 Filter

java 复制代码
@AutoConfiguration(afterName = {
        "...SimpleMetricsExportAutoConfiguration",
        "...ObservationAutoConfiguration" })
@ConditionalOnClass({ Observation.class, MeterRegistry.class })
@ConditionalOnBean({ ObservationRegistry.class, MeterRegistry.class })
@ConditionalOnWebApplication(type = Type.REACTIVE)
@EnableConfigurationProperties({ MetricsProperties.class, ObservationProperties.class })
public final class WebFluxObservationAutoConfiguration {

    private final ObservationProperties observationProperties;

    @Bean
    @Order(0)
    MaximumAllowableTagsMeterFilter metricsHttpServerUriTagFilter(MetricsProperties metricsProperties) {
        String meterNamePrefix = this.observationProperties.getHttp().getServer().getRequests().getName();
        int maxUriTags = metricsProperties.getWeb().getServer().getMaxUriTags();
        return new MaximumAllowableTagsMeterFilter(meterNamePrefix, "uri", maxUriTags);
    }

    @Bean
    @ConditionalOnMissingBean(ServerRequestObservationConvention.class)
    DefaultServerRequestObservationConvention defaultServerRequestObservationConvention() {
        return new DefaultServerRequestObservationConvention(
                this.observationProperties.getHttp().getServer().getRequests().getName());
    }
}

与 MVC 版的最大区别:没有 FilterRegistrationBean。

第 19 章 MVC 版注册了 ServerHttpObservationFilter(一个 servlet Filter, order HIGHEST_PRECEDENCE + 1)。WebFlux 版只注册了两个 bean : 一个 MeterFilter(限制 tag 基数)和一个 ServerRequestObservationConvention(命名约定)。

为什么不需要 Filter? 因为观测能力已经内建在 WebFlux 里 了------ Framework 的 DispatcherHandler/RouterFunctionMapping 自带 observation 支持, 只要容器里有 ObservationRegistry bean 就会自动启用。

这是一个很好的对照:

MVC WebFlux
观测实现 外挂 Filter (ServerHttpObservationFilter) 内建(Framework 自带)
Boot 提供 filter 注册 + 命名约定 + tag 限制 只有命名约定 + tag 限制

判据 :当 Boot 的自动配置只注册配置对象而不注册功能组件 时, 通常意味着功能已经内建在框架里。这是一个判断"Boot 在这里做了多少事"的快捷方法。

@ConditionalOnClass({ Observation.class, MeterRegistry.class }) + @ConditionalOnBean({...}) ------ 类检查 + bean 检查双重条件。bean 检查是必要的: Micrometer 在 classpath 上不代表用户启用了观测(可能只用了 metrics 没用 observation)。


12. WebFluxProperties 与 WebMvcProperties 的字段对比

从两个 grep 的结果看字段结构:

WebMvcProperties(spring.mvc) WebFluxProperties(spring.webflux)
日期格式 format.* format.* ✅ 相同
问题详情 problemdetails.enabled problemdetails.enabled ✅ 相同
API 版本 apiversion.* apiversion.* ✅ 相同
静态路径 static-path-pattern = /** static-path-pattern = /** ✅ 相同
webjars webjars-path-pattern = /webjars/** webjars-path-pattern = /webjars/** ✅ 相同
servlet 路径 servlet.path、servlet.load-on-startup ❌ 无
异步 async.request-timeout ❌ 无(reactive 不需要)
视图 view.prefix/view.suffix ❌ 无
内容协商 contentnegotiation.* ❌ 无
路径匹配 pathmatch.matching-strategy ❌ 无(reactive 只有 PathPattern)
消息码 messageCodesResolverFormat ❌ 无
DispatcherServlet 开关 dispatch-trace/options-request、publish-request-handled-events、log-request-details ❌ 无
base path ❌ 无(用 server.servlet.context-path) ✅ base-path
HTML 转义 ❌ 无 ✅ default-html-escape

WebMvcProperties 有 39 个 private 字段,WebFluxProperties 明显更少。

差异的根源很清楚:

  • MVC 多的:都是 servlet 特有的(servlet 路径、异步、视图解析、内容协商、路径匹配策略)
  • WebFlux 多的 :base-path(因为没有容器的 context-path 概念)、default-html-escape

"视图"相关配置 MVC 有而 WebFlux 没有 ------ 但 WebFlux 也支持 视图(ViewResolver、ViewResolutionResultHandler)。 WebFlux 没有 view.prefix/suffix 是因为它的默认视图解析器不是 InternalResourceViewResolver(那是 JSP 时代的产物)。


13. 两个栈的选择与共存:实践决策表

13.1 @ConditionalOnWebApplication 的互斥保证了什么

WebMvcAutoConfiguration 是 type = SERVLET,WebFluxAutoConfiguration 是 type = REACTIVE。 两者互斥 ------所以即使 classpath 上同时有 spring-webmvc 和 spring-webflux, 也只会有一套 Web 自动配置生效。

结合第 17 章的结论(MVC Deducer @Order(10) 先于 WebFlux Deducer @Order(20)):

classpath WebApplicationType 生效的自动配置
只有 spring-webmvc SERVLET MVC
只有 spring-webflux REACTIVE WebFlux
两者都有 SERVLET MVC (4.x 靠 @Order 短路)
两者都没有 NONE 都不生效(无 Web 服务器)

"两者都有 → SERVLET" 是最容易踩的一条。 很多人以为"引了 webflux 就是响应式应用"------不对 , 只要 spring-boot-starter-web(或 spring-webmvc)在 classpath 上,就是 servlet 应用。

想强制切换:

yaml 复制代码
spring:
  main:
    web-application-type: reactive

第 17 章讲过:spring.main.web-application-type 能覆盖编程式设置, 因为 bindToSpringApplication 在 createApplicationContext 之前执行。

13.2 五个常见坑

坑 1:引了 spring-boot-starter-webflux,但应用其实是 SERVLET

症状:@EnableWebFlux、RouterFunction、WebFlux 的 WebFilter 全部不生效, 且没有任何报错。

原因:WebFluxAutoConfiguration 的 @ConditionalOnWebApplication(type = REACTIVE) 不满足。 诊断:看 /actuator/conditions 里 WebFluxAutoConfiguration 的 negative matches (第 16 章讲过)。

坑 2:以为 WebClient 需要 WebFlux 应用

不需要 。如 5.8 节所述,CodecsAutoConfiguration 没有 @ConditionalOnWebApplication, 在纯 servlet 应用里也会生效,WebClient 完全可用。 这是 Boot 里"客户端能力独立于服务器类型"的一个体现。

坑 3:spring.http.converters.* 配了但没效果

如果你的应用是 REACTIVE,那么生效的是 spring.http.codecs.*, spring.http.converters.* 完全不起作用 (HttpMessageConvertersAutoConfiguration 用 NotReactiveWebApplicationCondition 把自己排除了,第 19 章 6.2 节)。

反过来也一样:两个前缀容易记混。

坑 4:spring.mvc.* 与 spring.webflux.* 的命名不对称

第 19 章 3.2 节与本章 3.1 节都提到:

  • MVC 用 spring.mvc.hiddenmethod.filter.enabled
  • WebFlux 用 spring.webflux.hiddenmethod.filter.enabled

同样是 problemdetails:

  • spring.mvc.problemdetails.enabled
  • spring.webflux.problemdetails.enabled

前缀一个是 spring.mvc,一个是 spring.webflux ------ 如果你从 MVC 迁移到 WebFlux,所有 spring.mvc.* 都要改成 spring.webflux.* (而且不是一一对应,见第 12 节的字段对比表)。

坑 5:@ConditionalOnMissingBean(WebFluxConfigurationSupport) 被 @EnableWebFlux 触发

与 MVC 的 @EnableWebMvc 一样,用户写了 @EnableWebFlux 会让 整个 WebFluxAutoConfiguration 失效(包括静态资源、欢迎页、编解码器定制)。 这是"要么全接管、要么全交给 Boot"的二元选择。

13.3 决策建议

不是本章源码事实,而是基于本章内容的工程判断:

场景 建议
传统 CRUD + 阻塞 JDBC MVC (虚拟线程 + spring.threads.virtual.enabled=true)
高并发 + 少量线程(R2DBC、Mongo reactive) WebFlux
需要 SSE / WebSocket 长连接 两者皆可 ,WebFlux 的 Flux 返回更自然
只调用外部 HTTP(用 WebClient) MVC 即可(WebClient 不需要 WebFlux 应用)
已有大量阻塞库(JPA、Redis 同步客户端) MVC(WebFlux 里调阻塞库会毁掉吞吐)

最容易被忽略的一条 :WebFlux 的收益来自"全链路非阻塞 "。 只要有一处阻塞(JPA、RestTemplate、Thread.sleep), 整个 event loop 会被卡住,性能比 MVC 还差 。 所以 3.5 节的 configureBlockingExecution 才要配一个线程池来兜底。

12.5 模块文件分布:51 个文件都干了什么

spring-boot-webflux 主源码(不含测试)按包统计:

包 文件数 内容
webflux/(顶层) 2 WebFluxWebApplicationTypeDeducer + package-info
webflux/autoconfigure/ 14 5 个普通自动配置 + 属性类 + 注册接口 + 定制器接口
webflux/autoconfigure/error/ 4 ErrorWebFluxAutoConfiguration + 3 个异常处理器
webflux/autoconfigure/actuate/** 5 健康检查扩展、HTTP exchanges、映射端点
webflux/error/ 4 ErrorAttributes / DefaultErrorAttributes / ErrorWebExceptionHandler
webflux/filter/ 3 OrderedWebFilter / OrderedHiddenHttpMethodFilter
webflux/actuate/** 8 端点 handler mapping、HTTP exchanges 记录器

几个观察:

  1. error 出现了两次 :webflux.error(4 个)与 webflux.autoconfigure.error(4 个)。 前者是公共 API (ErrorAttributes 接口等),后者是自动配置 。 这与第 03 章讲的 4.x 模块化改造一致:API 与自动配置分离到不同包。
  2. filter 包只有 3 个文件------对比 servlet 栈的 filter 机制(由容器管理), reactive 的 filter 就是普通 bean,Boot 不需要提供多少基础设施。
  3. actuate 相关占了 13 个文件(约 25%)------ Actuator 在 WebFlux 模块里的分量很重,第 25 章会展开。

OrderedWebFilter (webflux/filter/):

java 复制代码
public class OrderedWebFilter implements WebFilter, Ordered { ... }

它是 HiddenHttpMethodFilter 的排序包装(3.1 节用到 OrderedHiddenHttpMethodFilter)。 为什么需要它? 因为 HiddenHttpMethodFilter 本身不实现 Ordered , 而 WebHttpHandlerBuilder 收集 filter 时需要按 order 排序。 Boot 包一层加上 order------这是"给第三方类补接口"的标准手法。

14. 事实清单

本章从源码直接确认的事实:

  1. spring-boot-webflux 有 9 条 .imports 条目(5 个普通自动配置 + 4 个 Actuator 相关), 比 webmvc 多,且没有 AutoConfigureWebServer.imports 测试通道。
  2. WebFluxAutoConfiguration 与 WebMvcAutoConfiguration 的类级注解几乎逐条对称 : 同样的 afterName = ValidationAutoConfiguration、同样的 HIGHEST_PRECEDENCE + 10、 同样的 @ImportRuntimeHints(WebResourcesRuntimeHints.class)(同一个类)。
  3. @ConditionalOnWebApplication 的 SERVLET vs REACTIVE 是两者唯一的本质差异; @ConditionalOnClass 分别是 3 个类 vs 1 个 类(WebFluxConfigurer)。
  4. configureApiVersioning 的实现在两边逐行相同,只有 properties 来源不同。
  5. configureBlockingExecution(WebFlux)比 configureAsyncSupport(MVC) 多一个 Threading.VIRTUAL.isActive(environment) 条件 ------ 只在虚拟线程开启时才配 BlockingExecutionConfigurer。
  6. WebFluxAutoConfiguration 的 logger 用 WebFluxConfig.class, MVC 的用 WebMvcConfigurer.class(MVC 那边看起来是 copy-paste 瑕疵)。
  7. WelcomePageConfiguration.welcomePageRouterFunctionMapping 是一个可能返回 null 的 @Bean ------reactive 侧选择"不注册"而不是注册一个空 mapping;MVC 侧则总是注册。 RouterFunctionMapping.setOrder(1)(MVC 的 WelcomePageHandlerMapping 是 2)。
  8. WebFluxConfig 比 MVC Adapter 多注入 Environment (只为 Threading.VIRTUAL)。
  9. addResourceHandlers 两边都检查 hasMappingForPattern, 但 MVC 抽了私有方法、WebFlux 写了两个独立 if; WebFlux 支持多个 定制器(orderedStream()),MVC 只取一个 (getIfAvailable())。
  10. configureResourceCaching(WebFlux)在数据层 做 cachePeriod → cacheControl.maxAge 回退, MVC 则是先 setCachePeriod 再 setCacheControl(后者覆盖)。
  11. WebFluxRegistrations 比 WebMvcRegistrations 少 getExceptionHandlerExceptionResolver ------因为 reactive 栈没有 ExceptionHandlerExceptionResolver(servlet 专用类型)。
  12. EnableWebFluxConfiguration 有 webSessionManager()(MVC 无), 因为 servlet 的 session 由容器管理、reactive 的必须自己管。
  13. webSessionManager 与 localeContextResolver 都用 @ConditionalOnMissingBean(name = WebHttpHandlerBuilder.Xxx_BEAN_NAME)------ 又一次"按名字协作"。
  14. HttpHandlerAutoConfiguration 只有一个 @Bean 方法, 类级 @ConditionalOnMissingBean(HttpHandler.class) 是 WebFlux 的最大粒度开关。
  15. WebHttpHandlerBuilder.applicationContext(applicationContext) 是 从容器收集 WebExceptionHandler/WebFilter/WebSessionManager 的机制------ 这解释了 13 里为什么要按名字。
  16. spring.webflux.base-path 通过 ContextPathCompositeHandler 实现 (servlet 侧对应 server.servlet.context-path)。
  17. CodecsAutoConfiguration 的 CodecCustomizer 是 @FunctionalInterface, Boot 自己用 lambda 实现;servlet 侧的 customizer 因为要同时实现 Client/Server 两个接口,只能用具名类。
  18. NoJacksonOrJackson2Preferred extends AnyNestedCondition------ Jackson2 codec 在"(无 Jackson 3)或(preferred=jackson2)"时生效。
  19. @ConditionalOnMissingClass("tools.jackson.databind.json.JsonMapper") 用字符串 (Codecs 模块不编译期依赖 Jackson)。注意全限定名带 .json. 子包。
  20. kotlinx 序列化的 codec 在无任何 JSON 库 时用 (type) -> true 谓词接管所有类型。
  21. spring.http.codecs.* 只有两个属性:log-request-details(默认 false) 与 max-in-memory-size(默认 null,各 codec 默认 256K)。
  22. servlet 的 spring.http.converters.preferred-json-mapper 与 reactive 的 spring.http.codecs.preferred-json-mapper 是两个不同属性。
  23. ReactiveMultipartAutoConfiguration 用 configureDefaultCodec 做类型分派, 同时配置 DefaultPartHttpMessageReader 与 PartEventHttpMessageReader; 后者没有 fileStorageDirectory 且用 setMaxPartSize(不是 setMaxDiskUsagePerPart)。
  24. WebSessionIdResolverAutoConfiguration 构造函数注入了 webFluxProperties 但没有使用(未保存到字段)------疑似遗留参数。
  25. initializeCookie 里 7 个 PropertyMapper 映射中, 只有 sameSite 用了 .always()。
  26. ErrorWebFluxAutoConfiguration 的 errorWebExceptionHandler 是 @Order(-1) 的 WebExceptionHandler bean(MVC 用的是 @Controller + 容器 error page)。
  27. 它直接 setMessageWriters(serverCodecConfigurer.getWriters())------ 借用 codec 的 writer,体现"reactive 栈 codec 是共享资源"。
  28. NettyReactiveWebServerAutoConfiguration 收集三类扩展: ReactorResourceFactory、NettyRouteProvider、NettyServerCustomizer。
  29. ReactorNettyConfigurations.ReactorResourceFactoryConfiguration 是 public static (Javadoc 明说 "should be @Import in a regular auto-configuration class"), 与第 19 章那些包可见的内部类不同------可见性反映了是否对外提供。
  30. spring.reactor.netty.* 只有 shutdown-quiet-period 一个属性 (本系列见过的最小 @ConfigurationProperties 类)。
  31. createHttpServer() 的协议选择:HTTP/1.1 总是加入 , HTTP/2 按 SSL 启用与否选 H2(TLS)或 H2C(明文)。
  32. getListenAddress() 用 getAddress().getHostAddress()(字符串 ) 而不是直接传 InetAddress。
  33. serverCustomizers 是 LinkedHashSet------保序 + 去重。
  34. NettyRouteProvider extends Function<HttpServerRoutes, HttpServerRoutes>------ 直接继承 JDK 的 Function ,上面的 @FunctionalInterface 是冗余的。
  35. 默认用 server.handle(...)(单一 handler), 只有注册了 NettyRouteProvider 才切到 server.route(...)。
  36. NettyReactiveWebServerFactoryCustomizer 把 Netty 特有选项 转成 NettyServerCustomizer 加进 factory(因为抽象层没有这些属性)。
  37. getOrDeduceUseForwardHeaders() 三态:未配时看 CloudPlatform.getActive(environment).isUsingForwardHeaders()。
  38. Netty 相关有两个前缀 :server.netty.*(服务器实例)与 spring.reactor.netty.*(库本身)。
  39. WebFluxObservationAutoConfiguration 不注册 Filter ------ 观测能力内建在 WebFlux 里,Boot 只提供命名约定与 MaximumAllowableTagsMeterFilter。
  40. WebMvcProperties 有 39 个 private 字段,WebFluxProperties 明显更少; 多出来的是 servlet 特有项,WebFlux 独有 base-path 与 default-html-escape。

15. 本章未逐行核实 / 待你实测的项

# 待核实项 验证方法
1 @Bean 方法返回 null 的确切行为 读 Framework 的 ConfigurationClassBeanDefinitionReader;写一个返回 null 的 @Bean 测试
2 WebFluxProperties.cleanBasePath() 的实现与调用点 读该文件第 74 行附近
3 MaxIdleTimeInMemoryWebSessionStore 是否必需 读 InMemoryWebSessionStore 是否有 setMaxIdleTime(Duration)
4 configureFileStorageDirectory 的实现 读 ReactiveMultipartAutoConfiguration 剩余部分
5 WebSessionIdResolverAutoConfiguration 的无用构造参数 git blame 看是否曾经用过
6 always() 在 sameSite 上的动机 读 SameSite 与 ResponseCookieBuilder.sameSite
7 Jackson 3 的 JsonMapper 全限定名(tools.jackson.databind.json.JsonMapper) import 语句已间接证实,但值得确认
8 PropertyMapper.asInt 溢出 Integer.MAX_VALUE 时的行为 配一个 4GB 的 max-in-memory-size 试
9 getListenAddress() 用 getHostAddress() 的理由 可能是避免反向 DNS;未确认
10 NettyWebServer 为何不把 routeProviders 放进构造参数 git log 看是否是 4.0 新增时为了兼容
11 Threading.VIRTUAL.isActive 对应的属性名与默认值 读 Framework 的 Threading 枚举
12 spring.webflux.multipart.* 的默认值 读 ReactiveMultipartProperties
13 NettyServerProperties 的完整属性列表 读该文件
14 ServerCodecConfigurer 的 writer/reader 列表如何组装 Framework 源码,第 20 章范围外
15 ContextPathCompositeHandler 的行为(多路径映射?) Framework 源码
16 @AutoConfigureWebTestClient 的注册点(对比 MVC 的 AutoConfigureWebServer) 全仓 grep
17 第 19 章遗留的 ServerBuilder.withJsonConverter 语义 仍未解决
18 spring.threads.virtual.enabled 与 BlockingExecutionConfigurer 的完整交互 第 30 章

16. 方法论:本章沉淀的三条定律

15.1 第二十二定律:平行实现的不对称处 = 历史包袱或底层约束

Boot 对两个栈的自动配置高度对称;凡是出现不对称的地方, 要么是底层 API 约束(必有理由),要么是历史遗留(可能无理由)。区分二者的方法是: 如果去掉不对称会导致功能缺失或类型不匹配 → 底层约束;否则 → 历史遗留。

本章的不对称清单:

不对称 判定 理由
createExceptionHandlerExceptionResolver 只有 MVC 有 底层约束 reactive 没有 HandlerExceptionResolver 类型
webSessionManager 只有 WebFlux 有 底层约束 servlet 的 session 由容器管
NettyRouteProvider 只有 Netty 有 底层约束 servlet 容器没有"绕过 servlet 注册路由"的能力
CodecCustomizer 用 lambda,ConvertersCustomizer 用具名类 底层约束(接口设计) 后者要同时实现两个接口
属性前缀 spring.mvc vs spring.webflux 历史遗留 WebFlux 出现得晚
MVC 抽私有方法、WebFlux 写两遍 历史遗留 后者更好但前者没跟改
MVC 的 logger 用 WebMvcConfigurer.class 历史遗留(bug) WebFlux 用对了
server.netty vs spring.reactor.netty 半历史半语义 一个管实例、一个管库

判据的价值 :读源码时遇到"这里为什么和那边不一样", 先按这个二分法分类------能省下大量"是不是我理解错了"的自我怀疑, 也能快速定位哪些是真 bug(可以去提 issue)。

15.2 第二十三定律:@Bean 返回 null 是"条件注册"的隐藏形式

当"注册一个无效的空对象"有副作用时,Boot 会选择让 @Bean 方法返回 null 来"不注册"; 当空对象无害时,就照常注册。判断依据是"这个对象是否参与匹配/路由"。

本章的证据对:

做法 原因
WelcomePageHandlerMapping(MVC) 总是注册 (内部 if 决定是否 setRootHandler) HandlerMapping 不匹配就返回 null,无害
RouterFunctionMapping(WebFlux) 返回 null 不注册 空 router function 可能干扰路由匹配

第 19 章还有一个佐证:beanNameViewResolver 用 @ConditionalOnBean(View.class) 控制注册 (也是一种条件注册),因为无 View bean 时它是纯粹的浪费。

三种"条件注册"的手段(Boot 都会用):

  1. @ConditionalOnXxx(首选,但对"值依赖"无能为力)
  2. @Bean 方法返回 null(运行时才知道)
  3. 注册一个空对象(最省事,但要求空对象无害)

15.3 第二十四定律:Boot 只注册"配置对象"= 功能内建在框架里

看到某个自动配置只注册配置类/约定类而没有任何功能组件(Filter、Controller、Handler)时, 说明该能力已经内建在 Framework 里,Boot 只负责"按约定配一个默认值"。

本章的证据:WebFluxObservationAutoConfiguration 只注册 DefaultServerRequestObservationConvention + MaximumAllowableTagsMeterFilter, 而 MVC 版还注册了 ServerHttpObservationFilter------ 因为 MVC 的观测需要外挂 Filter,WebFlux 的观测是内建的。

这条定律的用法 :评估"Boot 在这里到底做了多少事"。 如果看到一堆 @ConditionalOnMissingBean + 默认值对象, 那 Boot 的贡献就是"命名约定 + 默认值",真正的功能要去 Framework 里找。

反过来也成立:如果一个能力在 Framework 里没有、Boot 却提供了 , 那一定会有一个功能组件 的 @Bean(Filter、Controller、Listener...)。


17. 下一章预告

第 21 章进入 gRPC 新纪元 :spring-boot-grpc-server / spring-boot-grpc-client。 这是 Boot 4 全新加入的一等公民能力(3.x 完全没有),核心问题包括:

  • gRPC 服务如何与 WebApplicationType 共存(gRPC 是 HTTP/2,但不是 servlet 也不是 WebFlux?)
  • spring-boot-grpc-server 的自动配置结构(是否复用了第 18 章的 WebServer 抽象?)
  • GrpcServerFactory / NettyGrpcServerFactory------这是 NettyRouteProvider 的真正用途吗?
  • 客户端侧的 GrpcClientFactory、channel 管理与 spring.grpc.client.*
  • spring-boot-grpc-test 模块

第 18 章遗留的"NettyRouteProvider 的具体使用场景"与 第 20 章 9.5 节的"绕过 WebFlux 注册路由"猜测,应该会在那里得到答案。

相关推荐
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 21 章 · gRPC 新纪元:`spring-boot-grpc-server`
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 13 章 · Bean 定义加载:`BeanDefinitionLoader`、组件扫描与 Bean 命名
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 18 章 内嵌 Web 服务器:`WebServer` 抽象与 Tomcat/Jetty/Netty
java·后端·开源
泡茶喝茶写代码1 小时前
A股量化策略回测实战:从行情数据到绩效评估(第 1 篇):回测引擎从零搭建
java·python·股票数据api·股票数据api接口·股票api数据接口·股票量化数据api·股票量化数据接口
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 10 章 · Profile 与多文档:`spring.config.activate`
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 06 章 · Environment 构建:PropertySource 次序
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 09 章 · `@ConfigurationProperties` 全生命周期:注册、绑定、校验与 AOT
java·后端·开源
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 百度人脸识别:抽取自动装配组件
java·百度·交友
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 08 章 · Binder 绑定引擎:从字符串到 Java 对象的完整路径
java·后端·开源