1. 概述

在这个教程中,我们将讨论数据传输对象(DTO)模式,它的定义、何时使用以及如何正确应用。到结束时,我们将了解如何恰当地使用它。

2. 模式

数据传输对象(DTOs)是用于在不同进程间传递数据,以减少方法调用次数的对象。 这一模式最初由Martin Fowler在他的《企业应用架构模式》(EAA)一书中引入。

Fowler解释说,该模式的主要目的是通过将多个参数打包成单次调用来减少与服务器的往返操作,从而减少远程操作中的网络开销。

另一个好处是封装了序列化逻辑(将对象结构和数据转换为特定格式以便存储和传输的机制)。它为序列化的细微差别提供了一个单一的变更点,并解耦域模型与表现层,使得两者可以独立变化。

3. 如何使用?

DTO通常作为POJO(Plain Old Java Object)创建。它们是扁平的数据结构,不包含业务逻辑,仅包含存储、访问器,可能还包括与序列化或解析相关的函数。

数据从域模型映射到DTO,通常通过表示层或界面层的映射组件进行。

下图展示了各组件之间的交互:第4层组件

4. 何时使用?

当系统存在远程调用时,DTO非常有用,因为它有助于减少调用次数。当域模型由众多不同的对象组成,而表现模型需要一次性获取所有数据时,或者当需要减少客户端和服务器之间的往返请求时,DTO也有帮助。

通过DTO,我们可以根据客户端需求构建不同的视图, 这样我们可以在不改变领域设计的情况下,为同一领域创建优化后的其他表示形式。这种灵活性是解决复杂问题的强大工具。

5. 示例

为了演示模式的实现,我们将使用一个简单的应用程序,其中有两个主要的域模型,例如用户(User)和角色(Role)。为了专注于模式,我们来看两个功能示例——用户检索和新用户的创建。

5.1. DTO与域模型

以下是两个模型的定义:

public class User {

    private String id;
    private String name;
    private String password;
    private List<Role> roles;

    public User(String name, String password, List<Role> roles) {
        this.name = Objects.requireNonNull(name);
        this.password = this.encrypt(password);
        this.roles = Objects.requireNonNull(roles);
    }

    // Getters and Setters

   String encrypt(String password) {
       // encryption logic
   }
}
public class Role {

    private String id;
    private String name;

    // Constructors, getters and setters
}

现在让我们看看DTO,以便与域模型进行比较。

此时,重要的是注意到DTO代表从API客户端发送或接收的模型。

因此,小的差异可能是为了将发送到服务器的请求打包在一起,或者优化客户端的响应:

public class UserDTO {
    private String name;
    private List<String> roles;
    
    // standard getters and setters
}

上述DTO只提供对客户端相关的信息,例如出于安全原因隐藏密码。

下一个DTO将创建用户所需的所有数据打包在一起,通过单个请求发送到服务器,这优化了与API的交互:

public class UserCreationDTO {

    private String name;
    private String password;
    private List<String> roles;

    // standard getters and setters
}

5.2. 连接两方

接下来,连接这两个类的层面使用映射组件来在两者之间传递数据,反之亦然。这通常发生在表现层:

@RestController
@RequestMapping("/users")
class UserController {

    private UserService userService;
    private RoleService roleService;
    private Mapper mapper;

    // Constructor

    @GetMapping
    @ResponseBody
    public List<UserDTO> getUsers() {
        return userService.getAll()
          .stream()
          .map(mapper::toDto)
          .collect(toList());
    }


    @PostMapping
    @ResponseBody
    public UserIdDTO create(@RequestBody UserCreationDTO userDTO) {
        User user = mapper.toUser(userDTO);

        userDTO.getRoles()
          .stream()
          .map(role -> roleService.getOrCreate(role))
          .forEach(user::addRole);

        userService.save(user);

        return new UserIdDTO(user.getId());
    }

}

最后,我们有**Mapper组件负责数据传输,确保DTO和域模型彼此不需要了解对方**:

@Component
class Mapper {
    public UserDTO toDto(User user) {
        String name = user.getName();
        List<String> roles = user
          .getRoles()
          .stream()
          .map(Role::getName)
          .collect(toList());

        return new UserDTO(name, roles);
    }

    public User toUser(UserCreationDTO userDTO) {
        return new User(userDTO.getName(), userDTO.getPassword(), new ArrayList<>());
    }
}

6. 常见错误

尽管DTO模式是一个简单的设计模式,但在实施此技术的应用中,我们可能会犯一些错误。

第一个错误是在每个场合都创建不同的DTO。这会增加我们需要维护的类和映射器的数量。尽量保持简洁,并评估添加一个或重用现有DTO的权衡。

我们还应避免在一个类中尝试处理多种场景。这种做法可能导致大合同,其中许多属性经常未被使用。

另一个常见错误是在这些类中添加业务逻辑,这是不应该的。模式的目的是优化数据传输和合同结构,因此所有业务逻辑应在领域层实现。

最后,我们有所谓的本地DTOs(LocalDTOs),即DTO在不同领域间传递数据。 问题在于维护所有映射的成本。

支持这种做法的常见论点之一是封装域模型。但这里的问题是将域模型与持久模型耦合。通过解耦,几乎消除了暴露域模型的风险。

其他模式也达到类似的效果,但它们通常用于更复杂的场景,如命令查询分离(CQRS)数据映射器(Data Mapper)命令查询分离(CommandQuerySeparation)等。

7. 总结

在这篇文章中,我们了解了数据传输对象模式(DTO Pattern)的定义,它存在的原因以及如何实现。我们也看到了与其实现相关的常见错误及其避免方法。

如往常一样,示例代码可在GitHub上获取


« 上一篇: Gradle离线模式