项目详情已经查到数据,接口却在写 JSON 时失败。点开返回对象,项目里有任务列表,每条任务又拿着所属项目。只要 Jackson 继续展开任务的 project 属性,它就会回到刚刚处理过的项目。

这个例子适用于 Spring Boot 3.5、JDK 17 或 21,以及使用 Jackson 2.x 写 JSON 的 Spring MVC 接口。@RestController 的返回值会交给消息转换器写入响应体,所以方法执行完毕之后,仍然可能在序列化阶段报错。Spring MVC 响应体文档 · Spring Boot JSON 文档

顺着异常里的属性路径查

外层出现 HttpMessageNotWritableException 时,展开异常链,找 Jackson 写到哪个属性失败。JsonMappingException 可以带上属性名和数组下标,日志里常以 through reference chain 显示。Spring 消息写入异常源码 · Jackson 映射异常源码

项目和任务互相引用时,属性路径会反复出现同一段。

Project.tasks[0]
    → Task.project
    → Project.tasks[0]
    → Task.project
    → …

在返回对象的位置打断点,取出第一条任务,对照 task.getProject() == project。这个表达式为真时,说明它们确实指向同一个项目实例。多层关系也用同样的办法,顺着字段和下标检查实例。

如果日志提示 `Document nesting depth›,再确认数据是否重复走回同一对象。Jackson 2.16 起提供写入深度限制,2.19 源码中的默认上限是 1000,项目可以按自己的配置调整。一个层级很深的树也可能触发这项限制,检查时要把属性路径和对象引用放在一起看。Jackson 写入深度限制源码

栈里如果一直重复的是 toString()、equals() 或 hashCode(),就回到对应的方法调用处。比如日志打印实体时先触发了 toString(),排查点就在日志调用与实体方法。

把接口需要的字段放进响应 DTO

假设项目详情页只用项目 ID、名称和任务的 ID、标题。可以用 ProjectResponse 定义这份响应,再把查到的项目映射进去。

下面的类放在能访问项目领域类型的位置。示例中的 Project 提供 getId()、getName() 和 getTasks(),任务列表已经加载且始终有值,无任务时返回空列表。任务元素提供 getId() 和 getTitle()。

import java.util.List;

public record ProjectResponse(
        long id,
        String name,
        List<ProjectResponse.TaskItem> tasks
) {
    public record TaskItem(long id, String title) {}

    public static ProjectResponse from(Project project) {
        List<TaskItem> tasks = project.getTasks().stream()
                .map(task -> new TaskItem(task.getId(), task.getTitle()))
                .toList();

        return new ProjectResponse(project.getId(), project.getName(), tasks);
    }
}

让项目详情接口返回这个 DTO。用项目 42 和任务 7 作为示例,默认字段名下的响应可以长这样。

{
  "id": 42,
  "name": "演示项目",
  "tasks": [
    {
      "id": 7,
      "title": "整理文档"
    }
  ]
}

如果任务详情还需要显示项目名称,可以在任务响应里定义 ProjectRef(id, name),让这一层结束在项目摘要。两个接口各自按页面要用的字段组装。

任务数据需要额外查询时,在服务层显式取出本次响应要用的字段。列表请求尤其要留意每条记录是否触发一次关联查询。可以对照本站的关联加载排查步骤。MyBatis 一页数据查出几十条 SQL

已经用了引用注解的项目

Jackson 的 @JsonManagedReference 和 @JsonBackReference 是一对有方向的属性标记。用在这个模型时,可以将前者标在 Project.tasks,后者标在 Task.project,并使用匹配的名称,如 project-tasks。项目序列化时会展开任务列表,任务上的反向引用会从 JSON 中省略。Jackson 正向引用源码 · Jackson 反向引用源码

调整注解前,先查哪些接口也用这个任务类型。独立返回任务详情时,标了反向引用的 project 字段也会被省略。请求体反序列化如果复用同一类型,也要验证成对注解重建反向引用的行为。

用原来的接口验证响应

本地开发实例已经启动、项目 42 存在并且接口允许当前客户端访问时,可以用下面的 GET 请求检查状态和正文。端口、路径和项目 ID 按实际环境替换。

curl -i -sS --max-time 10 "http://127.0.0.1:8080/projects/42"

先确认响应体可以完整解析,再核对字段与层级。项目有任务时,检查 tasks[0] 下的字段。项目无任务时,检查 tasks 是否符合客户端约定,例如空数组 []。如果同时有任务详情接口,再确认它能按约定返回项目摘要。

服务端仍有消息写入异常时,回到同一次请求的属性路径,检查响应是否还包含实体、代理对象或其他会继续展开的字段。接口有统一异常处理时,也可以对照现有处理路径定位记录异常的位置。Spring Boot 全局异常处理