1. 概述

在构建 Spring REST API 时,一个常见的架构实践是:内部使用 JPA 实体(Entity),对外暴露 DTO(Data Transfer Object)。这样做的好处包括:

✅ 隐藏数据库细节
✅ 避免序列化问题(如懒加载)
✅ 更灵活地控制响应结构
✅ 提升安全性(不暴露敏感字段)

本文重点讨论如何高效实现 Entity ↔ DTO 的双向转换,并借助 ModelMapper 库避免手写大量 setter/getter 映射代码,踩坑少、开发快。


2. ModelMapper 简介与配置

我们选择 ModelMapper 作为核心映射工具。它基于约定优于配置的原则,能自动匹配字段名相似的属性,简单粗暴有效。

添加依赖

<dependency>
    <groupId>org.modelmapper</groupId>
    <artifactId>modelmapper</artifactId>
    <version>3.2.0</version>
</dependency>

✅ 建议定期查看 Maven Central 获取最新版本

注册为 Spring Bean

@Bean
public ModelMapper modelMapper() {
    return new ModelMapper();
}

注入后即可在 Service 或 Controller 中直接使用。


3. 定义 DTO:PostDto

来看一个典型的 PostDto 示例:

public class PostDto {
    private static final SimpleDateFormat dateFormat
      = new SimpleDateFormat("yyyy-MM-dd HH:mm");

    private Long id;
    private String title;
    private String url;
    private String date;
    private UserDto user;

    // 将字符串日期转为 Date(按用户时区)
    public Date getSubmissionDateConverted(String timezone) throws ParseException {
        dateFormat.setTimeZone(TimeZone.getTimeZone(timezone));
        return dateFormat.parse(this.date);
    }

    // 将 Date 格式化为字符串(按用户时区)
    public void setSubmissionDate(Date date, String timezone) {
        dateFormat.setTimeZone(TimeZone.getTimeZone(timezone));
        this.date = dateFormat.format(date);
    }

    // standard getters and setters
}

⚠️ 注意两个自定义方法的作用:

  • getSubmissionDateConverted():接收客户端传来的字符串时间,在服务端转换为 Date 类型用于持久化
  • setSubmissionDate():将实体中的 Date 按当前用户时区格式化为字符串,写入 DTO 返回给前端

这解决了前后端时区不一致的问题,属于常见踩坑点。


4. 服务层:操作实体而非 DTO

服务层应始终面向领域模型(即 Entity),保持业务逻辑清晰独立:

public List<Post> getPostsList(int page, int size, String sortDir, String sort) {
    PageRequest pageReq = PageRequest.of(page, size, Sort.Direction.fromString(sortDir), sort);
    Page<Post> posts = postRepository.findByUser(userService.getCurrentUser(), pageReq);
    return posts.getContent();
}

可以看到,postService 返回的是 List<Post> 而非 PostDto,符合分层设计原则。


5. 控制器层:完成转换的核心位置

Controller 是 DTO 与 Entity 转换发生的主战场。以下是典型的 REST 接口实现:

@Controller
class PostRestController {

    @Autowired
    private IPostService postService;

    @Autowired
    private IUserService userService;

    @Autowired
    private ModelMapper modelMapper;

    @GetMapping
    @ResponseBody
    public List<PostDto> getPosts(...) {
        List<Post> posts = postService.getPostsList(page, size, sortDir, sort);
        return posts.stream()
          .map(this::convertToDto)
          .collect(Collectors.toList());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    @ResponseBody
    public PostDto createPost(@RequestBody PostDto postDto) {
        Post post = convertToEntity(postDto);
        Post postCreated = postService.createPost(post);
        return convertToDto(postCreated);
    }

    @GetMapping(value = "/{id}")
    @ResponseBody
    public PostDto getPost(@PathVariable("id") Long id) {
        return convertToDto(postService.getPostById(id));
    }

    @PutMapping(value = "/{id}")
    @ResponseStatus(HttpStatus.OK)
    public void updatePost(@PathVariable("id") Long id, @RequestBody PostDto postDto) {
        if (!Objects.equals(id, postDto.getId())) {
            throw new IllegalArgumentException("IDs don't match");
        }
        Post post = convertToEntity(postDto);
        postService.updatePost(post);
    }
}

关键点:

  • 所有 @RequestBody 和响应对象均为 DTO
  • CRUD 操作均通过 convertToEntity / convertToDto 方法完成转换

6. 转换逻辑实现

Entity → DTO

private PostDto convertToDto(Post post) {
    PostDto postDto = modelMapper.map(post, PostDto.class);
    postDto.setSubmissionData(post.getSubmissionDate(), 
        userService.getCurrentUser().getPreference().getTimezone());
    return postDto;
}

✅ 使用 modelMapper.map() 自动映射基础字段
⚠️ 手动处理日期格式化(按用户时区)

DTO → Entity

private Post convertToEntity(PostDto postDto) throws ParseException {
    Post post = modelMapper.map(postDto, Post.class);
    post.setSubmissionDate(postDto.getSubmissionDateConverted(
      userService.getCurrentUser().getPreference().getTimezone()));

    if (postDto.getId() != null) {
        Post oldPost = postService.getPostById(postDto.getId());
        post.setRedditID(oldPost.getRedditID());
        post.setSent(oldPost.isSent());
    }
    return post;
}

⚠️ 特别注意更新场景下的处理:

  • 已有字段(如 redditID, sent)不应由客户端修改
  • 从数据库查出旧数据,手动补回这些只读字段

否则可能导致数据篡改风险,属于安全踩坑点。


7. 单元测试:验证映射正确性

虽然 ModelMapper 很智能,但关键映射仍需测试兜底:

public class PostDtoUnitTest {

    private ModelMapper modelMapper = new ModelMapper();

    @Test
    public void whenConvertPostEntityToPostDto_thenCorrect() {
        Post post = new Post();
        post.setId(1L);
        post.setTitle("example");
        post.setUrl("www.test.com");

        PostDto postDto = modelMapper.map(post, PostDto.class);
        assertEquals(post.getId(), postDto.getId());
        assertEquals(post.getTitle(), postDto.getTitle());
        assertEquals(post.getUrl(), postDto.getUrl());
    }

    @Test
    public void whenConvertPostDtoToPostEntity_thenCorrect() {
        PostDto postDto = new PostDto();
        postDto.setId(1L);
        postDto.setTitle("example");
        postDto.setUrl("www.test.com");

        Post post = modelMapper.map(postDto, Post.class);
        assertEquals(postDto.getId(), post.getId());
        assertEquals(postDto.getTitle(), post.getTitle());
        assertEquals(postDto.getUrl(), post.getUrl());
    }
}

✅ 测试覆盖基本字段映射
✅ 可扩展测试嵌套对象、日期格式等复杂情况


8. 总结

通过引入 ModelMapper,我们实现了:

✅ 实体与 DTO 的自动映射,减少样板代码
✅ 在 Controller 层集中处理转换逻辑,职责清晰
✅ 支持自定义转换规则(如时区处理)
✅ 易于测试和维护

📌 最佳实践建议:

  • ✅ 所有外部接口统一使用 DTO
  • ✅ Service 层专注 Entity 操作
  • ✅ 转换逻辑集中在 Controller 或专门的 Mapper 类中
  • ✅ 对敏感/只读字段做特殊保护,防止被客户端覆盖

完整示例代码已托管至 GitHub:https://github.com/eugenp/tutorials/tree/master/spring-boot-rest


原始标题:Entity To DTO Conversion for a Spring REST API

« 上一篇: Baeldung每周评论34
» 下一篇: Baeldung周报35