尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

Spring Boot 3 REST API 工程化实践:校验、异常、日志与测试

Spring Boot 3 REST API 工程化实践:校验、异常、日志与测试
📅 发布时间:2026/8/4 6:31:30

写出一个能返回 JSON 的接口并不难,难的是让几十个接口长期保持一致:参数错误要容易定位,业务异常不能泄露堆栈,日志能够串起一次请求,重构后还要有测试兜底。

本文以Java 17、Spring Boot 3.x为基础,搭建一套小而完整的 REST API 骨架。示例使用 Spring Boot 3 对应的jakarta.*包。

1. 先确定接口契约

业务响应可以统一外形,但不能抹掉 HTTP 状态码的语义。例如参数错误仍应返回400,资源不存在返回404,未知服务端错误返回500。

import java.time.Instant; ​ public record ApiResponse<T>( String code, String message, T data, String traceId, Instant timestamp ) { public static <T> ApiResponse<T> success(T data, String traceId) { return new ApiResponse<>("OK", "success", data, traceId, Instant.now()); } ​ public static <T> ApiResponse<T> failure( String code, String message, T data, String traceId) { return new ApiResponse<>(code, message, data, traceId, Instant.now()); } }

code是稳定的机器可读标识,message面向人类,traceId用于查日志。不要让前端根据可能变化的中文提示判断业务分支。

项目至少需要 Web、Validation 和 Test 三组依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</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-test</artifactId> <scope>test</scope> </dependency>

2. 在入口完成参数校验

请求对象只描述输入,返回对象只描述输出,避免把数据库实体直接暴露给 API。

import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; ​ public record CreateUserRequest( @NotBlank(message = "name must not be blank") @Size(max = 50, message = "name length must be <= 50") String name, ​ @NotBlank(message = "email must not be blank") @Email(message = "email format is invalid") String email, ​ @NotNull(message = "age must not be null") @Min(value = 18, message = "age must be >= 18") @Max(value = 120, message = "age must be <= 120") Integer age ) {} ​ public record UserView(Long id, String name, String email, Integer age) {}

控制器只负责协议转换。@Valid触发请求体校验,业务规则则留在 Service 中。

import jakarta.validation.Valid; import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; ​ @RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; ​ public UserController(UserService userService) { this.userService = userService; } ​ @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse<UserView> create(@Valid @RequestBody CreateUserRequest request) { UserView user = userService.create(request); return ApiResponse.success(user, MDC.get("traceId")); } }

Bean Validation 只判断字段是否合法。诸如“邮箱是否已注册”“库存是否充足”需要访问业务数据,应由 Service 判断并抛出业务异常。

3. 为业务错误建立稳定分类

public enum ErrorCode { INVALID_ARGUMENT, USER_NOT_FOUND, EMAIL_ALREADY_EXISTS, INTERNAL_ERROR } ​ public class BusinessException extends RuntimeException { private final ErrorCode code; ​ public BusinessException(ErrorCode code, String message) { super(message); this.code = code; } ​ public ErrorCode getCode() { return code; } }

错误码是对外契约。已经发布的含义不要随意复用;内部数据库异常也不要原样返回给调用方。

4. 用全局异常处理保持一致

@RestControllerAdvice把异常集中映射为状态码和响应体,控制器不需要重复try/catch。

import jakarta.validation.ConstraintViolationException; import java.util.LinkedHashMap; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; ​ @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); ​ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation( MethodArgumentNotValidException exception) { Map<String, String> fields = new LinkedHashMap<>(); exception.getBindingResult().getFieldErrors().forEach(error -> fields.putIfAbsent(error.getField(), error.getDefaultMessage())); ​ return ResponseEntity.badRequest().body(ApiResponse.failure( ErrorCode.INVALID_ARGUMENT.name(), "request validation failed", fields, traceId())); } ​ @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity<ApiResponse<Void>> handleConstraint( ConstraintViolationException exception) { return ResponseEntity.badRequest().body(ApiResponse.failure( ErrorCode.INVALID_ARGUMENT.name(), exception.getMessage(), null, traceId())); } ​ @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity<ApiResponse<Void>> handleUnreadableBody() { return ResponseEntity.badRequest().body(ApiResponse.failure( ErrorCode.INVALID_ARGUMENT.name(), "request body is malformed", null, traceId())); } ​ @ExceptionHandler(BusinessException.class) public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException exception) { HttpStatus status = exception.getCode() == ErrorCode.USER_NOT_FOUND ? HttpStatus.NOT_FOUND : HttpStatus.CONFLICT; return ResponseEntity.status(status).body(ApiResponse.failure( exception.getCode().name(), exception.getMessage(), null, traceId())); } ​ @ExceptionHandler(Exception.class) public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception) { log.error("Unhandled request exception", exception); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( ApiResponse.failure(ErrorCode.INTERNAL_ERROR.name(), "internal server error", null, traceId())); } ​ private String traceId() { return MDC.get("traceId"); } }

