Spring MVC / Web 接口开发
Web 接口开发最容易写成“能调通就行”。一个 Controller、一个 Service、一个 JSON 返回,Postman 能通,开发阶段看起来就结束了。
生产环境不这么看。生产会继续问:
参数到底从 query、path、header 还是 body 进来?
校验失败返回什么结构?
错误码谁维护?
接口改字段会不会把老客户端打崩?
过滤器和拦截器到底该谁做鉴权、日志、traceId?
超时、重复提交、重试、幂等怎么约束?
异常日志能不能根据 traceId 找到?这篇就按一个 Web API 从第一行 Controller 写到上线检查的顺序来讲 Spring MVC。少讲“RESTful 很重要”这种价值论,多写可复制的代码、配置、请求、响应、排障命令和生产约束。
本文范围
本文覆盖:
- Spring MVC 请求处理链路。
DispatcherServlet、HandlerMapping、HandlerAdapter、参数解析器、返回值处理器、异常解析器这些运行时核心对象。@RestController、@RequestMapping、@GetMapping、@PostMapping。@PathVariable、@RequestParam、@RequestHeader、@RequestBody、表单对象绑定。- Jakarta Bean Validation、请求体校验、方法参数校验。
- 成功响应、统一错误响应、ProblemDetail、业务错误码。
@RestControllerAdvice和常见异常处理。- Filter、Interceptor 的边界和注册方式。
- API 版本策略、兼容性、幂等、超时、鉴权边界、日志追踪。
- MockMvc 测试、curl 验证、上线前检查清单。
本文不覆盖:
- Spring Security 的完整认证授权体系。
- API Gateway、OAuth2、JWT、SSO 的完整方案。
- 数据库事务、缓存、MQ、分布式事务和服务治理。
- 前端框架调用封装。
- WebFlux 响应式编程。
版本边界:写作日期是 2026-07-09。本文以 Spring Boot 4.1.0、Spring Framework 7.0.8、Java 17+ 为基准。Spring Boot 4.x 默认是 Jakarta EE 体系,示例里的校验注解使用 jakarta.validation.*,不要再混用 javax.validation.*。
Spring Boot 4.x 里,spring-boot-starter-web 仍能用,但已经建议迁移到更明确的 spring-boot-starter-webmvc。新项目直接用 spring-boot-starter-webmvc,老项目升级时把 starter 改名、跑一遍 Web 层测试和接口契约测试,不要只看启动成功。
前置条件
先准备一个带 Spring MVC 和 Validation 的项目。Maven 依赖可以这样写:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Spring Boot 4 的 MVC 测试切片已经迁到 spring-boot-webmvc-test 模块,@WebMvcTest 的包名是 org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest。如果公司的父 POM 或测试 starter 没有带入这个模块,要显式补上对应依赖;不要把 Boot 3 的 org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 复制到 Boot 4 项目里。
开发机确认:
java -version
mvn -version
curl --version建议工具:
curl:验证 HTTP 行为
jq:格式化 JSON
MockMvc:写 Web 层测试
日志 traceId:定位一次请求
接口文档或契约测试:控制兼容性企业内网和离线环境要提前准备 Maven 私服、插件缓存和接口测试工具。Web API 本身不依赖公网,但构建和测试经常死在依赖下载上。
一、先看 Spring MVC 请求链路
Spring MVC 的核心是 DispatcherServlet。所有请求先进入它,再由它找到 Controller 方法、解析参数、执行方法、处理返回值。
这张图排障时很有用。
如果接口 404,优先查 HandlerMapping 有没有匹配。
如果 400,优先查参数绑定和校验。
如果 415,优先查 Content-Type 和消息转换器。
如果日志 traceId 缺失,优先查 Filter 是否执行。
如果 Interceptor 没进来,优先查请求有没有到 DispatcherServlet。
把图拆成源码对象,大致是这条链:
| 环节 | 关键对象 | 主要职责 | 常见现象 |
|---|---|---|---|
| Servlet 入口 | DispatcherServlet | 前端控制器,统一执行请求分发算法 | 404、异常没有被统一处理、静态资源被误拦 |
| 找方法 | RequestMappingHandlerMapping | 把请求路径、HTTP 方法、consumes、produces、版本信息匹配到 HandlerMethod | 404、405、接口版本不命中 |
| 拦截链 | HandlerExecutionChain | 持有 handler 和匹配到的 HandlerInterceptor 列表 | Interceptor 没执行、顺序不对 |
| 调方法 | RequestMappingHandlerAdapter | 调用注解 Controller 方法,协调参数解析、校验和返回值处理 | 400、415、406、返回值序列化失败 |
| 解析参数 | HandlerMethodArgumentResolverComposite | 按参数类型和注解选择具体解析器 | query/path/header/body 取值混乱 |
| 绑定校验 | WebDataBinderFactory、WebDataBinder、ConversionService、Validator | 类型转换、对象绑定、Bean Validation | 类型转换失败、校验没生效 |
| 写响应 | HandlerMethodReturnValueHandlerComposite、HttpMessageConverter | 处理 ResponseEntity、@ResponseBody、JSON、字符串、文件流 | 406、中文乱码、JSON 字段异常 |
| 处理异常 | HandlerExceptionResolverComposite | 把 MVC 异常和业务异常转换成 HTTP 响应 | 错误码失真、ProblemDetail 不生效 |
Spring MVC 启动时会先把这些策略对象准备好。RequestMappingHandlerMapping 会扫描 Controller,把 @RequestMapping、@GetMapping 等方法整理成 RequestMappingInfo -> HandlerMethod 的映射;RequestMappingHandlerAdapter 会准备参数解析器、返回值处理器、@ControllerAdvice、@InitBinder、RequestBodyAdvice 和 ResponseBodyAdvice。请求真正进来时,不是临时再反射全项目,而是沿着这些已经初始化好的映射和处理器往下走。
这里最容易被忽略的是:Spring MVC 的很多“魔法”其实是按顺序遍历策略列表。参数解析器、返回值处理器、异常解析器都是这样。线上出现“明明有注解却不生效”时,不要只盯 Controller,先判断请求到底卡在匹配、绑定、校验、转换、返回还是异常解析哪一层。
二、写一个能上线的最小 Controller
先定义响应对象。这里用 Java record,让示例短一些:
package com.example.order.api;
import java.math.BigDecimal;
import java.time.Instant;
public record OrderResponse(
Long orderId,
String status,
BigDecimal amount,
Instant createdAt
) {
}请求对象:
package com.example.order.api;
import java.math.BigDecimal;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record CreateOrderRequest(
@NotBlank(message = "商品编码不能为空")
String skuCode,
@NotNull(message = "金额不能为空")
@DecimalMin(value = "0.01", message = "金额必须大于 0")
BigDecimal amount,
@Size(max = 200, message = "备注不能超过 200 个字符")
String remark
) {
}Controller:
package com.example.order.api;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/orders")
class OrderController {
@GetMapping("/{orderId}")
OrderResponse getOrder(
@PathVariable Long orderId,
@RequestHeader(name = "X-Request-Id", required = false) String requestId
) {
return new OrderResponse(orderId, "CREATED", new BigDecimal("99.00"), Instant.now());
}
@GetMapping
List<OrderResponse> listOrders(
@RequestParam(defaultValue = "1") @Min(value = 1, message = "page 最小为 1") int page,
@RequestParam(defaultValue = "20") @Min(value = 1, message = "size 最小为 1") int size
) {
return List.of(new OrderResponse(1L, "CREATED", new BigDecimal("99.00"), Instant.now()));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
return new OrderResponse(1001L, "CREATED", request.amount(), Instant.now());
}
}启动后验证查询:
curl -i http://127.0.0.1:8080/api/v1/orders/1001验证列表:
curl -i 'http://127.0.0.1:8080/api/v1/orders?page=1&size=10'验证创建:
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-H 'X-Request-Id: demo-001' \
-d '{"skuCode":"SKU-001","amount":99.00,"remark":"first order"}'你应该看到 201 和订单 JSON。
这里有几个实践约束:
查询单个资源:优先 GET /api/v1/orders/{orderId}
创建资源:优先 POST /api/v1/orders
请求体必须声明 Content-Type: application/json
创建成功用 201,比所有成功都 200 更清楚
路径版本 /api/v1 简单直接,适合大部分团队三、参数绑定按来源分清楚
参数绑定不要混着写。先问一句:这个参数从哪里来?
底层处理不是一个注解一个 if,而是 RequestMappingHandlerAdapter 按方法参数逐个找 HandlerMethodArgumentResolver:
@PathVariable -> PathVariableMethodArgumentResolver -> URI 模板变量
@RequestParam -> RequestParamMethodArgumentResolver -> query string / form 参数
@RequestHeader -> RequestHeaderMethodArgumentResolver -> HTTP header
@RequestBody -> RequestResponseBodyMethodProcessor -> HttpMessageConverter 读 body
普通对象 -> ModelAttributeMethodProcessor -> WebDataBinder 绑定属性解析器拿到原始值后,简单类型会走 ConversionService 做类型转换,复杂对象会交给 WebDataBinder 做属性绑定。带 @Valid 或约束注解时,再进入 Validator。所以排查参数问题时要按三步看:
第一步:参数来源是否正确,例如 query、path、header、body 有没有放错。
第二步:类型转换是否成功,例如 abc 能不能转成 Long、日期格式是否符合配置。
第三步:校验是否触发,以及异常有没有被全局处理成稳定错误响应。1. PathVariable
资源标识放路径里:
@GetMapping("/{orderId}")
OrderResponse getOrder(@PathVariable Long orderId) {
return orderQueryService.getOrder(orderId);
}请求:
curl -i http://127.0.0.1:8080/api/v1/orders/1001常见坑:
路径变量名和方法参数名不一致
Long 转换失败导致 400
路径层级太深,接口难维护如果参数名不一致,显式指定:
@PathVariable("orderId") Long id2. RequestParam
筛选、分页、排序放 query:
@GetMapping
List<OrderResponse> listOrders(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String status
) {
return orderQueryService.list(page, size, status);
}请求:
curl -i 'http://127.0.0.1:8080/api/v1/orders?page=1&size=20&status=CREATED'数组参数:
@GetMapping("/batch")
List<OrderResponse> batchGet(@RequestParam List<Long> orderIds) {
return orderQueryService.batchGet(orderIds);
}请求:
curl -i 'http://127.0.0.1:8080/api/v1/orders/batch?orderIds=1&orderIds=2'3. RequestHeader
请求追踪、调用方、幂等键、灰度标识可以从 header 来:
@PostMapping
OrderResponse createOrder(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CreateOrderRequest request
) {
return orderCommandService.create(idempotencyKey, request);
}请求:
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-20260709-0001' \
-d '{"skuCode":"SKU-001","amount":99.00}'幂等键不是装饰。生产上只要接口会被客户端重试、网关重试、消息补偿调用,就要明确幂等语义。
4. RequestBody
复杂写操作用 JSON body:
@PostMapping
OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderCommandService.create(request);
}请求必须带:
Content-Type: application/json如果没带,常见现象是 415:
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-d '{"skuCode":"SKU-001","amount":99.00}'修复:
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{"skuCode":"SKU-001","amount":99.00}'5. 表单和查询对象
GET 查询条件太多时,不要让方法参数排成一长串,可以用对象绑定:
package com.example.order.api;
import jakarta.validation.constraints.Min;
public class OrderSearchQuery {
@Min(value = 1, message = "page 最小为 1")
private int page = 1;
@Min(value = 1, message = "size 最小为 1")
private int size = 20;
private String status;
public int getPage() {
return page;
}
public void setPage(int page) {
this.page = page;
}
public int getSize() {
return size;
}
public void setSize(int size) {
this.size = size;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}Controller:
@GetMapping("/search")
List<OrderResponse> search(@Valid OrderSearchQuery query) {
return orderQueryService.search(query);
}请求:
curl -i 'http://127.0.0.1:8080/api/v1/orders/search?page=1&size=20&status=CREATED'这里要注意:GET 查询对象通常来自 query string,不是 JSON body。不要为了偷懒让 GET 带 body,代理、网关、缓存和客户端兼容性都可能出问题。
四、校验要在边界完成
接口边界必须做校验。不要把明显错误一路传到数据库层才炸。
请求体校验:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
OrderResponse createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderCommandService.create(request);
}请求:
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{"skuCode":"","amount":0}'正常应该返回 400。
方法参数校验:
@GetMapping
List<OrderResponse> listOrders(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size
) {
return orderQueryService.list(page, size);
}请求:
curl -i 'http://127.0.0.1:8080/api/v1/orders?page=0&size=20'Spring Framework 6.1 之后,Spring MVC 内置了对控制器方法校验的支持。老资料里经常要求在 Controller 类上加 @Validated 才能触发方法校验;在新版本里,如果类级别 @Validated 触发 AOP 校验,反而可能绕过 MVC 内置方法校验。Boot 4 / Framework 7 项目要按当前官方文档确认,不要照抄旧写法。
校验常见异常:
MethodArgumentNotValidException:@RequestBody 对象校验失败
HandlerMethodValidationException:Controller 方法参数或返回值校验失败
MissingServletRequestParameterException:缺少必填 query 参数
MethodArgumentTypeMismatchException:参数类型转换失败
HttpMessageNotReadableException:JSON 格式错误或 body 无法读取把参数绑定、校验和错误响应串起来看,会更容易定位 400 到底来自哪一层。排障时先看请求参数来源,再看类型转换和 JSON 读取,最后看校验异常有没有被统一转成稳定的 ProblemDetail。
设计评审时也可以按这张图反问:入口 DTO 是否和数据库实体解耦,所有 400 是否能映射到明确异常,traceId 是否进入错误体和日志,@RestControllerAdvice 是否覆盖了当前 Spring MVC 版本真正会抛出的异常类型。
校验链路可以按下面这张表反推:
| 写法 | 触发位置 | 常见异常 | 排查入口 |
|---|---|---|---|
@Valid @RequestBody CreateOrderRequest | RequestResponseBodyMethodProcessor 读完 body 后校验对象 | MethodArgumentNotValidException | JSON 是否可读、字段约束是否在 DTO 上 |
@Valid OrderSearchQuery | ModelAttributeMethodProcessor 绑定 query/form 后校验对象 | HandlerMethodValidationException 或绑定错误 | query 名称、setter、默认值、类型转换 |
@RequestParam @Min(1) int page | 方法参数级校验 | HandlerMethodValidationException | 是否引入 validation starter、是否处理该异常 |
@RequestHeader @NotBlank String tenantId | 方法参数级校验 | HandlerMethodValidationException | Header 是否传入、网关是否透传 |
这里有一个生产建议:请求 DTO 只表达入口协议,不要复用数据库实体。入口字段、校验文案、默认值和数据库字段的变化节奏不同,复用实体会让接口兼容性和数据模型绑死在一起。
如果你希望局部处理校验错误,可以在待校验参数后面紧跟 BindingResult。但大多数 REST API 更推荐全局统一处理,避免每个 Controller 重复写错误拼装逻辑。
五、成功响应和错误响应分开设计
很多团队喜欢所有返回都包一层:
{
"code": "0",
"message": "success",
"data": {}
}这没问题,但不要把 HTTP 状态码全部固定成 200。比较稳的做法是:
成功:业务统一结构可以保留,HTTP 状态码正常表达 200 / 201 / 204
失败:HTTP 状态码表达协议层结果,ProblemDetail 或错误结构表达业务错误码成功响应可以这样:
package com.example.order.api;
public record ApiResponse<T>(
String code,
String message,
T data
) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>("0", "success", data);
}
}Controller:
@GetMapping("/{orderId}")
ApiResponse<OrderResponse> getOrder(@PathVariable Long orderId) {
return ApiResponse.ok(orderQueryService.getOrder(orderId));
}如果团队想统一包装所有成功响应,可以用 ResponseBodyAdvice,但边界要很清楚:它只适合处理正常返回,不适合吞异常、改 HTTP 状态码、强行包装文件下载、SSE、流式响应和已经是 ProblemDetail 的错误响应。
package com.example.order.web;
import com.example.order.api.ApiResponse;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
@RestControllerAdvice
class ApiResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
org.springframework.http.server.ServerHttpRequest request,
org.springframework.http.server.ServerHttpResponse response
) {
if (body == null || body instanceof ApiResponse<?> || body instanceof ProblemDetail) {
return body;
}
return ApiResponse.ok(body);
}
}这段代码只是说明边界,真实项目里还要跳过 byte[]、Resource、StreamingResponseBody、文件下载和特定媒体类型。不要为了“统一返回”把所有响应都套成 JSON,否则下载、预览、网关探活和第三方回调都会出问题。
但错误响应更推荐使用 Spring 的 ProblemDetail,再补业务 code:
{
"type": "https://example.com/problems/validation-error",
"title": "请求参数校验失败",
"status": 400,
"detail": "skuCode: 商品编码不能为空",
"instance": "/api/v1/orders",
"code": "ORDER.PARAM_INVALID",
"traceId": "b6b6f..."
}配置:
spring:
mvc:
problemdetails:
enabled: true业务异常:
package com.example.order.error;
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
public String code() {
return code;
}
}全局异常处理:
package com.example.order.error;
import java.net.URI;
import java.util.stream.Collectors;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(BusinessException.class)
ProblemDetail handleBusiness(BusinessException ex, HttpServletRequest request) {
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
detail.setTitle("业务处理失败");
detail.setType(URI.create("https://example.com/problems/business-error"));
detail.setInstance(URI.create(request.getRequestURI()));
detail.setProperty("code", ex.code());
detail.setProperty("traceId", MDC.get("traceId"));
return detail;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleBodyValidation(MethodArgumentNotValidException ex, HttpServletRequest request) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(this::formatFieldError)
.collect(Collectors.joining("; "));
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
detail.setTitle("请求参数校验失败");
detail.setType(URI.create("https://example.com/problems/validation-error"));
detail.setInstance(URI.create(request.getRequestURI()));
detail.setProperty("code", "COMMON.PARAM_INVALID");
detail.setProperty("traceId", MDC.get("traceId"));
return detail;
}
@ExceptionHandler(HandlerMethodValidationException.class)
ProblemDetail handleMethodValidation(HandlerMethodValidationException ex, HttpServletRequest request) {
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "请求参数校验失败");
detail.setTitle("请求参数校验失败");
detail.setType(URI.create("https://example.com/problems/validation-error"));
detail.setInstance(URI.create(request.getRequestURI()));
detail.setProperty("code", "COMMON.PARAM_INVALID");
detail.setProperty("traceId", MDC.get("traceId"));
return detail;
}
@ExceptionHandler(Exception.class)
ProblemDetail handleUnknown(Exception ex, HttpServletRequest request) {
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "系统异常,请稍后重试");
detail.setTitle("系统异常");
detail.setType(URI.create("https://example.com/problems/internal-error"));
detail.setInstance(URI.create(request.getRequestURI()));
detail.setProperty("code", "COMMON.INTERNAL_ERROR");
detail.setProperty("traceId", MDC.get("traceId"));
return detail;
}
private String formatFieldError(FieldError error) {
return error.getField() + ": " + error.getDefaultMessage();
}
}未知异常要记录日志:
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(Exception.class)
ProblemDetail handleUnknown(Exception ex, HttpServletRequest request) {
log.error("unexpected api error, uri={}, traceId={}", request.getRequestURI(), MDC.get("traceId"), ex);
ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "系统异常,请稍后重试");
detail.setTitle("系统异常");
detail.setProperty("code", "COMMON.INTERNAL_ERROR");
detail.setProperty("traceId", MDC.get("traceId"));
return detail;
}异常解析也有顺序。DispatcherServlet 捕获请求处理异常后,会交给 HandlerExceptionResolverComposite,常见链路是:
ExceptionHandlerExceptionResolver
-> 找 @ExceptionHandler、@RestControllerAdvice;开启 ProblemDetail 时也会纳入 Boot 自动配置的 ResponseEntityExceptionHandler
ResponseStatusExceptionResolver
-> 处理 @ResponseStatus、ResponseStatusException
DefaultHandlerExceptionResolver
-> 把 MVC 内置异常映射成 400 / 404 / 405 / 415 等状态码生产上要记住两件事。第一,业务异常和校验异常应该由你自己的 @RestControllerAdvice 稳定接住,否则不同异常会走不同默认响应。第二,如果开启 spring.mvc.problemdetails.enabled,Spring Boot 会自动配置处理内置异常的 ResponseEntityExceptionHandler;你要覆盖内置异常时,自己的 Advice 顺序必须排在它前面,否则看起来写了处理器,实际没有生效。
生产原则:
参数错误:400
未认证:401
无权限:403
资源不存在:404
方法不支持:405
媒体类型不支持:415
业务冲突:409
服务端未知异常:500
下游不可用或超时:502 / 503 / 504 视网关和服务边界决定不要把所有错误都塞进 200。调用方、监控、网关、缓存和 APM 都需要真实 HTTP 语义。
错误码和 ProblemDetail 的映射规则
错误码治理不能只靠约定“大家自觉”。建议把 HTTP 状态码、业务 code、ProblemDetail 字段、日志级别和调用方动作放在同一张表里维护。这样前端、移动端、第三方调用方、监控和后端排障看到的是同一套语言。
| 场景 | HTTP 状态 | code 示例 | ProblemDetail 字段 | 日志级别 | 调用方动作 |
|---|---|---|---|---|---|
| JSON 格式错误 | 400 | COMMON.JSON_INVALID | title=请求体不可读,detail 写解析失败摘要 | WARN | 修正请求体 |
| 参数校验失败 | 400 | COMMON.PARAM_INVALID | detail 写字段错误,instance 写请求路径 | WARN | 修正入参 |
| 未登录或凭证失效 | 401 | AUTH.UNAUTHORIZED | 不泄露内部鉴权细节 | INFO/WARN | 重新登录或刷新凭证 |
| 无权限 | 403 | AUTH.FORBIDDEN | 说明缺少权限,不暴露权限表达式 | WARN | 申请权限或停止调用 |
| 资源不存在 | 404 | ORDER.NOT_FOUND | instance 保留请求路径 | INFO | 不重试,展示空态或提示 |
| 业务状态冲突 | 409 | ORDER.STATUS_NOT_ALLOWED | detail 写当前状态和允许动作 | WARN | 按业务状态刷新后重试 |
| 幂等重复提交 | 409 或 200 | COMMON.IDEMPOTENT_REPLAY | 返回首次处理结果或冲突原因 | INFO | 使用同一结果,不重复创建 |
| 下游超时 | 504 | PAYMENT.TIMEOUT | 不暴露下游域名和内网地址 | ERROR | 查询最终状态,谨慎重试 |
| 未知异常 | 500 | COMMON.INTERNAL_ERROR | 固定用户文案,带 traceId | ERROR | 带 traceId 报障 |
落地时有三个硬约束。
第一,code 一旦对外发布,不能随手复用成别的语义。
第二,ProblemDetail 的 type 要表达问题类型,不要每个异常都生成一个随机 URL。
第三,traceId 必须同时出现在响应头、错误体和日志里,否则错误码只能告诉你“错了”,不能帮你定位“哪一次错了”。如果团队已经有统一 ApiResponse,也不要把 ProblemDetail 硬塞进 data 里。更稳的方式是:成功响应走 ApiResponse<T>,错误响应走 RFC 9457 风格的 ProblemDetail,并在其中扩展 code、traceId、errors 等业务字段。调用方只需要记住一条规则:非 2xx 先按 HTTP 状态处理,再读取 code 做业务分支。
六、Filter 和 Interceptor 不要混用
Filter 和 Interceptor 都能拦请求,但位置不同。
Filter:Servlet 规范,DispatcherServlet 之前,适合 traceId、编码、CORS、请求包装、低层安全过滤
Interceptor:Spring MVC,Controller 前后,适合登录态、业务权限、接口日志、灰度标识、HandlerMethod 级判断三者的边界可以按请求位置判断:
这张图适合做设计评审。traceId、请求体缓存、底层 CORS 这类必须在 MVC 之前完成的事情放 Filter;依赖 HandlerMethod 的接口日志、轻量业务上下文放 Interceptor;参数校验、业务异常和内置 MVC 异常统一交给 @RestControllerAdvice。如果异常发生在 Filter、网关或 Spring Security 过滤链前段,不能指望普通 ControllerAdvice 兜住,要在对应层单独处理错误响应和 traceId。
1. 用 Filter 生成 traceId
package com.example.order.web;
import java.io.IOException;
import java.util.UUID;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.web.filter.OncePerRequestFilter;
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID = "traceId";
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String traceId = request.getHeader("X-Request-Id");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put(TRACE_ID, traceId);
response.setHeader("X-Request-Id", traceId);
try {
filterChain.doFilter(request, response);
} finally {
MDC.remove(TRACE_ID);
}
}
}注册:
package com.example.order.web;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class WebFilterConfig {
@Bean
FilterRegistrationBean<TraceIdFilter> traceIdFilter() {
FilterRegistrationBean<TraceIdFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new TraceIdFilter());
registration.addUrlPatterns("/*");
registration.setOrder(1);
return registration;
}
}验证:
curl -i http://127.0.0.1:8080/api/v1/orders/1001 -H 'X-Request-Id: demo-001'应该看到响应头:
X-Request-Id: demo-0012. 用 Interceptor 做接口日志
package com.example.order.web;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
public class ApiAccessLogInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(ApiAccessLogInterceptor.class);
private static final String START_TIME = "startTime";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
request.setAttribute(START_TIME, System.currentTimeMillis());
return true;
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) {
Object start = request.getAttribute(START_TIME);
long cost = start instanceof Long startTime ? System.currentTimeMillis() - startTime : -1;
String handlerName = handler instanceof HandlerMethod method
? method.getBeanType().getSimpleName() + "#" + method.getMethod().getName()
: String.valueOf(handler);
log.info("api access method={} uri={} status={} costMs={} handler={} traceId={}",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
cost,
handlerName,
MDC.get("traceId"));
}
}注册:
package com.example.order.web;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new ApiAccessLogInterceptor())
.addPathPatterns("/api/**")
.excludePathPatterns("/actuator/**");
}
}注意不要随便加 @EnableWebMvc。在 Spring Boot 项目里,通常实现 WebMvcConfigurer 就能扩展 MVC 配置;加了 @EnableWebMvc 会接管大量 Boot 自动配置,可能让消息转换器、静态资源、默认错误处理出现变化。
Filter 和 Interceptor 的选型:
| 需求 | 建议 |
|---|---|
| traceId、请求包装、读取原始请求、低层 CORS | Filter |
| 统计 Controller 耗时、读取 HandlerMethod、业务权限 | Interceptor |
| 登录认证、授权、CSRF、密码策略 | Spring Security,不要自己在 Interceptor 里造完整安全体系 |
| API 限流、灰度、网关鉴权 | 优先网关或基础设施,应用内只做兜底 |
七、接口版本和兼容性
版本策略先从简单可控开始。
路径版本:
/api/v1/orders
/api/v2/orders优点是直观,网关、日志、权限、文档都容易区分。大部分团队用它就够了。
如果使用 Spring MVC 新版本的 API Versioning 能力,可以用请求头:
spring:
mvc:
apiversion:
default: 1.0.0
supported:
- 1.0.0
- 2.0.0
required: false
use:
header: X-API-VersionController 可以按版本映射:
@GetMapping(path = "/api/orders/{orderId}", version = "1.0.0")
OrderResponse getV1(@PathVariable Long orderId) {
return orderQueryService.getOrder(orderId);
}
@GetMapping(path = "/api/orders/{orderId}", version = "2.0.0")
OrderDetailResponse getV2(@PathVariable Long orderId) {
return orderQueryService.getOrderDetail(orderId);
}请求:
curl -i http://127.0.0.1:8080/api/orders/1001 -H 'X-API-Version: 2.0.0'这套配置背后是 ApiVersionStrategy:它先通过 ApiVersionResolver 从 header、query、media type 参数或 path segment 取版本,再用 ApiVersionParser 把原始版本解析成可比较对象,最后参与 RequestMappingInfo 匹配。版本不支持或必填版本缺失时会进入 400 错误分支。你也可以用 WebMvcConfigurer#configureApiVersioning 控制多种解析策略的顺序,避免 header 和 query 同时存在时行为不稳定。
版本下线也要让客户端看得见。Spring MVC 的版本配置可以设置废弃版本处理器,标准处理器会给废弃版本响应增加 Deprecation、Sunset、Link 这类提示头。不要只在群里通知“v1 下个月删”,要让调用方的日志、网关采样和契约测试都能捕获到下线信号。
@Configuration
class WebApiVersionConfiguration implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.useRequestHeader("X-API-Version")
.addSupportedVersions("1.0.0", "2.0.0")
.setVersionRequired(false);
// 废弃版本的响应头配置按当前 Spring Framework API 调整。
// 重点是让 Deprecation / Sunset / Link 进入响应和测试断言。
}
}验证方式不要只测 200:
curl -i http://127.0.0.1:8080/api/orders/1001 -H 'X-API-Version: 1.0.0'
curl -i http://127.0.0.1:8080/api/orders/1001 -H 'X-API-Version: 9.9.9'
curl -i http://127.0.0.1:8080/api/orders/1001你要分别确认:废弃版本能返回下线提示头,不支持版本返回明确 400,未传版本时走默认版本还是拒绝请求,这和 required 配置一致。detect-supported 这类自动探测能力也不要滥用;生产更稳的是显式登记支持版本、废弃日期、下线日期、负责人和兼容测试样例。
版本策略不要一开始就做得太复杂。真正重要的是兼容性规则:
新增响应字段通常兼容
删除响应字段通常不兼容
修改字段类型不兼容
枚举新增可能不兼容,要看客户端是否兜底
必填请求字段新增不兼容
错误码语义变化不兼容
分页默认排序变化可能不兼容接口上线前建议写契约检查:
curl -s http://127.0.0.1:8080/api/v1/orders/1001 | jq .如果有 OpenAPI 文档或契约测试,发布前要比对 schema,不能只跑单元测试。
八、写 Web 层测试
接口不是 Postman 手点一下就算测试。至少给核心 Controller 写 MockMvc 测试。
package com.example.order.api;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void getOrder() throws Exception {
mockMvc.perform(get("/api/v1/orders/{orderId}", 1001L))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderId").value(1001));
}
@Test
void createOrderRejectsInvalidBody() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"skuCode\":\"\",\"amount\":0}"))
.andExpect(status().isBadRequest());
}
}运行:
mvn -q -Dtest=OrderControllerTest testMockMvc 测什么:
路径和方法是否匹配
状态码是否正确
请求体校验是否生效
错误响应是否稳定
响应字段是否符合接口契约MockMvc 不适合替代所有集成测试。涉及真实数据库、网关、序列化全链路、鉴权链路时,还要补集成测试。
九、排障手册:按状态码走
线上排障不要一上来就改代码。先把请求方法、URI、HTTP 状态码、业务 code、响应头里的 traceId 和应用日志对齐,再决定查映射、绑定、校验、鉴权、业务状态还是下游。
这张图的用法很简单:非 2xx 先按 HTTP 状态码分流,再读业务 code 做细分。设计评审时要检查每个分支是否都有可复现命令、稳定错误体、日志级别和调用方动作;如果只能看到“系统异常”但没有 traceId,排障链路就是断的。
1. 404 Not Found
请求:
curl -i http://127.0.0.1:8080/api/v1/order/1001检查:
路径是否写错
Controller 是否被扫描
类上和方法上的 @RequestMapping 是否拼接正确
是否配置了 server.servlet.context-path
是否被 Nginx 或网关改写路径命令:
curl -i http://127.0.0.1:8080/actuator/mappings/actuator/mappings 可能暴露接口结构,生产不要公网开放。
2. 405 Method Not Allowed
现象:接口存在,但 HTTP 方法错了。
curl -i -X DELETE http://127.0.0.1:8080/api/v1/orders/1001检查 Controller 是否只写了 @GetMapping 或 @PostMapping。不要用一个 @RequestMapping 不写 method 接住所有请求,后期很难治理。
3. 400 Bad Request
常见原因:
query 参数类型转换失败
path 变量类型转换失败
JSON 格式错误
校验失败
缺少必填参数命令:
curl -i 'http://127.0.0.1:8080/api/v1/orders?page=abc'看日志时要找具体异常:
MethodArgumentTypeMismatchException
HttpMessageNotReadableException
MethodArgumentNotValidException
HandlerMethodValidationException4. 415 Unsupported Media Type
现象:POST JSON 时没加 Content-Type。
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-d '{"skuCode":"SKU-001","amount":99.00}'修复:
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{"skuCode":"SKU-001","amount":99.00}'5. 500 Internal Server Error
500 不是前端问题。先找 traceId,再查应用日志。
curl -i http://127.0.0.1:8080/api/v1/orders/1001 -H 'X-Request-Id: debug-001'
grep 'debug-001' logs/order-service.log如果日志没有 traceId,先修 Filter 和日志 pattern。
6. Interceptor 没执行
检查:
请求路径是否命中 addPathPatterns
是否被 excludePathPatterns 排除
请求是否被 Filter 或 Security 提前拦截
是否访问的是静态资源或 Actuator
是否手动加了 @EnableWebMvc 导致配置变化7. 校验没生效
检查:
是否引入 spring-boot-starter-validation
@RequestBody 前是否加 @Valid
约束注解是否来自 jakarta.validation
方法参数约束是否被当前 Spring MVC 版本支持
全局异常是否吞掉校验异常命令:
mvn -q dependency:tree | grep validation十、生产化建议
接口命名:
路径用名词,不用动词堆满路径
版本放路径或官方版本机制里,不要藏在业务参数里
分页参数统一 page / size 或 cursor
时间字段统一 ISO-8601,明确时区
金额用 BigDecimal,不要用 double响应治理:
成功响应结构稳定
错误响应结构稳定
错误码有归属和文档
HTTP 状态码不伪装
traceId 必须出现在响应头和日志里安全边界:
Filter 可以做 traceId 和底层包装
Interceptor 可以做轻量业务上下文
认证授权优先交给 Spring Security 或网关
Controller 不直接信任 userId、tenantId 这类敏感参数性能边界:
上传大小要限制
请求体大小要限制
分页 size 要设上限
外部调用要有超时
批量接口要有数量上限日志边界:
不要打印完整请求体里的敏感信息
不要在高 QPS 接口打印大 JSON
错误日志要带 traceId、接口、错误码
慢接口要记录耗时和关键依赖耗时十一、落地工程深水区
1. 接口兼容比代码漂亮更重要
上线后,接口就是契约。后端觉得“只是改个字段名”,客户端可能直接崩。
约束:
响应字段只加不删
枚举新增要先通知客户端并确认兜底
字段类型不能悄悄从 number 改 string
必填入参新增必须走新版本
老版本下线必须有灰度和公告周期检查:
curl -s http://127.0.0.1:8080/api/v1/orders/1001 | jq .配合接口文档或契约测试保存历史响应样本。
2. 错误码要治理,否则会变成另一套乱码
错误码不要每个开发随手起。
建议格式:
COMMON.PARAM_INVALID
COMMON.INTERNAL_ERROR
ORDER.NOT_FOUND
ORDER.STATUS_NOT_ALLOWED
PAYMENT.TIMEOUT治理要求:
每个错误码有中文说明
每个错误码有处理建议
错误码不复用不同语义
废弃错误码要保留兼容说明3. 超时和重试必须配套幂等
接口超时后,调用方很可能重试。创建订单、扣库存、支付这类接口如果没有幂等键,重复请求就可能重复写数据。
接口约束:
写接口要求 Idempotency-Key
服务端保存幂等键、请求摘要、处理状态、响应结果
重复请求返回同一结果
幂等记录要有过期时间和清理策略不要只靠前端按钮置灰。网络重试、网关重试、移动端弱网重发都不受按钮控制。
4. 接口超时要按链路预算治理
spring.mvc.async.request-timeout 只控制 Spring MVC 异步请求处理的超时,不等于整个业务接口的总超时。同步 Controller、数据库查询、Redis、HTTP 客户端、线程池排队、下游 RPC 都要各自有上限。
配置示例:
spring:
mvc:
async:
request-timeout: 3s更重要的是预算表:
网关总超时:3000ms
入口服务处理:2500ms
数据库查询:300ms
Redis:100ms
下游 HTTP:800ms
重试:只允许非写接口或有幂等键的写接口重试
降级:非核心依赖超时后返回兜底,不占住请求线程如果客户端已经超时,而服务端还继续执行写操作,就会出现“调用方认为失败、服务端实际成功、重试又来一遍”的错位。写接口必须把超时、取消、幂等记录和最终状态查询一起设计。
5. 鉴权边界不要塞满 Controller
Controller 里到处写:
if (!currentUser.canDoSomething()) {
throw new BusinessException("NO_PERMISSION", "无权限");
}短期能跑,长期会散。
建议:
认证:Spring Security 或网关
粗粒度权限:Interceptor / method security
业务细粒度权限:领域服务统一判断
审计:统一切面或事件鉴权失败用 401 / 403,不要返回业务 200。
6. 日志追踪是接口排障生命线
没有 traceId 的接口,出了问题只能靠时间猜。
最小要求:
请求进来生成或透传 X-Request-Id
响应头返回 X-Request-Id
应用日志带 traceId
错误响应带 traceId
调用下游时透传 traceId验证:
curl -i http://127.0.0.1:8080/api/v1/orders/1001 -H 'X-Request-Id: trace-demo'
grep 'trace-demo' logs/order-service.log7. 请求大小和分页上限要提前定
没有上限的接口,迟早会被大请求拖垮。
约束示例:
server:
max-http-request-header-size: 8KB
tomcat:
max-http-form-post-size: 2MB
max-parameter-count: 1000
max-part-count: 50
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 20MB分页上限在代码里兜底:
int safeSize = Math.min(size, 100);更好的做法是统一分页对象,在边界层限制。
十二、命令速查
# GET
curl -i http://127.0.0.1:8080/api/v1/orders/1001
# query 参数
curl -i 'http://127.0.0.1:8080/api/v1/orders?page=1&size=20'
# POST JSON
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-H 'X-Request-Id: demo-001' \
-H 'Idempotency-Key: order-001' \
-d '{"skuCode":"SKU-001","amount":99.00}'
# 校验失败
curl -i -X POST http://127.0.0.1:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{"skuCode":"","amount":0}'
# API 版本请求头
curl -i http://127.0.0.1:8080/api/orders/1001 -H 'X-API-Version: 2.0.0'
# 查看映射
curl -s http://127.0.0.1:8080/actuator/mappings | jq .
# 查看健康
curl -fsS http://127.0.0.1:8080/actuator/health
# 运行 Web 层测试
mvn -q -Dtest=OrderControllerTest test十三、上线检查清单
[ ] Controller 路径、HTTP 方法、版本策略明确
[ ] 废弃版本是否返回 `Deprecation` / `Sunset` / `Link` 头,是否有下线日期和负责人
[ ] query / path / header / body 参数来源清楚
[ ] 请求体和方法参数校验已覆盖
[ ] 成功响应和错误响应结构稳定
[ ] 错误码有文档和归属人
[ ] 400 / 401 / 403 / 404 / 405 / 415 / 500 语义正确
[ ] traceId 在请求头、响应头、日志、错误响应中可串起来
[ ] Filter 和 Interceptor 职责没有混乱
[ ] 鉴权没有散落在每个 Controller
[ ] 写接口有幂等策略
[ ] 超时、重试、分页上限、请求大小上限明确
[ ] MockMvc 或契约测试覆盖核心接口
[ ] Actuator mappings/env/loggers 不对公网暴露
[ ] 不在错误响应和日志里暴露堆栈、SQL、内网地址、密钥十四、官方文档入口
| 主题 | 官方文档 |
|---|---|
| Spring Framework MVC | Spring Web MVC |
| DispatcherServlet | DispatcherServlet |
| Annotated Controllers | Annotated Controllers |
| Request Mapping | Mapping Requests |
| Controller 方法参数 | Method Arguments |
| Controller 返回值 | Return Values |
| Validation | Validation |
| Error Responses | Error Responses |
| Filters | Filters |
| Interceptors | Interceptors |
| API Versioning | API Version |
| Spring Boot MVC 自动配置 | Spring MVC Auto-configuration |
| Spring Boot Web Starters | Build Systems |
| Spring Boot 应用配置项 | Common Application Properties |
| Spring Boot Actuator | Actuator Endpoints |
写接口最后要记住一句:Controller 不是业务代码的垃圾桶,它是协议边界。边界越清楚,后面的服务、数据库、缓存、消息队列才越不容易被错误请求拖下水。
