Spring Boot投影模式下自定义实体关系建模的最佳实践
嘿,针对你这个需求,我来分享几个在Spring Boot里用DTO投影处理这类实体关联的最佳实践,结合你的Participation实体结构来拆解:
这是最省心的方式,适合只需要读取特定字段的场景。Spring Data会动态生成代理类,不用写完整的DTO实现。
首先定义一个投影接口,包含你需要的字段:
public interface CompetitionUserProjection { // 从关联的Competition实体取字段 String getCompetitionId(); String getCompetitionName(); // 假设你的Competition有name字段 // 从关联的User实体取字段 String getUserId(); String getUserUsername(); // 假设你的User有username字段 }
然后在ParticipationRepository里添加查询方法,直接返回这个接口的集合:
@Repository public interface ParticipationRepository extends JpaRepository<Participation, String> { // 按Team ID过滤,返回投影结果 List<CompetitionUserProjection> findByTeamId(String teamId); }
这种方式的优势是代码量极少,性能也不错,适合简单的读场景。
如果需要对数据做聚合、字段重命名或者自定义逻辑,自定义DTO类+构造器投影会更合适。
先写你的CompetitionDTO,包含竞赛信息和关联的用户列表:
public class CompetitionDTO { private String competitionId; private String competitionName; private List<UserDTO> users; // 单个竞赛-用户关联的构造器(用于查询映射) public CompetitionDTO(String competitionId, String competitionName, String userId, String username) { this.competitionId = competitionId; this.competitionName = competitionName; this.users = Collections.singletonList(new UserDTO(userId, username)); } // 内部静态的User子DTO public static class UserDTO { private String userId; private String username; public UserDTO(String userId, String username) { this.userId = userId; this.username = username; } // 别忘了加getter,Spring需要读取字段 public String getUserId() { return userId; } public String getUsername() { return username; } } // 用于聚合用户的方法 public void addUser(UserDTO user) { this.users.add(user); } // CompetitionDTO的getter public String getCompetitionId() { return competitionId; } public String getCompetitionName() { return competitionName; } public List<UserDTO> getUsers() { return users; } }
接着在Repository里用JPQL的NEW关键字指定构造器映射查询结果:
@Repository public interface ParticipationRepository extends JpaRepository<Participation, String> { @Query("SELECT NEW com.yourpackage.dto.CompetitionDTO(" + "p.competition.id, p.competition.name, p.user.id, p.user.username) " + "FROM Participation p WHERE p.team.id = :teamId") List<CompetitionDTO> findRawCompetitionUsersByTeamId(@Param("teamId") String teamId); }
这里返回的是每个竞赛-用户对的独立DTO,需要在Service层做聚合,把同一个竞赛下的用户合并到一个DTO的users列表里:
@Service public class ParticipationService { private final ParticipationRepository participationRepository; public ParticipationService(ParticipationRepository participationRepository) { this.participationRepository = participationRepository; } public List<CompetitionDTO> getCompetitionUsersByTeam(String teamId) { List<CompetitionDTO> rawDTOS = participationRepository.findRawCompetitionUsersByTeamId(teamId); // 用Map按竞赛ID聚合用户 Map<String, CompetitionDTO> competitionMap = new HashMap<>(); for (CompetitionDTO dto : rawDTOS) { competitionMap.computeIfAbsent(dto.getCompetitionId(), key -> { // 初始化一个空用户列表的竞赛DTO CompetitionDTO aggregatedDto = new CompetitionDTO(dto.getCompetitionId(), dto.getCompetitionName(), null, null); aggregatedDto.users = new ArrayList<>(); return aggregatedDto; }).addUser(dto.getUsers().get(0)); } return new ArrayList<>(competitionMap.values()); } }
这种方式灵活性拉满,适合需要对数据做二次处理的场景。
如果实体和DTO的字段很多,手动写构造器太繁琐,可以用ModelMapper或MapStruct这类映射工具,自动完成实体到DTO的转换。
以MapStruct为例,先定义映射接口:
@Mapper(componentModel = "spring") public interface CompetitionMapper { CompetitionMapper INSTANCE = Mappers.getMapper(CompetitionMapper.class); // 配置字段映射关系 @Mapping(source = "competition.id", target = "competitionId") @Mapping(source = "competition.name", target = "competitionName") @Mapping(source = "user.id", target = "users[0].userId") @Mapping(source = "user.username", target = "users[0].username") CompetitionDTO participationToCompetitionDTO(Participation participation); // 批量转换方法 List<CompetitionDTO> participationsToCompetitionDTOs(List<Participation> participations); }
然后在Service层查询出Participation列表,用MapStruct转换后再做聚合:
@Service public class ParticipationService { private final ParticipationRepository participationRepository; private final CompetitionMapper competitionMapper; public ParticipationService(ParticipationRepository participationRepository, CompetitionMapper competitionMapper) { this.participationRepository = participationRepository; this.competitionMapper = competitionMapper; } public List<CompetitionDTO> getCompetitionUsersByTeam(String teamId) { List<Participation> participations = participationRepository.findByTeamId(teamId); List<CompetitionDTO> rawDTOS = competitionMapper.participationsToCompetitionDTOs(participations); // 同样做聚合操作,和之前的逻辑一致 Map<String, CompetitionDTO> competitionMap = new HashMap<>(); for (CompetitionDTO dto : rawDTOS) { competitionMap.computeIfAbsent(dto.getCompetitionId(), key -> { CompetitionDTO aggregatedDto = new CompetitionDTO(dto.getCompetitionId(), dto.getCompetitionName(), null, null); aggregatedDto.users = new ArrayList<>(); return aggregatedDto; }).getUsers().addAll(dto.getUsers()); } return new ArrayList<>(competitionMap.values()); } }
这种方式能大幅减少手动映射的代码,维护性更好,适合字段较多的复杂场景。
如果不想在Service层做聚合,也可以用JPQL的GROUP BY在数据库层面直接聚合用户信息,不过这种方式适合用户信息比较简单的场景(比如只需要用户名):
@Repository public interface ParticipationRepository extends JpaRepository<Participation, String> { @Query("SELECT p.competition.id, p.competition.name, GROUP_CONCAT(p.user.username SEPARATOR ',') " + "FROM Participation p WHERE p.team.id = :teamId " + "GROUP BY p.competition.id, p.competition.name") List<Object[]> findAggregatedCompetitionUsersByTeamId(@Param("teamId") String teamId); }
然后在Service层把返回的Object[]转换成CompetitionDTO:
public List<CompetitionDTO> getCompetitionUsersByTeam(String teamId) { List<Object[]> results = participationRepository.findAggregatedCompetitionUsersByTeamId(teamId); return results.stream().map(result -> { String compId = (String) result[0]; String compName = (String) result[1]; String userNames = (String) result[2]; List<UserDTO> users = Arrays.stream(userNames.split(",")) .map(username -> new UserDTO(null, username)) .collect(Collectors.toList()); CompetitionDTO dto = new CompetitionDTO(compId, compName, null, null); dto.users = users; return dto; }).collect(Collectors.toList()); }
注意:如果需要多个用户字段(比如userId+username),GROUP_CONCAT就不太好用了,可能需要用原生SQL或者更复杂的查询逻辑。
选择建议
- 简单读场景:优先用基于接口的投影,代码最少;
- 需要自定义逻辑/聚合:用自定义DTO+构造器投影;
- 字段多、映射复杂:用MapStruct/ModelMapper;
- 复杂聚合需求:考虑数据库层面聚合。
内容的提问来源于stack exchange,提问作者Sebastian Ullrich

