Spring Boot · 第 05 章

Web 与配置

第 2 章的 onRefresh 启动了内嵌 Tomcat——这一章接上:请求进来后怎么走,以及配置怎么从 yml 绑到对象。

本章你将建立的 schema

  • DispatcherServlet 的 doDispatch 流程:HandlerMapping → HandlerAdapter → HttpMessageConverter → 响应
  • Environment 跨有序 PropertySource 解析,命令行 > OS 环境变量 > application-{profile}.yml > application.yml 优先级链
  • @ConfigurationProperties 的 relaxed binding 与 @Value 的精确匹配根本差异;OSIV 默认开启掩盖 N+1 的失败模式

5.1DispatcherServlet 请求生命周期

DispatcherServlet 是 Spring MVC 的前控制器——每个请求在这里被分派给正确的 Handler,再经消息转换器序列化为响应体。

为什么需要它

Servlet 规范只规定了 HttpServlet.service() 的入口,没有定义 URL 路由、参数绑定、返回值序列化。DispatcherServlet 把这些横切能力统一到一个前控制器,业务代码只需声明 @RequestMapping,路由与转换由框架统一处理。

请求路径从内嵌 Tomcat 的 Connector 开始。Connector 把原始 TCP 流解析为 HttpServletRequest,交给 DispatcherServlet.doDispatch()。

doDispatch 核心步骤:

  1. getHandler():遍历所有注册的 HandlerMapping(最常用的是 RequestMappingHandlerMapping),返回 HandlerExecutionChain(目标 handler + 拦截器链)。
  2. getHandlerAdapter():根据 handler 类型选择 HandlerAdapter,@RequestMapping 方法对应 RequestMappingHandlerAdapter。
  3. HandlerAdapter.handle():解析方法参数(@RequestBody 走 HttpMessageConverter 反序列化),调用 controller 方法,对返回值序列化(@ResponseBody 同样走 HttpMessageConverter)。
  4. processDispatchResult():若有 ModelAndView 则渲染视图(resolveViewName);若已直接写入响应体则跳过。
Tomcat Connector Dispatcher Servlet doDispatch() getHandler Handler Mapping getAdapter Handler Adapter invoke Controller Message Converter HTTP Response
图 5.1请求数据流:Tomcat Connector 解析原始连接,DispatcherServlet 承接分派,Controller 返回值经 HttpMessageConverter 序列化写入响应。注意:HandlerMapping 只做路由查找,HandlerAdapter 才真正调用方法——两者职责不同,经常被混淆。
想一想

HandlerMapping 与 HandlerAdapter 各自负责什么?能否只保留一个?

展开答案(先停 10 秒)

HandlerMapping 的职责是"找到谁处理这个请求"(URL → handler 对象);HandlerAdapter 的职责是"怎么调用这个 handler"(适配不同类型的 handler:@RequestMapping 方法、HttpRequestHandler、Servlet 等)。两者分离是适配器模式——DispatcherServlet 不必关心 handler 的具体调用方式,只需通过 HandlerAdapter 统一调用。去掉任何一个都会破坏这一可扩展性。

5.2内嵌服务器(Boot 4.0)

Boot 4.0 的内嵌 Servlet 容器只剩 Tomcat(默认)和 Jetty;Undertow 已移除;响应式栈用 Netty。

机制回路指向 第 2 章 §2.3:ServletWebServerApplicationContext 在 onRefresh()(refresh 第 9 阶段)重写父类,调用 createWebServer() 建立 TomcatWebServer;此时 finishBeanFactoryInitialization(第 11 阶段,预实例化业务单例)尚未执行。内嵌服务器的启动早于绝大多数业务 Bean 的实例化。

切换到 Jetty 只需把 spring-boot-starter-web 中的 Tomcat 依赖排除,再引入 spring-boot-starter-jetty——自动配置的 JettyAutoConfiguration 在 WebServerFactoryCustomizer 接口下发现 Jetty 工厂并接管。

Undertow 已移除

Spring Boot 4.0 起彻底删除 Undertow 支持(spring-boot-starter-undertow 不再存在)。从 3.x 升级时,依赖 Undertow 的项目必须迁移到 Tomcat 或 Jetty,否则启动失败。

5.3Environment 与 PropertySource 优先级

Environment 是 profiles + properties 的抽象,内部维护一个有序的 PropertySource 列表——先命中的 PropertySource 赢。

