返回

解决 IntelliJ 调试 JPA 项目 StackOverflowError

java

IntelliJ 调试 Spring Boot JPA 项目:解决表达式求值时的 StackOverflowError

在开发使用 Spring Boot 和 JPA 的应用程序时,开发者有时会在 IntelliJ IDEA 调试过程中遇到 StackOverflowError,该错误通常发生在使用 Evaluate Expression(表达式求值)功能尝试查看 JPA 查询结果的时候。这种问题常使开发者感到困惑,特别是当代码本身能够正常运行而只有调试时出现此错误的情况。

问题现象与初步分析

当尝试在 IntelliJ IDEA 中使用表达式求值功能来查看诸如 userRepository.getById(userId) 之类的方法的结果时,可能会遇到一个 StackOverflowError,伴随着长串的错误信息,提示出现了堆栈溢出。通常与对象之间的双向关联相关,更具体地说,常常发生于一个实体类中拥有相互引用的关系。
通过 Evaluate Expression,调试工具尝试序列化一个实体类,该实体类对象中,例如 User 实体,通过 List<Token> tokens; 拥有Token列表的引用关系。
同时每个 Token 对象也会有对应 @ManyToOne 的 User 对象的引用关系。
因此该工具会持续循环引用这两个实体对象,直至堆栈溢出。

相关代码示例

@Entity
@Table(name = "_user")
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class,property = "id")
public class User implements UserDetails {

  @Id
  @GeneratedValue
  private Integer id;

  // ... 其他字段 ...

  @JsonIgnore
  @OneToMany(mappedBy = "user")
  private List<Token> tokens;

  // ...
}

@Entity
public class Token {

  @Id
  @GeneratedValue
  private Integer id;

  // ... 其他字段 ...

  @ManyToOne
  @JoinColumn(name = "user_id")
  private User user;

  // ...
}

同时可能看到堆栈信息 com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion

解决方案

此问题的核心在于对象之间的双向引用,例如 User 实体中包含了一个 Token 列表,而 Token 实体又包含一个 User 引用。调试器在尝试展现这些对象的内容时陷入无限循环。应对此问题,可以采取以下策略。

1. 调整 IntelliJ IDEA 的调试器设置

IntelliJ IDEA 允许调整 Data Views 的一些参数。在实际应用时,可以通过更改 Data Views 的设置,从而限制其对于对象属性的获取,防止调试器递归获取过多内容导致 StackOverflowError

操作步骤
  1. 打开 IntelliJ IDEA 设置 (File > Settings on Windows/Linux, IntelliJ IDEA > Preferences on macOS)。
  2. 导航至 Build, Execution, Deployment > Debugger > Data Views > Java
  3. 勾选 Enable 'toString()' object view。并调整do not expand nodes 中对应 Start expanding...Stop expanding... 的数字,调大此范围或降低第一个范围数字可以一定程度减少对较深层次级的数据展现。避免调试工具递归层数过高引发StackOverflowError
  4. 应用更改。

2. 使用 @JsonManagedReference@JsonBackReference

在双向关系中使用 @JsonManagedReference@JsonBackReference 注解可以控制 Jackson 库如何处理对象的序列化。尽管这两个注解主要影响序列化的 JSON 输出,但有时候对调试过程也会有帮助。

代码示例
@Entity
public class User implements UserDetails {
    // ... 其他字段

    @JsonManagedReference
    @OneToMany(mappedBy = "user")
    private List<Token> tokens;

    // ...
}

@Entity
public class Token {
    // ... 其他字段

    @JsonBackReference
    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;

    // ...
}
说明
  • @JsonManagedReference 是前向引用,会被正常序列化。
  • @JsonBackReference 是后向引用,会被忽略,从而避免循环引用。

3. 使用 @JsonIdentityInfo

@JsonIdentityInfo 注解可以告诉 Jackson 在序列化过程中使用对象标识符来避免重复序列化对象。这是一个比较彻底的解决方案。

代码示例
@Entity
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class User implements UserDetails {
    // ...
}

@Entity
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "id")
public class Token {
    // ...
}
说明
  • 此注解可以添加到所有相关的实体类中。
  • generator 指定了生成对象标识符的策略。
  • property 指定了用作标识符的属性。

4. 创建 DTO 对象

在实际场景中,创建专门用于数据传输的 DTO(Data Transfer Object)对象是一个非常通用的解决方案。可以针对性地减少对象的数量、降低嵌套程度,规避递归调用带来的麻烦。

操作步骤
  1. 创建与实体类相对应的 DTO 类。
  2. 在 DTO 类中只包含必要的字段,并处理好关联关系,避免循环引用。
  3. 在服务层或控制器层中,将实体对象转换为 DTO 对象再进行返回或查看。
代码示例
public class UserDto {
    private Integer id;
    private String firstname;
    private String lastname;
    // ... 其他必要字段,不包含 Token 列表
    // 构造方法, getters and setters
}

在服务层使用:

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    public UserDto getUserById(int userId) {
        User user = userRepository.getById(userId)
                .orElseThrow(() -> new RuntimeException("User not found"));
        return new UserDto(user.getId(), user.getFirstname(), user.getLastname());
        // 可以使用 ModelMapperMapStruct 等库简化转换过程
    }
}
安全建议

使用 DTO还可以避免内部信息不经意泄露,提升API的安全性。

5. 更改JPA的查询方式

原始代码中定义的getById,可能触发不必要的一对多加载。
可选用findById()方法进行查询,或是在新方法中使用EntityGraph去按需获取必要的关联对象数据,进一步控制对象规模,减小问题出现的可能性。

代码示例
public interface UserRepository extends JpaRepository<User, Integer> {
    @EntityGraph(attributePaths = {"tokens"}) // 可以按需添加需要的关联属性
    Optional<User> findById(Integer id);
}

总结

遭遇 StackOverflowError 常常是实体间双向引用导致的问题。开发者需要合理地运用手段来阻止工具的深度递归获取数据。也可以选择其他途径实现调试目的,比如输出日志,进行单元测试,不依赖Evaluate Expression也可以达到对问题排查和验证的效果。
通过适当的调试设置,借助一些特殊注解和创建DTO等途径解决它,将大幅度降低解决问题的耗时,也能提升代码质量,防止数据泄漏,确保项目开发效率和软件品质。