Spring Data JPA关联实体检索及DTO封装方案咨询
已配置的Hibernate实体关联
我在Spring Boot应用中用Hibernate配置了以下实体的关联关系:
Recipe实体
@Entity public class Recipe { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable=false, length=50) private String title; @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL) private List<RecipeIngredient> recipeIngredients = new ArrayList<>(); }
RecipeIngredient关联实体
@Entity public class RecipeIngredient { @EmbeddedId private RecipeIngredientId recipeIngredientId = new RecipeIngredientId(); @ManyToOne(optional = true, fetch = FetchType.LAZY) @MapsId("recipeId") @JoinColumn(name = "recipe_id", referencedColumnName = "id") private Recipe recipe; @ManyToOne(optional = true, fetch = FetchType.LAZY) @MapsId("ingredientId") @JoinColumn(name = "ingredient_id", referencedColumnName = "id") private Ingredient ingredient; }
Ingredient实体
@Entity public class Ingredient { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique=true, nullable=false, length=50) @EqualsAndHashCode.Include private String name; @OneToMany(mappedBy = "ingredient", cascade = CascadeType.ALL) private Set<RecipeIngredient> recipeIngredients = new HashSet<>(); }
需求与问题
现在需要实现:检索Recipe时同时获取其关联的所有Ingredient。我知道可以用Projection或者结合Hibernate特性+Java Stream处理关联数据,但不清楚具体实现方式。
我需要得到包含List<Ingredient>的Optional<Recipe>,或用如下DTO返回数据:
@Data public class ResponseDTO { private Long id; private String title; List<RecipeIngredient> ingredients; // getter, setter, constructor }
另外,我尝试过Projection方式,但检索到的数据会根据Recipe关联的Ingredient数量重复,想解决这个问题;同时也想知道如何用Java Stream填充DTO,使其包含指定Recipe及对应的Ingredient数据(需要Ingredient名称,不止ID)。
更新后的尝试代码
我编写了以下DTO,但不确定是否正确:
ResponseDTO
@Getter @Setter @NoArgsConstructor public class ResponseDTO { private Long id; private String title; List<IngredientDTO> ingredientDTOList; public ResponseDTO(Recipe recipe) { this.id = recipe.getId(); this.title = recipe.getTitle(); this.ingredientDTOList = recipe.getRecipeIngredients().stream() .map(ri -> new IngredientDTO(ri.getIngredient().getName())) .toList(); } }
IngredientDTO
@Getter @Setter public class IngredientDTO { private Long id; private String name; public IngredientDTO(String name) { this.name = name; } }
解决方案
一、解决Projection重复数据问题
Projection出现重复数据是因为关联查询返回了笛卡尔积,可通过以下方式解决:
添加
DISTINCT并修正实体的equals/hashCode
在JPQL查询中加入DISTINCT,同时确保Recipe实体的equals/hashCode仅基于ID:@Repository public interface RecipeRepository extends JpaRepository<Recipe, Long> { @Query("SELECT DISTINCT r FROM Recipe r LEFT JOIN FETCH r.recipeIngredients ri LEFT JOIN FETCH ri.ingredient WHERE r.id = :id") Optional<Recipe> findByIdWithIngredients(@Param("id") Long id); }给
Recipe实体添加注解:@Entity @EqualsAndHashCode(onlyExplicitlyIncluded = true) public class Recipe { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @EqualsAndHashCode.Include private Long id; // 其他字段不变 }这样Hibernate就能基于ID正确去重。
使用
DistinctRootEntityResultTransformer(旧版Hibernate)
对于Hibernate 5.x及更早版本,可在查询中设置结果转换器,但新版已推荐使用上述DISTINCT+实体equals/hashCode的方案。
二、正确用Java Stream填充DTO(修复你的代码)
你的DTO构造方法遗漏了Ingredient的ID,同时要注意懒加载问题:如果查询时未主动加载关联数据,会触发N+1查询甚至懒加载异常(非事务场景下)。
步骤1:确保查询时加载所有关联数据
通过Repository的查询语句主动加载recipeIngredients和关联的ingredient,避免懒加载。
步骤2:修正DTO构造方法,补充Ingredient ID
@Getter @Setter @NoArgsConstructor public class ResponseDTO { private Long id; private String title; List<IngredientDTO> ingredientDTOList; public ResponseDTO(Recipe recipe) { this.id = recipe.getId(); this.title = recipe.getTitle(); // 转换时同时获取Ingredient的ID和名称 this.ingredientDTOList = recipe.getRecipeIngredients().stream() .map(recipeIngredient -> { Ingredient ingredient = recipeIngredient.getIngredient(); return new IngredientDTO(ingredient.getId(), ingredient.getName()); }) .collect(Collectors.toList()); } } // 修正IngredientDTO的构造方法 @Getter @Setter public class IngredientDTO { private Long id; private String name; public IngredientDTO(Long id, String name) { this.id = id; this.name = name; } }
步骤3:业务层调用(确保在事务内)
@Service @Transactional public class RecipeService { private final RecipeRepository recipeRepository; public ResponseDTO getRecipeWithIngredients(Long recipeId) { return recipeRepository.findByIdWithIngredients(recipeId) .map(ResponseDTO::new) .orElseThrow(() -> new RuntimeException("Recipe not found")); } }
三、备选方案:直接用JPQL构造DTO
如果不想用Stream转换,可直接在JPQL中构造DTO,避免后续数据转换:
@Query("SELECT r.id, r.title, i.id, i.name FROM Recipe r LEFT JOIN r.recipeIngredients ri LEFT JOIN ri.ingredient i WHERE r.id = :id") List<Object[]> findRecipeIngredientData(@Param("id") Long id);
之后需要手动将查询结果分组组装成ResponseDTO,但这种方式不如Stream转换直观。
内容的提问来源于stack exchange,提问作者Jack