ConfigurableEnvironment 在 run() 的 prepareEnvironment 阶段构建,由 ConfigDataEnvironmentPostProcessor 加载外部化配置文件并按优先级插入到 PropertySource 链。查询属性时,Environment 按顺序遍历链,第一个包含该 key 的 PropertySource 获胜——后续来源不再被查询。

优先级(从高到低):

  1. 命令行参数(--server.port=9090)
  2. SPRING_APPLICATION_JSON(嵌入 JSON 的环境变量或系统属性)
  3. OS 环境变量(如 SERVER_PORT)
  4. application-{profile}.yml(激活的 profile 特定文件)
  5. application.yml(默认配置文件)
  6. @PropertySource 注解导入的文件
  7. SpringApplication 默认属性
PropertySource 优先级(高→低) 优先级 命令行参数 --key=value 最高优先 SPRING_APPLICATION_JSON 嵌入 JSON 的环境变量/系统属性 OS 环境变量 SERVER_PORT · MY_APP_PORT 等 application-{profile}.yml 激活 profile 的特定文件 application.yml 默认配置文件 @PropertySource 文件 · 默认属性 最低优先 先命中即生效
图 5.2PropertySource 优先级栈:Environment 从顶部向下查找,首次命中即返回。注意:profile 特定文件(application-prod.yml)优先于通用 application.yml——这意味着 profile 文件里的值覆盖通用文件,而非追加。

5.4@ConfigurationProperties 绑定与 relaxed binding

ConfigurationPropertiesBindingPostProcessor 用 Binder 把前缀下的所有属性绑到 POJO,支持 relaxed binding;@Value 是逐字段 SpEL 求值,无 relaxed binding。

为什么需要 relaxed binding

环境变量只能用大写字母和下划线(如 MY_APP_MAX_CONNECTIONS),而 Java 属性命名惯例是驼峰或短横线(myApp.maxConnections)。Binder 的 relaxed binding 把这两套命名归一:my-app.max-connections、MY_APP_MAX_CONNECTIONS、myApp.maxConnections 指向同一字段。

Binder 的归一规则:把属性名统一转为 canonical form(小写 + 短横线),再匹配 POJO 字段。MY_APP_PORT → my-app.port,与 @ConfigurationProperties(prefix="my.app") 的 port 字段匹配成功。

@Value("${my.app.port}") 则是精确字符串匹配——它直接查询 Environment,Key 必须完全等于 my.app.port,OS 环境变量 MY_APP_PORT 在 Environment 里以原始大写 key 存储,查询 my.app.port 时找不到,绑定失败(返回默认值或抛出 IllegalArgumentException)。

MyAppProperties.javaJava
@ConfigurationProperties(prefix = "my.app")
@Validated
public class MyAppProperties {

    /** relaxed binding: my.app.port / MY_APP_PORT / myApp.port 都能绑到这里 */
    @Min(1) @Max(65535)
    private int port = 8080;

    @NotBlank
    private String name;

    @DurationUnit(ChronoUnit.SECONDS)
    private Duration timeout = Duration.ofSeconds(30);

