Spring 全栈硬核教程 · 03:Spring Web(MVC / WebFlux / REST / 实时通信 / 客户端)
这一篇回答的核心问题:一个 HTTP 请求从网卡进来,到你的
@RestController方法被调用,再到 JSON 写回浏览器,中间到底经历了什么? 以及那个 2026 年最值得重新思考的问题:虚拟线程之后,WebFlux 还有必要学吗?
1. 先厘清概念:Spring Web ≠ Spring MVC
| 名词 | 是什么 |
|---|---|
spring-web | 共享基础设施:HttpMessageConverter、RestClient、HTTP 抽象。MVC 和 WebFlux 都依赖它 |
spring-webmvc | 基于 Servlet API 的阻塞式框架,核心是 DispatcherServlet |
spring-webflux | 基于 Reactive Streams 的非阻塞式框架,核心是 DispatcherHandler,默认跑在 Netty 上 |
关键认知:MVC 和 WebFlux 是两套并行、互不依赖的实现。同时引入两个 starter 时,Spring Boot 会优先使用 MVC(Servlet 栈),这是个经常让人困惑的默认行为。
2. DispatcherServlet 请求处理全流程
① 请求到达 Tomcat → 交给 DispatcherServlet(唯一入口,前端控制器模式)
↓
② HandlerMapping:根据 URL + HTTP方法 + 请求头(consumes/produces) 找到目标方法
核心实现:RequestMappingHandlerMapping
产出:HandlerExecutionChain(目标方法 + 匹配的拦截器链)
↓
③ 拦截器 preHandle()(按注册顺序正向执行)
↓
④ HandlerAdapter 调用目标方法
├─ 先跑 HandlerMethodArgumentResolver 链解析每个参数(见第3节)
├─ 反射调用 Controller 方法
└─ 再跑 HandlerMethodReturnValueHandler 处理返回值
↓
⑤ 拦截器 postHandle()(按注册顺序反向执行)
↓
⑥ 返回值序列化:HttpMessageConverter(@ResponseBody)或 ViewResolver(视图名)
⭐ ResponseBodyAdvice 在序列化前介入
↓
⑦ 异常发生时:HandlerExceptionResolver 链
核心实现:ExceptionHandlerExceptionResolver(匹配 @ExceptionHandler/@RestControllerAdvice)
↓
⑧ 拦截器 afterCompletion()(无论成功失败都执行)
↓
⑨ 响应写回这张图能帮你排查的真实问题
| 现象 | 卡在哪一步 | 根因 |
|---|---|---|
| 404 但路径明明对 | ② | @ComponentScan 没扫到,或 consumes/produces 不匹配 |
| 406 Not Acceptable | ② | 客户端 Accept 头和方法的 produces 无交集 |
| 415 Unsupported Media Type | ② | 请求 Content-Type 和 consumes 不匹配(最常见:忘了 application/json) |
| 全局异常处理器"不生效" | ⑦ | 异常发生在 Filter 层(在 DispatcherServlet 之外,根本进不到⑦),或发生在异步线程里 |
| 拦截器里改响应没效果 | ⑤ | @ResponseBody 的序列化在⑥,但 postHandle 也在⑥之前……实际问题通常是响应已 commit。正解用 ResponseBodyAdvice |
统一响应包装的正确姿势
@RestControllerAdvice
public class ApiResponseWrapper implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class converterType) {
// 排除已经是 Result 的、以及 Actuator/Swagger 的端点
return !returnType.getParameterType().equals(Result.class);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType type, Class converterType,
ServerHttpRequest req, ServerHttpResponse res) {
// ⚠️ String 类型要特殊处理,否则 StringHttpMessageConverter 会抛类型转换异常
if (body instanceof String s) {
return JsonUtils.toJson(Result.ok(s));
}
return Result.ok(body);
}
}那个 String 的坑:因为
StringHttpMessageConverter的优先级高于 Jackson,返回 String 时走的是它,而你在beforeBodyWrite里返回了一个Result对象,类型对不上直接报错。这是实现统一响应包装时 100% 会遇到的问题。
3. 参数绑定机制:HandlerMethodArgumentResolver
3.1 原理
每种参数注解背后都有一个 Resolver,RequestMappingHandlerAdapter 遍历所有 Resolver,找到第一个 supportsParameter() 返回 true 的来解析。这是策略模式 + 责任链,而且完全可扩展。
| 注解 | Resolver | 取值来源 |
|---|---|---|
@RequestParam | RequestParamMethodArgumentResolver | query / form |
@PathVariable | PathVariableMethodArgumentResolver | URL 路径模板 |
@RequestBody | RequestResponseBodyMethodProcessor | 请求体(走 MessageConverter) |
@RequestHeader | RequestHeaderMethodArgumentResolver | 请求头 |
| 无注解 POJO | ServletModelAttributeMethodProcessor | 按 @ModelAttribute 逐字段绑定 |
3.2 实战:写一个 @CurrentUser
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {}
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter p) {
return p.hasParameterAnnotation(CurrentUser.class)
&& LoginUser.class.isAssignableFrom(p.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter p, ModelAndViewContainer mav,
NativeWebRequest req, WebDataBinderFactory f) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof LoginUser user)) {
throw new UnauthorizedException("未登录");
}
return user;
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new CurrentUserArgumentResolver());
}
}用起来:
@GetMapping("/profile")
public UserProfile getProfile(@CurrentUser LoginUser user) {
return userService.getProfile(user.getId()); // 再也不用写 SecurityContextHolder 了
}给老司机的提示:下次你发现"每个 Controller 方法都要重复写同一段取值逻辑"时,先想想能不能用自定义
ArgumentResolver解决。这个扩展点的使用率远低于它的价值。
4. RESTful API 设计:不只是"用对 HTTP 方法"
4.1 幂等性设计(从定义到落地)
| 方法 | 应幂等 | 落地要点 |
|---|---|---|
| GET / HEAD | 是 | 天然幂等 |
| PUT | 是 | 必须是整体覆盖式更新,price = price + 1 这种写法就破坏了幂等性 |
| DELETE | 是 | 删除不存在的资源返回 204/404,不要报 500,否则客户端重试会出问题 |
| POST | 否 | 需要幂等时,靠 Idempotency-Key 实现 |
@PostMapping("/orders")
public ResponseEntity<OrderDTO> create(
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody CreateOrderRequest req) {
String cacheKey = "idem:order:" + key;
// setIfAbsent 保证并发下只有一个请求能进入创建逻辑
Boolean acquired = redis.opsForValue().setIfAbsent(cacheKey, "PROCESSING", Duration.ofMinutes(5));
if (Boolean.FALSE.equals(acquired)) {
Object cached = redis.opsForValue().get(cacheKey);
if ("PROCESSING".equals(cached)) {
return ResponseEntity.status(HttpStatus.CONFLICT).build(); // 正在处理,让客户端稍后重试
}
return ResponseEntity.status(HttpStatus.CREATED).body((OrderDTO) cached); // 返回上次结果
}
OrderDTO created = orderService.create(req);
redis.opsForValue().set(cacheKey, created, Duration.ofHours(24));
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}这是支付/下单接口的标配设计,也是"如何设计防重复提交接口"这道面试题的完整答案。
4.2 错误响应标准化:RFC 7807 ProblemDetail
Spring Framework 6+ 原生支持 RFC 7807,别再自造错误格式了:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ProblemDetail handleBusiness(BusinessException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(ex.getStatus(), ex.getMessage());
pd.setType(URI.create("https://api.example.com/errors/" + ex.getCode()));
pd.setTitle(ex.getCode());
pd.setProperty("traceId", MDC.get("traceId")); // ⭐ 把链路 ID 带给前端,报障时能秒定位
pd.setProperty("timestamp", Instant.now());
return pd;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
pd.setTitle("VALIDATION_FAILED");
pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage, (a, b) -> a)));
return pd;
}
}
traceId那一行是本文最实用的一行代码。把链路追踪 ID 放进错误响应,用户报障时截图一发,你直接拿 traceId 去日志系统全链路检索,排查时间从半小时降到一分钟。
4.3 API 版本化:Spring Framework 7 的原生支持
这是 Boot 4 时代最值得升级的新特性之一。过去做 API 版本管理只能靠土办法:URL 里塞 /v1/、自定义 Header 判断、或者自己写 @ApiVersion 注解 + 自定义 RequestCondition。Spring Framework 7 把它做成了一等公民——在 @RequestMapping 及其变体上新增了 version 属性:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping(path = "/{id}", version = "1")
public OrderV1 getV1(@PathVariable String id) { ... }
@GetMapping(path = "/{id}", version = "1.1+") // ⭐ 基线版本:1.1 及以上都走这个
public OrderV2 getV2(@PathVariable String id) { ... }
}配套机制:
ApiVersionResolver决定从哪里取版本——支持请求头、查询参数、路径段、媒体类型参数四种策略,开箱即用ApiVersionParser解析版本值,内置SemanticApiVersionParser支持major.minor.patch语义化版本- 客户端也支持:
RestClient/WebClient可以配置ApiVersionInserter,然后.apiVersion(1.1)一行搞定 - HTTP Interface 客户端通过
@HttpExchange的version属性支持 - 测试工具打通:
MockMvc、WebTestClient都能指定版本 - 弃用提示:可以配置在响应里自动带上版本弃用通知
spring:
mvc:
apiversion:
use:
header: API-Version # 从这个请求头取版本选型建议:REST 之父 Roy Fielding 其实反对 API 版本化(他认为应该靠超媒体演进),但现实世界里版本化是刚需。Spring 团队的态度很务实——不评判该不该做,而是把四种常见做法都支持好,让你按业务需要选。从实用角度:对内服务用请求头版本化(URL 保持干净),对外开放 API 用路径版本化(最直观,第三方开发者最容易理解)。
4.4 参数校验进阶
// 分组校验
public interface OnCreate {}
public interface OnUpdate {}
public record BookRequest(
@Null(groups = OnCreate.class) @NotNull(groups = OnUpdate.class) Long id,
@NotBlank(groups = {OnCreate.class, OnUpdate.class}) String title,
@NotEmpty List<@Valid AuthorRequest> authors // ⭐ 集合元素要在泛型上标 @Valid
) {}
@PostMapping
public BookDTO create(@Validated(OnCreate.class) @RequestBody BookRequest req) { ... }嵌套校验的坑:
List<AuthorRequest>里每个元素的校验注解默认不生效,必须写成List<@Valid AuthorRequest>。这是极高频的踩坑点,很多人排查半天以为是 Bug。
5. MVC vs WebFlux:2026 年的重新审视
5.1 完整对比
| 维度 | Spring MVC | Spring WebFlux |
|---|---|---|
| 模型 | Servlet,一请求一线程 | Reactive Streams,Mono/Flux |
| 容器 | Tomcat / Jetty | Netty(默认) |
| 背压支持 | 无 | ✅ 原生支持 Backpressure |
| 调试体验 | 堆栈清晰,断点好使 | 异步堆栈难读,断点经常断不到想要的地方 |
| 生态兼容 | 全部第三方库原生支持 | JDBC 不可用(需 R2DBC),部分库需适配 |
| 学习成本 | 低 | 高(操作符、背压、错误传播是全新范式) |
| 团队成本 | 新人一周上手 | 新人一个月还在踩坑 |
5.2 ⭐ 虚拟线程改变了什么
spring:
threads:
virtual:
enabled: true # 一行配置,Tomcat 用虚拟线程处理请求这一行配置的意义:Spring MVC 的传统阻塞代码,现在也能撑住大量并发连接了。过去"高并发必须上 WebFlux"的论断,前提是"阻塞会占死操作系统线程"——虚拟线程把这个前提拆了。
新的选型逻辑:
你的场景是什么?
├─ 常规业务系统(CRUD 为主,数据库交互密集)
│ → MVC + 虚拟线程。心智负担最低,吞吐足够
│
├─ 网关 / BFF 聚合层(大量下游 HTTP 调用,CPU 计算少)
│ → WebFlux。这是它真正的甜蜜点,背压和流式组合能力无可替代
│
├─ 需要流式/推送(SSE、大模型逐字输出、实时数据流)
│ → WebFlux 更自然,但 MVC 也能用 SseEmitter / StreamingResponseBody 做到
│
└─ 已有大量阻塞式存量代码,想提升吞吐
→ MVC + 虚拟线程。改造成本几乎为零,别去重写成 WebFlux虚拟线程的已知限制(别当银弹):
synchronized块内做阻塞操作可能触发 Pinning(虚拟线程被"钉"在载体线程上无法卸载),要改用ReentrantLock。Java 21 之后的版本在持续改善,但存量第三方库里的synchronized你改不动- ThreadLocal 滥用会放大内存占用——虚拟线程数量级远超平台线程,每个都挂一份 ThreadLocal 数据会吃掉大量堆内存
- 连接池仍是瓶颈:虚拟线程不占系统线程了,但数据库连接池只有 20 个连接,照样卡在那里
一句话结论:虚拟线程把 WebFlux 的适用范围从"追求高并发的所有场景"压缩到了"真正需要流式处理和背压的场景"。对绝大多数团队,MVC + 虚拟线程是 2026 年的默认答案。
6. 实时通信:WebSocket / SSE
6.1 三种方案对比
| 方案 | 方向 | 协议 | 适用场景 |
|---|---|---|---|
| 轮询 | 客户端拉 | HTTP | 兼容性最好,浪费资源,尽量别用 |
| SSE | 服务端→客户端单向 | HTTP(长连接) | AI 流式输出、通知推送、进度条、实时日志 |
| WebSocket | 全双工 | ws/wss | 聊天室、协同编辑、实时对战游戏 |
选型核心:只要不需要客户端频繁推消息给服务端,优先 SSE。它就是普通 HTTP,天然穿透代理和防火墙、自动重连、实现简单;WebSocket 要处理心跳、重连、代理兼容,复杂度高一个量级。
6.2 SSE 实战:大模型流式输出
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@RequestParam String prompt) {
SseEmitter emitter = new SseEmitter(180_000L); // 3 分钟超时
// 用虚拟线程执行,不占宝贵的平台线程
Thread.ofVirtual().start(() -> {
try {
aiClient.streamChat(prompt, chunk ->
emitter.send(SseEmitter.event().data(chunk).name("message")));
emitter.send(SseEmitter.event().name("done").data("[DONE]"));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}WebFlux 版本更简洁:
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String prompt) {
return aiClient.streamChat(prompt); // 天生就是流
}实战坑:SSE 经过 Nginx 时必须关掉缓冲,否则内容会被攒着一次性吐出来,流式效果完全消失:
nginxproxy_buffering off; proxy_cache off; proxy_read_timeout 300s;这个配置遗漏是"本地流式好好的,上线就变成一次性返回"的头号原因。
6.3 WebSocket + STOMP
@Configuration
@EnableWebSocketMessageBroker
public class WsConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue"); // 生产环境建议换成 RabbitMQ/Redis 做外部 Broker
registry.setApplicationDestinationPrefixes("/app");
}
}集群陷阱:
enableSimpleBroker是单机内存 Broker,多实例部署时用户 A 连在实例 1、用户 B 连在实例 2,互相收不到消息。生产必须换成enableStompBrokerRelay对接 RabbitMQ,或用 Redis Pub/Sub 自己做广播。这是 WebSocket 上线后最常见的"本地好好的,线上不通"事故。
7. HTTP 客户端三代演进
| 客户端 | 状态 | 编程模型 | 建议 |
|---|---|---|---|
RestTemplate | 维护模式,不再加新特性 | 同步阻塞 | 存量项目继续用,新项目别选 |
WebClient | 活跃 | 响应式(也支持 .block() 同步用) | 需要响应式/流式时用 |
RestClient | Spring 6.1+ 新增,推荐 | 同步 + 流式 API | ⭐ 新项目首选,WebClient 的优雅 API + 同步的简单心智 |
| HTTP Interface | Spring 6+ | 声明式(类似 Feign) | 接口多时最优雅 |
RestClient:新项目的默认选择
@Bean
public RestClient paymentRestClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.payment.com")
.defaultHeader("X-App-Id", appId)
.requestInterceptor((req, body, exec) -> {
log.info("调用支付网关 {} {}", req.getMethod(), req.getURI());
return exec.execute(req, body);
})
.build();
}
// 使用
PaymentResult result = restClient.post()
.uri("/v1/pay")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
(req, res) -> { throw new PaymentException("参数错误"); })
.body(PaymentResult.class);HTTP Interface:最优雅的写法
@HttpExchange("/api/users")
public interface UserApiClient {
@GetExchange("/{id}")
User getUser(@PathVariable Long id);
@PostExchange
User create(@RequestBody CreateUserRequest req);
@GetExchange(value = "/{id}", version = "2") // ⭐ Framework 7:客户端也支持版本化
UserV2 getUserV2(@PathVariable Long id);
}
@Bean
public UserApiClient userApiClient(RestClient.Builder builder) {
RestClient client = builder.baseUrl("https://user-service").build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(client))
.build()
.createClient(UserApiClient.class);
}和 OpenFeign 的关系:HTTP Interface 是 Spring 原生的声明式客户端,不需要 Spring Cloud 依赖。在微服务场景下 OpenFeign 仍有优势(和服务发现、负载均衡、熔断集成更深),但在单体或不依赖 Spring Cloud 的场景,HTTP Interface 是更轻的选择。
8. 跨域 CORS:三种配置方式与优先级
// 方式1:全局配置(推荐)
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("https://*.example.com") // ⚠️ 带 credentials 时不能用 "*"
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowCredentials(true)
.maxAge(3600);
}
}和 Spring Security 一起用时的坑:Security 的过滤器链在 MVC 之前,
WebMvcConfigurer的 CORS 配置对预检请求(OPTIONS)可能不生效,导致"接口能通但浏览器报跨域"。正解:在 Security 配置里显式开启http.cors(Customizer.withDefaults()),让 Security 读取CorsConfigurationSourceBean。这个问题在 Stack Overflow 上有几千个重复提问,根因就是这个顺序。
9. 本篇自测
- 画出
DispatcherServlet的完整请求处理流程,说明 406 和 415 分别卡在哪一步。 - 为什么全局异常处理器捕获不到 Filter 里抛出的异常?
- 自定义
HandlerMethodArgumentResolver能解决什么类型的重复代码? List<AuthorRequest>里的元素校验为什么不生效?- Spring Framework 7 的 API 版本化提供了哪四种版本解析策略?
- 虚拟线程出现后,MVC 和 WebFlux 的选型逻辑发生了什么变化?虚拟线程有哪些已知限制?
- SSE 和 WebSocket 怎么选?SSE 经过 Nginx 需要注意什么?
RestTemplate、WebClient、RestClient、HTTP Interface 各自的定位是什么?
下一篇(04 · Spring Data):数据怎么进出,以及怎么不出错。JPA 和 MyBatis 的世纪之争、N+1 查询的真相、事务传播的连接池陷阱、Redis 缓存三大经典故障,以及一个很多人不知道的问题——你的
@Transactional方法里调用了 HTTP 接口吗?那是个定时炸弹。