最后的兜底处理器必须记录完整异常,但响应只返回受控信息。把 SQL、类名或堆栈发给客户端既不稳定,也可能泄露系统细节。

5. 给每次请求添加 traceId

MDC 会把 traceId 带入同一线程产生的日志。由于线程池会复用线程,清理 MDC 是必需步骤。

import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.UUID; import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; ​ @Component @Order(Ordered.HIGHEST_PRECEDENCE) 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 = UUID.randomUUID().toString().replace("-", ""); MDC.put(TRACE_ID, traceId); response.setHeader("X-Trace-Id", traceId); try { filterChain.doFilter(request, response); } finally { MDC.remove(TRACE_ID); } } }

在 Logback pattern 中加入%X{traceId:-no-trace}即可打印该值。分布式系统中应优先接入 OpenTelemetry 等追踪方案,并遵循统一的 trace context,而不是让每个服务各自生成互不关联的 ID。

6. 用接口测试锁定行为

下面的测试验证三个关键契约:HTTP 状态、稳定错误码和字段级错误信息。

import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; 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.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; ​ @WebMvcTest(UserController.class) @Import({GlobalExceptionHandler.class, TraceIdFilter.class}) class UserControllerTest { @Autowired private MockMvc mockMvc; ​ @MockBean private UserService userService; ​ @Test void shouldRejectInvalidEmail() throws Exception { mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content(""" {"name":"Alice","email":"bad-email","age":20} """)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("INVALID_ARGUMENT")) .andExpect(jsonPath("$.data.email").value("email format is invalid")) .andExpect(jsonPath("$.traceId").isNotEmpty()); } ​ @Test void shouldCreateUser() throws Exception { when(userService.create(any())).thenReturn( new UserView(1L, "Alice", "alice@example.com", 20)); ​ mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content(""" {"name":"Alice","email":"alice@example.com","age":20} """)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.code").value("OK")) .andExpect(jsonPath("$.data.id").value(1)); } }

Service 还应单独测试业务分支;涉及数据库约束时,再增加包含真实数据库行为的集成测试。只依赖 MockMvc 无法发现 SQL、事务和数据库方言问题。

7. 上线前检查清单

  • HTTP 状态码与业务错误码各司其职,错误码含义稳定。

  • DTO 使用jakarta.validation,Controller 参数确实添加了@Valid。

  • 未知异常记录堆栈,但响应不暴露内部实现。

  • 日志包含 traceId,过滤器和异步任务都会清理 MDC。

  • API 测试覆盖成功、校验失败、业务冲突和未知异常。

  • 时间、分页、空值和金额等字段有明确的序列化约定。

总结

REST API 的工程质量来自一致的边界:DTO 负责输入约束,Service 负责业务规则,异常处理器负责协议映射,traceId 负责定位请求,测试负责锁定契约。这套骨架并不复杂,却能显著减少重复代码,也让后续增加鉴权、审计和链路追踪时有清晰的落点。

相关新闻

  • 软件结构图设计:变换分析、事务分析与混合流设计实战指南
  • DNS服务管理:从基础原理到企业级配置实践
  • 靠亏损攒经验的时代过去了:自营考核正全面提速交易成长

最新新闻

  • 自动驾驶迎来“第二春“:物理 AI 与端到端大模型重塑行业
  • RabbitMQ常见知识点总结
  • Omron C200PC-ISA03-1 印刷电路板
  • Unity网络通信中Curl error 60的根源分析与安全解决方案
  • 技术成长:从执行到思考的认知跃迁与工程实践
  • Wi-Fi天线原理与实战调优:从增益、极化到MIMO,彻底改善信号质量

日新闻

  • 5分钟快速搭建智能数字人:Live2D虚拟形象终极部署指南
  • 告别繁简字幕转换烦恼:这款开源工具让你一键搞定影视字幕处理 [特殊字符]
  • GPT-5.4传闻背后:大模型永久记忆与极限推理的技术演进与挑战

周新闻

  • 怀化母婴除甲醛公司测甲醛中心怎么选:康之居母婴除甲醛标准、流程、避坑指南 - 信誉隆金银铂奢回收
  • 三步打造你的终极音乐中心:foobox-cn网络电台功能完整指南
  • Lance湖仓格式:为多模态AI工作流设计的终极数据存储方案

月新闻

  • ClickHouse版本管理深度实战:4步构建零风险升级与回滚体系
  • Java 23 种设计模式:从踩坑到精通 | 番外:责任链模式 —— 物流审批流程实战
  • 华硕笔记本性能解放指南:G-Helper轻量级控制工具全面解析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号