    // getters & setters (或用 record — Boot 4.0 支持)
    public int getPort() { return port; }
    public void setPort(int port) { this.port = port; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public Duration getTimeout() { return timeout; }
    public void setTimeout(Duration timeout) { this.timeout = timeout; }
}
AppConfig.javaJava
@SpringBootApplication
@EnableConfigurationProperties(MyAppProperties.class)  // 或在类上直接加 @Component
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

Boot 4.0 中 jakarta.validation.*(如 @NotBlank、@Min)替代旧 javax.validation.*,需引入 spring-boot-starter-validation。校验失败在容器启动时即报 BindValidationException,而非首次注入时才发现。

@Value vs @ConfigurationProperties 对比
维度@Value@ConfigurationProperties
绑定范围 单个属性 同一前缀下所有属性 → POJO
Relaxed binding 无——精确匹配 key 字符串 有——MY_APP_PORT = my-app.port = myApp.port
JSR-380 校验 不支持 支持(加 @Validated)
SpEL 支持(#{bean.method()}) 不支持
类型转换 有限(Spring ConversionService) 丰富(Duration、DataSize、枚举等)
适用场景 偶尔注入一个值、需要 SpEL 多属性结构化绑定、外部配置分组

5.5OSIV 陷阱

Open Session in View 默认把 Hibernate EntityManager session 开到整个 HTTP 请求周期——代价是掩盖 N+1 并在 WARN 日志里静默失败。

OSIV(spring.jpa.open-in-view=true)由 OpenEntityManagerInViewInterceptor(或 OpenEntityManagerInViewFilter)实现:在请求进入时绑定 EntityManager 到当前线程,在响应返回后关闭。由于 session 在 controller 和视图层都处于打开状态,lazy 关联(@OneToMany(fetch=LAZY))被访问时能透明触发额外 SELECT——**不会**抛 LazyInitializationException。

失败模式的隐蔽性在于:当 controller 返回包含懒关联的实体列表,Jackson 序列化过程中逐个访问每条记录的关联集合,每次触发一条 SELECT——100 条记录产生 100 条额外查询。这一行为在 OSIV 开启时完全静默,只有在慢查询日志或 Hibernate statistics 里才能观察到。

OSIV 失败模式

Boot 启动日志会打印 WARN spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning.——大多数工程师忽略该警告。关闭 OSIV(open-in-view=false)后,若 Service 层事务外访问 lazy 关联会立即抛 LazyInitializationException,强制修复数据获取策略。

修复路径:关闭 open-in-view=false,在 Service 层使用 JOIN FETCH JPQL、@EntityGraph、或 DTO 投影显式抓取所需关联,让数据获取意图在代码里可见。

application.ymlYAML
spring:
  jpa:
    open-in-view: false   # 关闭 OSIV,强制显式抓取
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb
OrderRepository.javaJava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // JOIN FETCH 显式抓取 items,避免 N+1
    @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.userId = :userId")
    List<Order> findWithItemsByUserId(@Param("userId") Long userId);

    // 或用 @EntityGraph 声明式指定
    @EntityGraph(attributePaths = {"items", "items.product"})
    List<Order> findByUserId(Long userId);
}

§本章 self-check

先合上教程,把答案写下来再展开对照。

  1. 一个请求从 Tomcat 到 controller 方法执行,经过哪几个核心组件?每个组件的职责一句话。
  2. 命令行参数 --server.port=9090 和 application.yml 里的 server.port: 8080,最终生效哪个?为什么(机制,不是"因为命令行优先")?
  3. 为什么环境变量 MY_APP_PORT 能绑到 @ConfigurationProperties 的 port 字段,却绑不到 @Value("${my.app.port}")?
  4. OSIV 默认开启的代价是什么?关闭后会出现什么问题,如何修复?
答案(先做完再展开)
  1. Tomcat Connector(解析 TCP → HttpServletRequest)→ DispatcherServlet.doDispatch(前控制器,协调后续)→ HandlerMapping(URL 路由,找到 handler)→ HandlerAdapter(调用 handler,含参数绑定与返回值处理)→ HttpMessageConverter(@RequestBody 反序列化 / @ResponseBody 序列化)→ 写入 HttpServletResponse。
  2. 命令行参数对应的 PropertySource 位于 Environment 有序列表的最高位,Environment 按顺序遍历时在命令行 PropertySource 处已找到 server.port,不再继续查询 application.yml 对应的 PropertySource,故 9090 生效。
  3. @ConfigurationProperties 使用 Binder,Binder 将属性 key 统一转为 canonical form(小写 + 短横线)再匹配字段:MY_APP_PORT → my-app.port,与 prefix my.app + 字段 port 拼合后匹配。@Value 是精确 key 查询,在 Environment 里查找字符串 my.app.port,而环境变量以原始大写 key 存储,查不到,故绑定失败。
  4. OSIV 将 Hibernate EntityManager session 开放到整个请求周期,序列化层透明触发 lazy 关联的额外 SELECT,造成 N+1 且完全静默。关闭 OSIV 后,Service 事务提交后再访问 lazy 关联会抛 LazyInitializationException;修复:在 Service 层用 JOIN FETCH 或 @EntityGraph 显式抓取所需关联。
进阶挑战 · 刚好够不着

同一 key 在三处配置——谁赢?

同一个属性 my.app.port 在 application.yml、激活的 application-prod.yml、以及 OS 环境变量 MY_APP_PORT 里都有值。生产环境(prod profile 激活)启动时,最终生效的是哪个?列出原因链,不能只说"因为优先级"。

提示(卡住再展开)

从 Environment 的 PropertySource 链顺序入手:OS 环境变量(位于第 3 层)vs application-prod.yml(第 4 层)vs application.yml(第 5 层)。Environment 从第 1 层开始遍历,先命中者获胜——OS 环境变量排在所有文件来源之上。再思考 relaxed binding:MY_APP_PORT 在 Environment 里以大写 key 存在,@Value("${my.app.port}") 能查到它吗?@ConfigurationProperties 呢?