[译] DTO模式

本文翻译自 The DTO Pattern (Data Transfer Object)

概述

在本文中,我们将讨论 DTO模式,包括它是什么,何时去使用它,以及如何正确的使用它。

介绍

DTO模式首次被提出来是在 Martin Fowler 的 EAA 一书中,DTOs 或 Data Transfer Objects 是一种在进程中传递数据的对象,用来减少方法的调用。

根据 Martin Fowler 的解释,该模式的主要目的是通过在一次调用中批量处理多个命令参数,从而减少请求服务器的次数,进而减少远程操作的网络开销。

该模式的另一个好处是对序列化的逻辑(序列化是指将对象的结构和数据转换为特定模式用来存储或者传输的机制)进行了封装,提供了单一的变化点去修改序列化的逻辑。它还可以将领域模型与表现层解耦,允许两者独立变化而不互相影响。

如何使用

DTOs 通常是作为 POJO 创建的。两者都是扁平的数据结构,不包含业务逻辑,只包含存储、访问器和序列化或解析有关的方法。

DTOs 数据通常是通过表现层或门面层的映射组件,从 领域模型 映射产生的。

下图说明了组件之间的联系:

dto模式

何时使用

在远程调用的系统中很有用,因为 DTOs 有助于减少远程调用的次数。

当领域模型由很多对象组成,并且展示模型需要一次性使用到所有数据时,DTOs 也很有帮助,同时甚至可以减少客户端与服务端之间的请求次数。

使用 DTOs,我们可以在不影响领域设计的情况下,在领域模型中建立不同的视图,比如根据客户需求创建同一领域的不同表现形式,为解决复杂问题提供了灵活性。

使用示例

我们将使用一个简单程序来演示这种模式,其中包含两个主要领域模型–用户和角色,两个功能例子–用户查询和创建用户。

DTO vs Domain

领域模型定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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
}
}
1
2
3
4
5
6
7
public class Role {

private String id;
private String name;

// Constructors, getters and setters
}

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

此刻,重点关注 DTO 呈现的是客户端请求或接收的数据模型。

因此,这些微小的差异是可能为了将请求合并后发送到服务器,也可能是为了优化客户端的响应。

1
2
3
4
5
6
public class UserDTO {
private String name;
private List<String> roles;

// standard getters and setters
}

上面的 DTO 只向客户端提供用户有关信息,例如出于安全考虑不返回密码。

下面的 DTO 则是组合了创建用户所需的数据,并在一个请求中发送给服务器,优化了请求交互:

1
2
3
4
5
6
7
8
public class UserCreationDTO {

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

// standard getters and setters
}

请求交互

接下来,使用映射器将数据在两边转换传输,这通常在表现层上使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@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());
}

}

最后,我们需要转换数据模型的映射组件,确保 DTO 和领域模型不需要互相了解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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<>());
}
}

常见错误

虽然 DTO模式 是一种简单的设计模式,但我们在使用这种模式时通常会犯一些错误。

第一个错误就是在每一个场合都创建不同的 DTOs,这会导致我们需要维护的类和映射器数量增加,因此尽量保持它们的的简洁,同时评估增加或重用现有的 DTO 的利弊。

同时我们也要避免在多个场合中使用同一个 DTO,因为这种做法可能会导致 DTO 中许多属性经常都用不上。

另一个错误就是在 DTO 中添加业务逻辑,因为该模式的目的是为了优化数据结构和数据传输,因此,所有的业务逻辑应该放在领域层中。

最后,我们还有种叫 LocalDTOs即使用 DTOs 跨层传递数据,但问题还是在于维护映射的成本。

支持这种 LocalDTOs 用法最常见的论据之一是对领域模型的封装,因为这里的问题是领域模型与持久化模型的耦合,而通过这种方法解耦,几乎可以规避暴露领域模型的风险。

而其他模式也可以达到类似的结果,但它们通常用于更复杂的场景,如CQRSData MappersCommandQuerySeparation 等。

总结

在本文中,我们看到了 DTO模式 的定义,和它为什么存在以及如何实现它。

同时,我们还看到了使用 DTO模式 中一些常见错误和避免的方法。