《SpringBoot4进阶实战》第8招 Web请求完整走一遍
# Owner 查询 JSON API:从 HTTP 到 Response 的完整调用链 ```text 旧方案:仅返回 Thymeleaf 视图,无标准 JSON API 冲突:前端/第三方需要结构化数据,但现有 Controller 只返回视图名 后果:无法复用业务逻辑,接口契约不清晰,错误响应格式不统一 ``` Petclinic 官方项目默认面向服务端渲染,OwnerController 的查询方法返回 ModelAndView 或视图名。 当团队需要给移动端、管理后台或开放平台提供 Owner 查询能力时,直接复用现有 Controller 会暴露 HTML 片段,而不是结构化 JSON。 我是老李,今天要带你把 Owner 查询改造成标准 JSON API,并且把 HTTP 请求从进入 Tomcat 到最终返回 Response 的每一层都追清楚。 为什么 Controller 里只写了一句 return owner,最终客户端却能收到一个完整的 HTTP JSON Response? 是继续在现有 Controller 上增加 @ResponseBody,还是单独建立 ApiController 并引入 DTO、校验和统一异常处理? 如果选错,会不会出现实体直接序列化导致循环引用、校验错误返回 500、或者 404 与业务异常格式不一致的问题? # 01、故事 ```text 周二 14:30 → 研发区 → 给运营后台提供 Owner 查询接口 → 前端催接口、现有代码只返回 HTML ``` 周二下午两点半,我坐在研发区,任务是给运营后台提供一个按 ID 查询 Owner 的 JSON 接口。 前端同事在群里催:“接口什么时候好?我现在拿到的全是 HTML 片段,根本没法解析。” 我打开 Petclinic 的 OwnerController,发现查询方法返回的是视图名,数据通过 Model 传给 Thymeleaf 渲染。 如果直接加 @ResponseBody 返回 Owner 实体,又担心宠物集合被序列化出一大堆嵌套对象,甚至可能出现循环引用。 > 午后工位催声急, > 旧日视图难解意。 > 欲把主人成数据, > 却愁嵌套无穷已。 # 02、问题 ```text 需求变化:服务端渲染 → 标准 JSON API 冲突:Controller 返回视图,实体直接序列化风险高 后果:接口不可用、错误格式混乱、前后端联调成本高 ``` 现有 OwnerController 的查询逻辑绑定视图渲染,无法直接满足 JSON 接口需求。 如果直接返回 Owner 实体,JPA 关联的 pets、visits 可能被 Jackson 深度序列化,导致响应体膨胀、性能下降,甚至双向关联引发循环引用。 同时,参数缺失、ID 不存在、业务校验失败等场景没有统一的错误结构,前端需要针对不同状态码和响应体写多套解析逻辑。 可验证的验收标准是:GET /api/owners/{id} 返回标准 JSON; 参数错误返回 400; 不存在返回 404; 业务异常返回 409; 未预期异常返回 500,且错误体符合 Problem Detail 规范。 > 视图转身成接口, > 实体裸奔隐患久。 > 错误若还无定式, > 联调终夜空白首。 # 03、原理 ```text HTTP → Tomcat → Filter → DispatcherServlet → HandlerMapping → HandlerAdapter → ArgumentResolver → Validation → Controller → Service → MessageConverter → Response ``` Spring MVC 的核心是 DispatcherServlet,它把请求分发给 HandlerMapping 找到的处理器,再由 HandlerAdapter 完成参数解析、校验、调用和返回值处理 。 Controller 方法返回的 Owner 对象并不是 HTTP 响应本身,它只是返回值。 真正把它变成 JSON 的是 HttpMessageConverter,Spring Boot 默认配置了 MappingJackson2HttpMessageConverter。 一个反直觉判断是:Controller 里那句 return owner 并没有“发送响应”,它只是把对象交给框架,框架在方法返回后才开始序列化和写响应。 另一个反直觉判断是:@Valid 校验失败抛出的 MethodArgumentNotValidException 如果不在统一异常处理中捕获,默认会变成 500,而不是 400。 > 一句返回非终点, > 框架接力方成全。 > 校验若无人接住, > 五百错把四百演。 # 04、架构 ```text 输入:GET /api/owners/{id} 本模块:ApiController → OwnerRequest/Response DTO → Validation → OwnerService → ExceptionHandler → ProblemDetail 输出:标准 JSON + 正确 HTTP Status 边界:不直接暴露 JPA 实体,不把业务异常泄漏到客户端 ``` 本模块分为四层:Controller 负责接收 HTTP 请求并调用 Service; DTO 负责请求和响应契约; Service 复用现有业务逻辑; ExceptionHandler 负责把异常映射为 Problem Detail。 Controller 不直接返回 Owner 实体,而是返回 OwnerResponse DTO,这样可以把 JPA 关联关系隔离在服务层内部。 统一异常处理放在 @RestControllerAdvice 中,分别处理参数校验失败、资源不存在、业务冲突和未预期异常。 收益是接口契约清晰、错误格式统一、实体演化不影响外部 API。 代价是增加 DTO 映射代码,且需要明确哪些异常映射到哪些 HTTP 状态码。 适用条件是系统需要对外提供 JSON API,或前后端分离后需要稳定契约。 > 实体深藏服务内, > 契约只露薄薄层。 > 异常各有归处去, > 状态分明不误人。 # 05、第一版实战 ```text 环境:Spring Boot 4 + Java 21 + Maven 配置:pom.xml 引入 spring-boot-starter-web、spring-boot-starter-validation、spring-boot-starter-data-jpa 代码:OwnerResponse、OwnerRequest、ApiOwnerController、GlobalExceptionHandler 启动:mvn spring-boot:run 验证:curl GET /api/owners/1 V1:返回 OwnerResponse JSON,但错误处理不完整 ``` 先确认 pom.xml 包含 Web、Validation 和 JPA 依赖。 ```xml org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-validation org.springframework.boot spring-boot-starter-data-jpa ``` 创建 OwnerResponse DTO,只暴露必要字段。 ```java public record OwnerResponse( Long id, String firstName, String lastName, String address, String city, String telephone ) { } ``` 创建 ApiOwnerController,返回 DTO 而不是实体。 ```java @RestController @RequestMapping("/api/owners") public class ApiOwnerController { private final OwnerService ownerService; public ApiOwnerController(OwnerService ownerService) { this.ownerService = ownerService; } @GetMapping("/{id}") public OwnerResponse getOwner(@PathVariable Long id) { Owner owner = ownerService.findOwnerById(id); return new OwnerResponse( owner.getId(), owner.getFirstName(), owner.getLastName(), owner.getAddress(), owner.getCity(), owner.getTelephone() ); } } ``` 启动应用。 ```bash mvn spring-boot:run ``` 验证正常查询。 ```bash curl -i http://localhost:8080/api/owners/1 ``` 示例输出: ```text HTTP/1.1 200 Content-Type: application/json {"id":1,"firstName":"George","lastName":"Franklin","address":"110 W. Liberty St.","city":"Madison","telephone":"6085551023"} ``` V1 成功返回 JSON,但此时如果传入非数字 ID,会触发 500;如果 ID 不存在,也会因为 Service 抛异常而返回 500。 > 初版接口已成形, > 正常路径走得通。 > 只是错误无人管, > 五百乱象隐其中。 # 06、排查 ```text 诊断树 ├── 参数类型错误 → 怀疑 MethodArgumentTypeMismatchException → 检查默认异常处理 → 证据:返回 500 → 根因:未捕获该异常 ├── ID 不存在 → 怀疑 Service 抛 NoSuchElementException → 检查响应 → 证据:返回 500 → 根因:未映射为 404 └── 业务校验失败 → 怀疑 Service 抛 IllegalStateException → 检查响应 → 证据:返回 500 → 根因:未映射为 409 ``` 第一个问题是传入非数字 ID。 ```bash curl -i http://localhost:8080/api/owners/abc ``` 示例输出: ```text HTTP/1.1 500 {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/api/owners/abc"} ``` 怀疑是 MethodArgumentTypeMismatchException 没有被处理。 检查日志,看到异常堆栈: ```text Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long' ``` 根因是 Spring 默认把类型转换失败当作未预期异常,返回 500。 第二个问题是 ID 不存在。 ```bash curl -i http://localhost:8080/api/owners/9999 ``` 示例输出: ```text HTTP/1.1 500 {"timestamp":"...","status":500,"error":"Internal Server Error","path":"/api/owners/9999"} ``` 怀疑是 Service 抛出的 NoSuchElementException 没有被映射。 检查 Service 实现,发现 findOwnerById 内部使用 Optional.orElseThrow()。 根因是异常直接冒泡到容器,被默认 500 处理。 **错误尝试:** 我一开始在 Controller 里用 try-catch 捕获所有异常并手动构造 ResponseEntity,结果每个方法都重复大量样板代码,而且状态码散落各处,难以统一维护。 > 参数错时五百现, > 资源不在也同面。 > 若在方法里捕尽, > 样板成灾更难辨。 # 07、优化 ```text V1:正常路径可用,错误路径全部 500 证据:curl 参数错误、不存在 ID 均返回 500 V2:引入 @RestControllerAdvice + ProblemDetail,统一映射 400/404/409/500 ``` 创建 GlobalExceptionHandler,集中处理四类异常。 ```java @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ProblemDetail handleTypeMismatch(MethodArgumentTypeMismatchException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.BAD_REQUEST, "参数类型错误: " + ex.getName() ); pd.setTitle("Bad Request"); return pd; } @ExceptionHandler(NoSuchElementException.class) public ProblemDetail handleNotFound(NoSuchElementException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.NOT_FOUND, "Owner 不存在" ); pd.setTitle("Not Found"); return pd; } @ExceptionHandler(IllegalStateException.class) public ProblemDetail handleConflict(IllegalStateException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.CONFLICT, ex.getMessage() ); pd.setTitle("Conflict"); return pd; } @ExceptionHandler(Exception.class) public ProblemDetail handleGeneric(Exception ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.INTERNAL_SERVER_ERROR, "服务器内部错误" ); pd.setTitle("Internal Server Error"); return pd; } } ``` 同时让 Service 在业务校验失败时抛出 IllegalStateException,而不是返回 null 或布尔值。 这样 Controller 保持简洁,所有错误路径都汇聚到 GlobalExceptionHandler。 > 异常汇聚一处管, > 状态分明各有安。 > 四百四百零九, > 五百不再乱纠缠。 # 08、演进 ```text V2:统一异常处理 + Problem Detail 能力:标准 JSON 契约、稳定错误格式、可扩展异常映射 遗留:校验异常尚未覆盖,请求体校验需后续补充 ``` 用 V1 同一组请求验证 V2。 正常查询: ```bash curl -i http://localhost:8080/api/owners/1 ``` 示例输出: ```text HTTP/1.1 200 Content-Type: application/json {"id":1,"firstName":"George","lastName":"Franklin","address":"110 W. Liberty St.","city":"Madison","telephone":"6085551023"} ``` 参数类型错误: ```bash curl -i http://localhost:8080/api/owners/abc ``` 示例输出: ```text HTTP/1.1 400 Content-Type: application/problem+json {"type":"about:blank","title":"Bad Request","status":400,"detail":"参数类型错误: id","instance":"/api/owners/abc"} ``` ID 不存在: ```bash curl -i http://localhost:8080/api/owners/9999 ``` 示例输出: ```text HTTP/1.1 404 Content-Type: application/problem+json {"type":"about:blank","title":"Not Found","status":404,"detail":"Owner 不存在","instance":"/api/owners/9999"} ``` 收益是前端只需识别 Content-Type 和 status,就能统一解析错误。 代价是 GlobalExceptionHandler 需要持续维护异常映射,避免把所有异常都归为 500。 遗留问题是当前只有路径参数,还没有覆盖请求体校验,例如创建 Owner 时的 @Valid 校验失败。 > 同组请求再验证, > 状态分明各归位。 > 契约稳定前端喜, > 遗留校验待下回。 # 09、洞见 ```text 从现象到判断:Controller 返回对象 ≠ HTTP 响应;异常映射决定 API 成熟度 ``` ## Controller 的 return 只是返回值,不是响应 ```text Controller → return Owner → HandlerAdapter → MessageConverter → HTTP Response ``` 很多人以为 return 语句执行完响应就发出了,实际上 return 只是把对象交给框架。 真正的序列化和响应写入发生在方法返回之后,由 HttpMessageConverter 完成。 这解释了为什么 Controller 里只有一句 return owner,客户端却能收到完整 JSON。 ## 反直觉判断:默认异常处理会把很多客户端错误变成 500 ```text 参数类型错误 → 默认 500 → 实际应为 400 ``` Spring Boot 的默认错误处理对未捕获异常统一返回 500,即使根因是客户端传错参数。 如果不显式映射 MethodArgumentTypeMismatchException,前端会误以为服务器故障。 统一异常处理不是可选优化,而是 JSON API 的基本能力。 ## 反直觉判断:DTO 不是多余代码,而是契约边界 ```text 实体直接序列化 → 关联爆炸/循环引用 → DTO 隔离 → 契约稳定 ``` 直接返回 JPA 实体看似省事,但会把持久化细节泄漏到 API。 DTO 的映射代码虽然增加,但换来的是外部契约与内部模型解耦。 当实体增加字段或调整关联时,API 可以保持稳定。 ## Problem Detail 让错误从“状态码”升级为“可读文档” ```text 错误响应 → title + status + detail + instance → 前端可统一处理 ``` Problem Detail 规范让错误响应包含人类可读的 title、机器可读的 status、具体 detail 和发生路径 instance。 前端可以只写一个错误解析器,而不需要为每个接口单独适配。 > 返回非响应须明辨, > 异常映射定成熟。 > 契约边界靠 DTO, > 错误文档解百忧。 # 10、系统落地 ```text 入口:GET /api/owners/{id} 服务:ApiOwnerController → OwnerService → OwnerRepository 本模块:DTO 映射、统一异常处理、Problem Detail 存储:H2/MySQL 中的 owners 表 未实现:请求体校验、创建/更新 API、分页查询 ``` 原来 Petclinic 只有服务端渲染的 Owner 查询,没有标准 JSON API。 本篇增加了 ApiOwnerController、OwnerResponse DTO 和 GlobalExceptionHandler,使系统具备对外提供 Owner 查询 JSON 接口的能力。 现在前端可以通过 GET /api/owners/{id} 获取结构化数据,并且错误响应统一为 Problem Detail 格式。 下一篇需要补充创建和更新 Owner 的 JSON API,并引入请求体 DTO 与 Bean Validation。 官方文档可参考 Spring 官网 https://spring.io 和 Petclinic 仓库 https://github.com/spring-projects/spring-petclin ic 。 > 旧日视图今成 API, > 查询一路到存储。 > 错误统一契约稳, > 创建更新待下章。 # 11、小结 ```text 三问三答:return 如何变响应;是否新建 ApiController;选错后果 ``` 第一问:Controller 里只写了一句 return owner,为什么最终能变成完整 HTTP JSON Response? 因为 return 只是把对象交给 HandlerAdapter,后续由 MappingJackson2HttpMessageConverter 序列化为 JSON,并写入 HTTP 响应体。 第二问:是继续在现有 Controller 上增加 @ResponseBody,还是单独建立 ApiController? 单独建立 ApiController 更合适,因为服务端渲染和 JSON API 的返回类型、异常策略、契约要求不同,分离后各自演化更清晰。 第三问:如果选错会怎样? 如果直接返回实体会导致关联爆炸或循环引用;如果不做统一异常处理,参数错误和 404 都会变成 500,前端无法区分客户端错误和服务端故障。 当前状态是 Owner 查询 JSON API 已可用,错误响应符合 Problem Detail,但创建、更新和请求体校验尚未实现。 > 一句返回非终点, > 框架接力方成全。 > 分离接口避旧债, > 统一异常保平安。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:为什么 @RestController 的方法返回对象后,客户端能收到 JSON? 因为 @RestController 组合了 @ResponseBody,HandlerAdapter 在方法返回后调用 HttpMessageConverter 将返回值序列化为 JSON,再写入 HTTP 响应体。 ## 实战题:为 Petclinic 增加 GET /api/owners?lastName=xxx 的查询接口,返回 List,并处理空结果。 创建 ApiOwnerController 中的查询方法,调用 OwnerService 按 lastName 查询,空结果返回空列表和 200,而不是 404。 ## 排障题:调用 GET /api/owners/abc 返回 500,但日志显示 MethodArgumentTypeMismatchException,如何修复? 在 GlobalExceptionHandler 中增加 @ExceptionHandler(MethodArgumentTypeMismatchException.class),返回 400 和 P roblemDetail,而不是让它冒泡到默认 500 处理。 ## 架构判断题:如果前端要求返回 Owner 的所有 pets 和 visits,是否应该直接返回 Owner 实体? 不应该。应该为这个需求单独设计包含 pets 和 visits 的 DTO,避免实体直接序列化带来的关联爆炸和循环引用,同时保持 API 契约稳定。 > 理解框架接力链, > 实战查询列表添。 > 排障须看异常类, > 架构判断守边界。 # 13、升华 ```text 工程结论:JSON API 的成熟度取决于契约、校验和错误处理,而不是能否返回 JSON ``` 能返回 JSON 只是起点,真正的工程能力在于稳定契约、明确校验和统一错误处理。 Controller 的 return 只是调用链中的一个环节,理解从 HTTP 到 Response 的完整路径,才能在排障时快速定位问题。 下一篇将引入请求体 DTO 和 Bean Validation,把创建 Owner 的 JSON API 落地,并覆盖校验失败的错误映射。 > 能返 JSON 非终点, > 契约校验方为全。 > 调用链中明因果, > 排障定位不茫然。 **系列项目:** Spring 官方 Petclinic 演进为生产级 Spring Boot 4 Java 应用 **已完成:** 环境搭建、启动机制、依赖与自动配置、配置体系、Web 基础、安全、数据、事务、远程调用、数据库迁移、缓存、并发、日志、测试、Testcontainers、Docker Compose、Actuator、可观测、自定义自动配置、容器构建、生产上线 **本篇:** Owner 查询标准 JSON API、DTO、统一异常处理、Problem Detail、HTTP 状态码映射 **下一篇:** Owner 创建与更新 JSON API、请求体 DTO、Bean Validation、校验错误映射