Skip to content

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

统一响应包装的正确姿势 ​

java
@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取值来源
@RequestParamRequestParamMethodArgumentResolverquery / form
@PathVariablePathVariableMethodArgumentResolverURL 路径模板
@RequestBodyRequestResponseBodyMethodProcessor请求体(走 MessageConverter)
@RequestHeaderRequestHeaderMethodArgumentResolver请求头
无注解 POJOServletModelAttributeMethodProcessor按 @ModelAttribute 逐字段绑定

3.2 实战:写一个 @CurrentUser ​

java
@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());
    }
}

用起来:

java
@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 实现
java
@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,别再自造错误格式了:

java
@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 属性:

java
@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 都能指定版本
  • 弃用提示:可以配置在响应里自动带上版本弃用通知
yaml
spring:
  mvc:
    apiversion:
      use:
        header: API-Version      # 从这个请求头取版本

选型建议:REST 之父 Roy Fielding 其实反对 API 版本化(他认为应该靠超媒体演进),但现实世界里版本化是刚需。Spring 团队的态度很务实——不评判该不该做,而是把四种常见做法都支持好,让你按业务需要选。从实用角度:对内服务用请求头版本化(URL 保持干净),对外开放 API 用路径版本化(最直观,第三方开发者最容易理解)。

4.4 参数校验进阶 ​

java
// 分组校验
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 MVCSpring WebFlux
模型Servlet,一请求一线程Reactive Streams,Mono/Flux
容器Tomcat / JettyNetty(默认)
背压支持无✅ 原生支持 Backpressure
调试体验堆栈清晰,断点好使异步堆栈难读,断点经常断不到想要的地方
生态兼容全部第三方库原生支持JDBC 不可用(需 R2DBC),部分库需适配
学习成本低高(操作符、背压、错误传播是全新范式)
团队成本新人一周上手新人一个月还在踩坑

5.2 ⭐ 虚拟线程改变了什么 ​

yaml
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 实战:大模型流式输出 ​

java
@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 版本更简洁:

java
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String prompt) {
    return aiClient.streamChat(prompt);   // 天生就是流
}

实战坑:SSE 经过 Nginx 时必须关掉缓冲,否则内容会被攒着一次性吐出来,流式效果完全消失:

nginx
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;

这个配置遗漏是"本地流式好好的,上线就变成一次性返回"的头号原因。

6.3 WebSocket + STOMP ​

java
@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() 同步用)需要响应式/流式时用
RestClientSpring 6.1+ 新增,推荐同步 + 流式 API⭐ 新项目首选,WebClient 的优雅 API + 同步的简单心智
HTTP InterfaceSpring 6+声明式(类似 Feign)接口多时最优雅

RestClient:新项目的默认选择 ​

java
@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:最优雅的写法 ​

java
@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:三种配置方式与优先级 ​

java
// 方式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 读取 CorsConfigurationSource Bean。这个问题在 Stack Overflow 上有几千个重复提问,根因就是这个顺序。


9. 本篇自测 ​

  1. 画出 DispatcherServlet 的完整请求处理流程,说明 406 和 415 分别卡在哪一步。
  2. 为什么全局异常处理器捕获不到 Filter 里抛出的异常?
  3. 自定义 HandlerMethodArgumentResolver 能解决什么类型的重复代码?
  4. List<AuthorRequest> 里的元素校验为什么不生效?
  5. Spring Framework 7 的 API 版本化提供了哪四种版本解析策略?
  6. 虚拟线程出现后,MVC 和 WebFlux 的选型逻辑发生了什么变化?虚拟线程有哪些已知限制?
  7. SSE 和 WebSocket 怎么选?SSE 经过 Nginx 需要注意什么?
  8. RestTemplate、WebClient、RestClient、HTTP Interface 各自的定位是什么?

下一篇(04 · Spring Data):数据怎么进出,以及怎么不出错。JPA 和 MyBatis 的世纪之争、N+1 查询的真相、事务传播的连接池陷阱、Redis 缓存三大经典故障,以及一个很多人不知道的问题——你的 @Transactional 方法里调用了 HTTP 接口吗?那是个定时炸弹。

基于 Vite 强力驱动 | 纯静态轻量托管