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 核心步骤:
- getHandler():遍历所有注册的
HandlerMapping(最常用的是RequestMappingHandlerMapping),返回HandlerExecutionChain(目标 handler + 拦截器链)。 - getHandlerAdapter():根据 handler 类型选择
HandlerAdapter,@RequestMapping方法对应RequestMappingHandlerAdapter。 - HandlerAdapter.handle():解析方法参数(
@RequestBody走HttpMessageConverter反序列化),调用 controller 方法,对返回值序列化(@ResponseBody同样走HttpMessageConverter)。 - processDispatchResult():若有
ModelAndView则渲染视图(resolveViewName);若已直接写入响应体则跳过。
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 工厂并接管。
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 获胜——后续来源不再被查询。
优先级(从高到低):
- 命令行参数(
--server.port=9090) SPRING_APPLICATION_JSON(嵌入 JSON 的环境变量或系统属性)- OS 环境变量(如
SERVER_PORT) application-{profile}.yml(激活的 profile 特定文件)application.yml(默认配置文件)@PropertySource注解导入的文件- SpringApplication 默认属性
5.4@ConfigurationProperties 绑定与 relaxed binding
ConfigurationPropertiesBindingPostProcessor 用 Binder 把前缀下的所有属性绑到 POJO,支持 relaxed binding;@Value 是逐字段 SpEL 求值,无 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)。
@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; }
}
@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 | @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 里才能观察到。
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 投影显式抓取所需关联,让数据获取意图在代码里可见。
spring:
jpa:
open-in-view: false # 关闭 OSIV,强制显式抓取
datasource:
url: jdbc:postgresql://localhost:5432/mydb
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
先合上教程,把答案写下来再展开对照。
- 一个请求从 Tomcat 到 controller 方法执行,经过哪几个核心组件?每个组件的职责一句话。
- 命令行参数
--server.port=9090和application.yml里的server.port: 8080,最终生效哪个?为什么(机制,不是"因为命令行优先")? - 为什么环境变量
MY_APP_PORT能绑到@ConfigurationProperties的port字段,却绑不到@Value("${my.app.port}")? - OSIV 默认开启的代价是什么?关闭后会出现什么问题,如何修复?
答案(先做完再展开)
- Tomcat Connector(解析 TCP → HttpServletRequest)→ DispatcherServlet.doDispatch(前控制器,协调后续)→ HandlerMapping(URL 路由,找到 handler)→ HandlerAdapter(调用 handler,含参数绑定与返回值处理)→ HttpMessageConverter(@RequestBody 反序列化 / @ResponseBody 序列化)→ 写入 HttpServletResponse。
- 命令行参数对应的 PropertySource 位于 Environment 有序列表的最高位,Environment 按顺序遍历时在命令行 PropertySource 处已找到
server.port,不再继续查询 application.yml 对应的 PropertySource,故 9090 生效。 @ConfigurationProperties使用 Binder,Binder 将属性 key 统一转为 canonical form(小写 + 短横线)再匹配字段:MY_APP_PORT→my-app.port,与 prefixmy.app+ 字段port拼合后匹配。@Value是精确 key 查询,在 Environment 里查找字符串my.app.port,而环境变量以原始大写 key 存储,查不到,故绑定失败。- 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 呢?