You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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出现重复数据是因为关联查询返回了笛卡尔积,可通过以下方式解决:

  1. 添加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正确去重。

  2. 使用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.09 15:25:17