Note 10. Springboot 架构师思维:分层架构与领域对象模型 系统性剖析 PO、DTO、VO、BO、QO

Note 10. Springboot 架构师思维:分层架构与领域对象模型 系统性剖析 PO、DTO、VO、BO、QO

摘要:“代码能跑就行” 是初学者的终点,却是架构师的起点。本章,我们将告别将所有逻辑堆砌在 Controller 里的 “一把梭” 式开发,正式引入企业级项目开发的核心思想——分层架构。我们将深入探讨 Web、Service、Dao 各层的职责边界,并系统性地剖析 PO、DTO、VO、BO、QO 等领域对象模型在不同业务场景下的定义、流转与转换策略。通过本章学习,你将彻底理解为什么要 “多此一举” 地定义这么多对象,以及如何通过合理的分层设计构建一个清晰、健壮、可维护的后端应用架构。

本章学习路径

  1. 灾难现场:从一个真实的反例出发,直观感受 “不分层” 带来的维护噩梦。
  2. 分层理念:深入理解三层架构的设计哲学,掌握每一层的职责边界和禁止事项。
  3. 领域对象速览:建立对 PO、DTO、VO、QO、BO 的整体认知,理解它们的诞生背景。
  4. 场景一:简单 CRUD:从最常见的增删改查入手,掌握 PO、DTO、VO 的定义和转换。
  5. 场景二:复杂查询:引入 QO 对象,解决查询参数过多导致的接口膨胀问题。
  6. 场景三:复杂业务:通过订单业务案例,理解 BO 在封装复杂业务逻辑中的价值。
  7. 对象转换实战:深入讲解手动转换、BeanUtil 工具转换的各种技巧和注意事项。
  8. 完整流程演练:通过用户注册和订单创建两个完整案例,串联所有知识点。

10.1. 灾难现场:不分层的代码有多可怕?

在正式学习分层架构之前,让我们先来看一个真实项目中的 “灾难现场”。这是一个由初学者开发的用户管理系统,我们将通过这个反例来深刻理解分层的必要性。

10.1.1. 反例:Controller 中的 “万能方法”

文件路径src/main/java/com/example/demo/controller/BadUserController.java

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
package com.example.demo.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.web.bind.annotation.*;

import java.sql.Timestamp;
import java.util.*;

/**
* 反例:不分层的 Controller
* 这是一个典型的 "面条代码" 示例
*/
@RestController
@RequestMapping("/bad/users")
public class BadUserController {

@Autowired
private JdbcTemplate jdbcTemplate; // 直接注入底层数据访问工具

@Autowired
private JavaMailSender mailSender; // 直接注入邮件服务

/**
* 用户注册接口
* 问题:所有逻辑都堆砌在一个方法里
*/
@PostMapping("/register")
public Map<String, Object> register(@RequestBody Map<String, String> params) {
Map<String, Object> result = new HashMap<>();

// 1. 手动解析和校验参数
String username = params.get("username");
String password = params.get("password");
String email = params.get("email");

// 手动校验:用户名
if (username == null || username.trim().isEmpty()) {
result.put("code", 400);
result.put("message", "用户名不能为空");
return result;
}
if (username.length() < 4 || username.length() > 20) {
result.put("code", 400);
result.put("message", "用户名长度必须在4-20之间");
return result;
}

// 手动校验:密码
if (password == null || password.length() < 6) {
result.put("code", 400);
result.put("message", "密码长度不能少于6位");
return result;
}

// 手动校验:邮箱
if (email == null || !email.contains("@")) {
result.put("code", 400);
result.put("message", "邮箱格式不正确");
return result;
}

// 2. 检查用户名是否已存在(直接写 SQL)
String checkSql = "SELECT COUNT(*) FROM t_user WHERE username = ?";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class, username);
if (count != null && count > 0) {
result.put("code", 400);
result.put("message", "用户名已存在");
return result;
}

// 3. 插入用户数据(直接写 SQL)
String insertSql = "INSERT INTO t_user (username, password, email, status, create_time) VALUES (?, ?, ?, ?, ?)";
try {
jdbcTemplate.update(
insertSql,
username,
password, // 密码明文存储(严重的安全问题!)
email,
1, // 魔法数字:1 表示正常状态
new Timestamp(System.currentTimeMillis())
);
} catch (Exception e) {
result.put("code", 500);
result.put("message", "注册失败:" + e.getMessage()); // 暴露异常细节
return result;
}

// 4. 发送欢迎邮件(业务逻辑与 HTTP 处理混在一起)
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(email);
message.setSubject("欢迎注册");
message.setText("您好," + username + "!欢迎注册我们的系统。");
mailSender.send(message);
} catch (Exception e) {
// 邮件发送失败,但用户已经注册成功了,数据不一致!
System.out.println("邮件发送失败:" + e.getMessage());
}

result.put("code", 200);
result.put("message", "注册成功");
return result;
}

/**
* 用户列表查询接口
* 问题:同样的校验逻辑、SQL 拼接在另一个方法中重复出现
*/
@GetMapping
public Map<String, Object> listUsers(
@RequestParam(required = false) String username,
@RequestParam(required = false) Integer status,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize
) {
Map<String, Object> result = new HashMap<>();

// 拼接 SQL(SQL 注入风险!)
StringBuilder sql = new StringBuilder("SELECT * FROM t_user WHERE 1=1");
List<Object> params = new ArrayList<>();

if (username != null && !username.isEmpty()) {
sql.append(" AND username LIKE ?");
params.add("%" + username + "%");
}

if (status != null) {
sql.append(" AND status = ?");
params.add(status);
}

// 分页(手动计算偏移量)
int offset = (pageNum - 1) * pageSize;
sql.append(" LIMIT ? OFFSET ?");
params.add(pageSize);
params.add(offset);

// 执行查询
List<Map<String, Object>> users = jdbcTemplate.queryForList(sql.toString(), params.toArray());

// 手动处理敏感字段(不安全,容易遗漏)
for (Map<String, Object> user : users) {
user.remove("password"); // 删除密码字段

// 手动转换状态值
Integer userStatus = (Integer) user.get("status");
if (userStatus != null) {
user.put("statusText", userStatus == 1 ? "正常" : "禁用");
}
}

result.put("code", 200);
result.put("data", users);
return result;
}

/**
* 用户详情查询
* 问题:相同的逻辑又重复了一遍
*/
@GetMapping("/{id}")
public Map<String, Object> getUserById(@PathVariable Long id) {
Map<String, Object> result = new HashMap<>();

String sql = "SELECT * FROM t_user WHERE id = ?";
List<Map<String, Object>> users = jdbcTemplate.queryForList(sql, id);

if (users.isEmpty()) {
result.put("code", 404);
result.put("message", "用户不存在");
return result;
}

Map<String, Object> user = users.get(0);
user.remove("password"); // 又一次手动删除密码

// 又一次手动转换状态
Integer status = (Integer) user.get("status");
if (status != null) {
user.put("statusText", status == 1 ? "正常" : "禁用");
}

result.put("code", 200);
result.put("data", user);
return result;
}
}

10.1.2. 这段代码的十宗罪

让我们详细分析这段 “灾难代码” 的问题:

问题类型具体表现严重后果
职责混乱Controller 既处理 HTTP 请求,又执行业务逻辑,还直接操作数据库,甚至发送邮件违反单一职责原则,代码难以理解和维护
代码重复相同的校验逻辑、SQL 语句、数据转换在多个方法中重复出现修改一处需要改多处,容易遗漏,维护成本极高
无法复用业务逻辑与 HTTP 强耦合,其他模块(如定时任务、MQ 消费者)无法复用每个功能都要重写一遍,代码膨胀
事务失控用户注册成功但邮件发送失败,数据不一致缺乏事务管理,数据完整性无法保证
安全隐患密码明文存储、SQL 拼接(注入风险)、异常信息暴露极易被攻击,造成数据泄露
参数处理原始使用 Map<String, String> 接收参数,缺乏类型安全和自动校验运行时才能发现错误,调试困难
响应格式不统一成功和失败的响应结构不一致前端需要编写大量适配代码
魔法数字status = 1code = 400 等硬编码代码可读性差,难以维护
错误处理粗暴直接返回 e.getMessage(),暴露技术细节用户体验差,安全风险高
测试困难所有逻辑耦合在一起,无法单独测试某个业务规则测试覆盖率低,质量无法保证

10.1.3. 维护噩梦的具体场景

让我们通过几个真实的需求变更场景,来感受这种代码的维护之痛:

场景一:用户名长度规则从 4-20 改为 6-30

在不分层的代码中,你需要:

  1. 找到所有写了 username.length() < 4 || username.length() > 20 的地方
  2. 可能在 register 方法中改了,但忘记在 update 方法中改
  3. 甚至在 JavaScript 前端代码中也有这个校验,也要改
  4. 改完后没有集中的单元测试,只能靠手动点击测试

场景二:需要在多个地方创建用户(注册、管理员添加、批量导入)

在不分层的代码中,你需要:

  1. 复制 register 方法中的所有业务逻辑到新的方法中
  2. 三个方法中的校验逻辑、SQL 语句、邮件发送逻辑都是重复的
  3. 一旦需求变更(如增加短信验证),需要改三处代码

场景三:需要支持定时任务批量创建用户

在不分层的代码中,你会发现:

  1. 定时任务无法调用 Controller 的方法(Controller 依赖 HTTP 请求)
  2. 只能把核心逻辑再复制一遍到定时任务中
  3. 又产生了一份重复代码

场景四:需要记录用户注册日志

在不分层的代码中,你需要:

  1. register 方法中插入日志记录代码
  2. 这个方法已经有 100 多行了,再加代码会更加臃肿
  3. 日志记录失败可能会影响注册流程

10.1.4. 分层架构如何解决这些问题?

通过合理的分层,上述所有问题都能得到根本性的解决:

分层架构图表

分层后的优势

优势具体体现
职责清晰Controller 只处理 HTTP,Service 只处理业务,Dao 只处理数据
代码复用业务逻辑在 Service 中定义一次,可被多个 Controller、定时任务、MQ 调用
易于维护修改业务规则只需改 Service,不影响 Controller 和 Dao
便于测试Service 可以脱离 HTTP 环境进行单元测试
事务管理Service 层统一管理事务,保证数据一致性
安全可控敏感数据在 Service 层处理,不会暴露到 Controller

10.2. 分层架构的设计哲学

10.2.1. 标准三层模型详解

在企业级应用开发中,最广泛采用的是 三层架构(Three-tier Architecture),它将应用程序划分为表现层、业务逻辑层和数据访问层。

mvc 三层架构

10.2.2. 各层的职责边界

Web 层(表现层)

核心定位:HTTP 请求的 “翻译官” 和 “搬运工”。

文件组织

1
2
3
4
5
src/main/java/com/example/demo/
├── controller/ # 所有 Controller 类
│ ├── UserController.java
│ ├── OrderController.java
│ └── ProductController.java

职责清单

职责说明示例代码
接收请求处理 HTTP 请求,解析 URL、Header、Body@PostMapping("/users")
参数绑定将请求参数绑定到 DTO/QO 对象@RequestBody UserCreateDTO dto
参数校验触发 JSR-303 校验@Validated UserCreateDTO dto
调用 Service将 DTO 传递给 Service 层处理userService.createUser(dto)
数据转换将 Service 返回的数据转换为 VOUserVO vo = convert(user)
封装响应将 VO 封装成统一的 Result 格式return Result.success(vo)
异常处理由全局异常处理器统一处理无需在 Controller 中 try-catch

禁止行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 禁止直接注入 Mapper
@Autowired
private UserMapper userMapper;

// ❌ 禁止写 SQL
String sql = "SELECT * FROM t_user";

// ❌ 禁止写业务逻辑
if (user.getBalance() < order.getAmount()) {
// 复杂的计算逻辑
}

// ❌ 禁止管理事务
@Transactional // Controller 层不应该有这个注解

// ❌ 禁止调用外部服务
mailSender.send(message);

标准代码模板

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
37
38
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService; // 只注入 Service

/**
* 新增用户
*
* Controller 的职责:
* 1. 接收 HTTP POST 请求
* 2. 将 JSON 绑定到 UserCreateDTO
* 3. 触发 JSR-303 校验(@Validated
* 4. 调用 Service
* 5. 封装响应
*/
@PostMapping
public Result<Long> createUser(@RequestBody @Validated UserCreateDTO dto) {
// 直接调用 Service,不做任何业务逻辑处理
Long userId = userService.createUser(dto);
return Result.success(userId);
}

/**
* 查询用户详情
*
* Controller 的职责:
* 1. 接收 GET 请求和路径参数
* 2. 调用 Service
* 3. Service 已经返回了 VO,直接封装
*/
@GetMapping("/{id}")
public Result<UserVO> getUser(@PathVariable Long id) {
UserVO userVO = userService.getUserById(id);
return Result.success(userVO);
}
}

Service 层(业务逻辑层)

核心定位:业务规则的 “指挥中心” 和 “编排者”。

文件组织

1
2
3
4
5
6
7
src/main/java/com/example/demo/
├── service/
│ ├── UserService.java # 接口
│ ├── OrderService.java
│ └── impl/
│ ├── UserServiceImpl.java # 实现类
│ └── OrderServiceImpl.java

职责清单

职责说明示例代码
业务规则校验检查用户名是否已存在、库存是否充足等if (existsByUsername(name)) throw ...
数据转换DTO → PO、PO → VOUser po = convertToEntity(dto)
编排 Dao 操作调用多个 Mapper 完成一个业务流程userMapper.insert(), roleMapper.insert()
事务管理通过 @Transactional 保证数据一致性@Transactional(rollbackFor = Exception.class)
调用外部服务调用邮件、短信、支付等外部接口mailService.sendWelcomeEmail()
复杂计算价格计算、积分计算等calculateTotalPrice()
状态流转订单状态变更、用户状态变更updateOrderStatus()

禁止行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 禁止处理 HTTP 请求相关的对象
public void createUser(HttpServletRequest request) {
String username = request.getParameter("username"); // 不应该出现在 Service
}

// ❌ 禁止直接返回 PO 给 Controller
public User getUserById(Long id) {
return userMapper.selectById(id); // 应该转换为 VO
}

// ❌ 禁止在 Service 中捕获异常后不处理
try {
userMapper.insert(user);
} catch (Exception e) {
e.printStackTrace(); // 吞掉异常,导致调用方无法感知错误
}

标准代码模板

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;
private final MailService mailService; // 可以注入其他 Service

/**
* 创建用户
*
* Service 的职责:
* 1. 业务规则校验(用户名是否重复)
* 2. DTO → PO 转换
* 3. 补全业务数据(状态、创建时间等)
* 4. 调用 Mapper 持久化
* 5. 调用外部服务(发送邮件)
* 6. 返回用户 ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long createUser(UserCreateDTO dto) {
log.info("开始创建用户, username: {}", dto.getUsername());

// 1. 业务规则校验
if (userMapper.existsByUsername(dto.getUsername())) {
throw new BusinessException(ResultCode.USER_ALREADY_EXIST);
}

// 2. DTO → PO 转换
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(dto.getPassword()); // 实际应该加密
user.setEmail(dto.getEmail());

// 3. 补全业务数据
user.setStatus(1); // 正常状态
user.setCreateTime(LocalDateTime.now());

// 4. 持久化
userMapper.insert(user);

// 5. 调用外部服务
mailService.sendWelcomeEmail(user.getEmail(), user.getUsername());

log.info("用户创建成功, userId: {}", user.getId());
return user.getId();
}

/**
* 查询用户详情
*
* Service 的职责:
* 1. 调用 Mapper 查询 PO
* 2. 校验数据存在性
* 3. PO → VO 转换
* 4. 返回 VO
*/
@Override
public UserVO getUserById(Long id) {
// 1. 查询 PO
User user = userMapper.selectById(id);

// 2. 校验
if (user == null) {
throw new BusinessException(ResultCode.USER_NOT_EXIST);
}

// 3. PO → VO 转换
UserVO vo = new UserVO();
vo.setId(user.getId());
vo.setUsername(user.getUsername());
// 注意:不包含 password
vo.setStatusText(convertStatusToText(user.getStatus()));
vo.setCreateTime(user.getCreateTime());

return vo;
}

private String convertStatusToText(Integer status) {
if (status == null) return "未知";
return status == 1 ? "正常" : "禁用";
}
}

Dao/Mapper 层(数据访问层)

核心定位:数据库的 “代言人”,提供原子化的 CRUD 操作。

文件组织

1
2
3
4
5
6
7
8
9
src/main/java/com/example/demo/
├── mapper/
│ ├── UserMapper.java
│ ├── OrderMapper.java
│ └── ProductMapper.java
├── entity/
│ ├── User.java # PO(与数据库表对应)
│ ├── Order.java
│ └── Product.java

职责清单

职责说明示例方法
基础 CRUD增删改查insert(), deleteById(), updateById(), selectById()
条件查询按条件查询selectByUsername(), selectListByStatus()
聚合查询统计、分组countByStatus(), sumAmount()
批量操作批量插入、更新batchInsert(), batchUpdate()
关联查询多表关联selectUserWithRoles()

禁止行为

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 禁止在 Mapper 接口中写业务逻辑
public interface UserMapper extends BaseMapper<User> {
// 不应该有这样的方法
void registerUserAndSendEmail(User user, String emailContent);
}

// ❌ 禁止在 Mapper XML 中写复杂的业务规则判断
<select id="selectUser">
SELECT * FROM t_user WHERE 1=1
<if test="age != null and age >= 18"> <!-- 年龄限制是业务规则,不应该在 SQL 中 -->
AND age >= 18
</if>
</select>

标准代码模板

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
37
38
/**
* 用户数据访问接口
*
* 职责:
* 1. 定义与 t_user 表相关的所有数据访问方法
* 2. 每个方法都是原子化的、无业务逻辑的
* 3. 方法名清晰表达了操作意图
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {

/**
* 根据用户名查询用户
* 这是一个纯粹的查询操作,不包含任何业务判断
*/
User selectByUsername(@Param("username") String username);

/**
* 检查用户名是否已存在
* 返回 boolean,便于 Service 层进行业务判断
*/
boolean existsByUsername(@Param("username") String username);

/**
* 根据状态查询用户列表
*/
List<User> selectListByStatus(@Param("status") Integer status);

/**
* 分页查询用户(复杂查询)
* 注意:这里只是定义查询条件,不包含 "为什么要这样查" 的业务逻辑
*/
IPage<User> selectPageByCondition(
IPage<User> page,
@Param("username") String username,
@Param("status") Integer status
);
}

对应的 XML 配置:

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
<!-- UserMapper.xml -->
<mapper namespace="com.example.demo.mapper.UserMapper">

<select id="selectByUsername" resultType="com.example.demo.entity.User">
SELECT * FROM t_user WHERE username = #{username}
</select>

<select id="existsByUsername" resultType="boolean">
SELECT COUNT(*) > 0 FROM t_user WHERE username = #{username}
</select>

<select id="selectListByStatus" resultType="com.example.demo.entity.User">
SELECT * FROM t_user WHERE status = #{status}
</select>

<select id="selectPageByCondition" resultType="com.example.demo.entity.User">
SELECT * FROM t_user
<where>
<if test="username != null and username != ''">
AND username LIKE CONCAT('%', #{username}, '%')
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
ORDER BY create_time DESC
</select>

</mapper>

10.2.3. 分层依赖原则

依赖方向

1
Controller → Service → Dao → Database

核心原则

  1. 单向依赖:上层可以依赖下层,下层绝不依赖上层
  2. 跨层禁止:Controller 不能直接调用 Dao
  3. 循环禁止:Service A 不能依赖 Service B,同时 Service B 又依赖 Service A

依赖注入方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ✅ 推荐:构造器注入(配合 Lombok)
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final MailService mailService;
}

// ⚠️ 可用但不推荐:字段注入
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
}

// ❌ 错误:循环依赖
@Service
public class UserServiceImpl implements UserService {
@Autowired
private OrderService orderService; // 如果 OrderService 也注入了 UserService,就循环了
}

10.3. 领域对象模型速览

在分层架构中,数据需要在不同层之间流转。为了保证每一层的独立性和数据的安全性,我们需要定义不同的对象来承载数据。

10.3.1. 五大领域对象的定位

mvc 流转图

对象类型全称中文名主要职责生命周期典型包名
POPersistent Object持久化对象与数据库表一一对应Service ↔ Mapper ↔ DBcom.example.entity
DTOData Transfer Object数据传输对象接收前端数据、跨层传输Controller → Servicecom.example.dto
VOView Object视图对象返回给前端的数据Service → Controller → 前端com.example.vo
QOQuery Object查询对象封装复杂查询条件Controller → Servicecom.example.dtocom.example.query
BOBusiness Object业务对象封装复杂业务逻辑Service 内部com.example.service.bo

10.3.2. 为什么需要这么多对象?

很多初学者会问:“为什么不能用一个 User 对象走天下?”

让我们通过一个对比表来理解:

场景:用户注册

阶段如果只用一个 User 对象使用分层对象模型
前端提交{ id: null, username: "test", password: "123", email: "test@qq.com", status: null, createTime: null }UserCreateDTO { username, password, email }
问题前端需要知道所有字段,但很多字段是后端生成的前端只需要关心必填字段,接口边界清晰
Service 处理需要判断哪些字段是前端传的,哪些是后端生成的DTO → PO 转换时,明确知道哪些字段需要补全
数据库插入直接插入 User 对象插入 PO 对象
返回前端需要手动删除 password 字段VO 中从一开始就没有 password 字段

场景:查询用户详情

阶段如果只用一个 User 对象使用分层对象模型
数据库查询查到完整的 User(包含 password)查到 PO(包含 password)
Service 处理需要手动删除 passwordPO → VO 转换,VO 中没有 password 字段
状态转换status: 1 需要在 Controller 或前端转换为 “正常”VO 中直接是 statusText: "正常"
返回前端{ id: 1, username: "test", status: 1, createTime: "2025-01-01T10:00:00" }{ id: 1, username: "test", statusText: "正常", createTime: "2025-01-01 10:00:00" }

核心优势

  1. 安全性:敏感字段物理隔离,从源头避免泄露
  2. 清晰性:每个对象的用途一目了然
  3. 灵活性:同一个 PO 可以转换为多个不同的 VO(列表 VO、详情 VO)
  4. 可维护性:数据库字段变更不影响接口定义。

10.4 为什么需要这么多 Object?

10.4.1 初学者的第一版代码

假设你刚接触 Spring Boot,需要实现一个用户查询接口。你可能会这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 用户实体
@Data
@TableName("t_user")
public class User {
private Long id;
private String username;
private String password; // 密码字段
private String email;
private Integer status; // 1-正常,2-禁用
private LocalDateTime createTime;
}

// Controller
@RestController
public class UserController {
@Autowired
private UserMapper userMapper;

@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userMapper.selectById(id); // 直接返回数据库对象
}
}

问题一:密码泄露

前端收到的响应:

1
2
3
4
5
6
7
8
{
"id": 1,
"username": "admin",
"password": "$2a$10$xyzabc...", // ❌ 密码暴露给前端
"email": "admin@example.com",
"status": 1,
"createTime": "2024-12-17T16:30:00"
}

问题二:枚举值难以理解

前端看到 "status": 1,不知道是什么意思,需要查文档或者前端再转换。

问题三:架构混乱

Controller 直接注入 Mapper,跨越了 Service 层,事务管理、业务逻辑无法复用。

10.4.2 解决方案的演进

演进阶段做法解决的问题带来的成本
阶段 1:直接返回 POController 返回数据库对象密码泄露、字段耦合
阶段 2:在 PO 上加注解@JsonIgnore 隐藏密码密码泄露PO 被污染,职责不清
阶段 3:引入 VO专门的对象返回给前端安全性、字段解耦需要对象转换
阶段 4:引入 DTO专门的对象接收前端数据参数校验、字段解耦需要对象转换
阶段 5:引入 BO/QO封装复杂业务逻辑和查询条件代码可维护性对象数量增加

核心结论:看起来对象变多了,但实际上是 职责分离,每个对象做好自己的事,系统反而更清晰。


10.5 五种 Object 的定位与职责 —— 术语速查手册

10.5.1 核心概念速查表

对象全称中文名生命周期是否跨层职责
POPersistent Object持久化对象Service ↔ Mapper ↔ DB与数据库表一一对应
DTOData Transfer Object数据传输对象Controller → Service接收前端输入
VOView Object视图对象Service → Controller返回前端展示
QOQuery Object查询对象Controller → Service封装查询条件
BOBusiness Object业务对象Service 内部封装业务逻辑

10.5.2 PO(Persistent Object)—— 数据库的映射

定位:数据库表在 Java 世界的投影。

设计原则

  1. ✅ 字段与数据库表 完全一致
  2. ✅ 包含 所有字段(包括敏感字段)
  3. ✅ 只在 Service 和 Mapper 层使用
  4. 绝不 直接返回给 Controller

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 用户 PO - 与数据库表 t_user 完全对应
*/
@Data
@TableName("t_user")
public class User {
@TableId(type = IdType.AUTO)
private Long id;

private String username;
private String password; // ✅ 包含敏感字段
private String email;
private Integer status; // ✅ 存储原始枚举值(1,2)
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

关键点

  • PO 是数据库的 “翻译器”,字段一一对应
  • PO 不做任何业务转换(如 status = 1 不转换为 “正常”)
  • PO 不考虑前端需求,只考虑数据库结构

10.2.3 DTO(Data Transfer Object)—— 接收前端数据

定位:Controller 和 Service 之间的数据传递载体(输入方向)。

设计原则

  1. ✅ 根据 业务操作 设计(创建、更新、删除…)
  2. ✅ 只包含本次操作 必需的字段
  3. ✅ 不包含系统生成的字段(id、createTime)
  4. ✅ 可以包含参数校验注解(@NotNull@Email

示例

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
/**
* 创建用户 DTO - 只包含创建用户所需的字段
*/
@Data
public class UserCreateDTO {
@NotBlank(message = "用户名不能为空")
private String username;

@NotBlank(message = "密码不能为空")
private String password;

@Email(message = "邮箱格式不正确")
private String email;

// ❌ 没有 id(由数据库生成)
// ❌ 没有 createTime(由系统生成)
// ❌ 没有 status(由系统默认设置)
}

/**
* 更新用户 DTO - 只包含可更新的字段
*/
@Data
public class UserUpdateDTO {
@NotNull(message = "用户ID不能为空")
private Long id; // ✅ 更新需要指定 ID

@Email
private String email; // ✅ 可选字段

// ❌ 没有 username(用户名通常不允许修改)
// ❌ 没有 password(密码修改是单独的接口)
}

关键点

  • 同一个实体(如 User),不同操作有不同的 DTO(Create、Update、Delete)
  • DTO 的字段由 业务规则 决定,不是照搬 PO
  • DTO 是 “输入的过滤器”,只放行需要的数据

10.2.4 VO(View Object)—— 返回前端展示

定位:Service 和 Controller 之间的数据传递载体(输出方向)。

设计原则

  1. ✅ 根据 前端页面需求 设计(列表、详情、下拉框…)
  2. 不包含敏感字段(password)
  3. ✅ 进行数据转换(枚举 → 文本,日期格式化)
  4. ✅ 字段名可以与 PO 不同(根据前端规范)

示例

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
37
38
39
/**
* 用户列表 VO - 用于列表页面展示
*/
@Data
public class UserListVO {
private Long id;
private String username;
private String email;

private String statusText; // ✅ "正常"/"禁用"(不是 1/2)

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;

// ❌ 没有 password(安全)
// ❌ 没有 phone(列表不需要显示)
// ❌ 没有 updateTime(列表不需要显示)
}

/**
* 用户详情 VO - 用于详情页面展示
*/
@Data
public class UserDetailVO {
private Long id;
private String username;
private String email;
private String phone; // ✅ 详情页显示手机号

private String statusText;

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; // ✅ 详情页显示更新时间

// ❌ 依然没有 password
}

关键点

  • 同一个实体,不同页面有不同的 VO(List、Detail、Simple)
  • VO 的字段由 前端需求 决定,不是照搬 PO
  • VO 是 “输出的美化器”,让前端用起来更舒服

10.2.5 QO(Query Object)—— 封装查询条件

定位:封装复杂查询参数的容器。

设计原则

  1. ✅ 包含所有可能的查询条件
  2. ✅ 所有字段都是 可选的(允许为 null)
  3. ✅ 包含分页和排序参数
  4. ✅ 提供默认值

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 用户查询 QO - 封装所有查询条件
*/
@Data
public class UserQueryDTO {
// 查询条件
private String username; // 用户名模糊查询
private Integer status; // 状态精确查询
private LocalDate createTimeStart; // 创建时间范围-开始
private LocalDate createTimeEnd; // 创建时间范围-结束

// 分页参数
private Integer pageNum = 1;
private Integer pageSize = 10;

// 排序参数
private String sortField = "createTime";
private String sortOrder = "desc"; // asc/desc
}

对比:使用 QO 前后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ❌ 不使用 QO:方法签名臃肿
@GetMapping("/users")
public Result listUsers(
@RequestParam String username,
@RequestParam Integer status,
@RequestParam LocalDate createTimeStart,
@RequestParam LocalDate createTimeEnd,
@RequestParam Integer pageNum,
@RequestParam Integer pageSize,
@RequestParam String sortField,
@RequestParam String sortOrder
) {
// 参数太多,难以阅读和维护
}

// ✅ 使用 QO:一个对象搞定
@GetMapping("/users")
public Result listUsers(UserQueryDTO query) {
// 简洁清晰
return Result.success(userService.listUsers(query));
}

关键点

  • QO 是 “查询的容器”,把分散的参数聚合起来
  • QO 让方法签名更简洁,参数传递更方便
  • QO 的扩展性好,增加查询条件不需要改方法签名

10.2.6 BO(Business Object)—— 封装业务逻辑

定位:Service 层内部使用的业务对象,封装复杂逻辑。

设计原则

  1. ✅ 只在 Service 层内部使用(不跨层)
  2. ✅ 可以包含业务方法(充血模型)
  3. ✅ 封装复杂的计算、校验、转换逻辑
  4. ✅ 作为多个 PO 的组合

示例

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 订单 BO - 封装订单创建的复杂逻辑
*/
@Getter
public class OrderBO {
private final User user;
private final Product product;
private final Integer quantity;

private BigDecimal totalAmount;
private BigDecimal discountAmount;
private BigDecimal finalAmount;

public OrderBO(User user, Product product, Integer quantity) {
this.user = user;
this.product = product;
this.quantity = quantity;
}

/**
* 业务方法:校验库存
*/
public boolean validateStock() {
return product.getStock() >= quantity;
}

/**
* 业务方法:计算价格
*/
public void calculatePrice() {
this.totalAmount = product.getPrice().multiply(BigDecimal.valueOf(quantity));
this.discountAmount = calculateDiscount();
this.finalAmount = totalAmount.subtract(discountAmount);
}

/**
* 业务方法:转换为订单 PO
*/
public Order toOrderPO() {
Order order = new Order();
order.setUserId(user.getId());
order.setProductId(product.getId());
order.setTotalAmount(totalAmount);
order.setFinalAmount(finalAmount);
return order;
}

private BigDecimal calculateDiscount() {
// 复杂的折扣计算逻辑
return BigDecimal.ZERO;
}
}

使用 BO 前后对比

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
37
38
39
40
41
// ❌ 不使用 BO:Service 方法臃肿
@Transactional
public void createOrder(OrderCreateDTO dto) {
User user = userMapper.selectById(dto.getUserId());
Product product = productMapper.selectById(dto.getProductId());

// 校验库存
if (product.getStock() < dto.getQuantity()) {
throw new RuntimeException("库存不足");
}

// 计算价格(大量业务逻辑堆砌)
BigDecimal totalAmount = product.getPrice().multiply(...);
BigDecimal discountAmount = ...;
BigDecimal finalAmount = ...;

// 创建订单
Order order = new Order();
order.setUserId(user.getId());
// ... 设置很多字段 ...
orderMapper.insert(order);
}

// ✅ 使用 BO:Service 方法清晰简洁
@Transactional
public void createOrder(OrderCreateDTO dto) {
User user = userMapper.selectById(dto.getUserId());
Product product = productMapper.selectById(dto.getProductId());

// 创建业务对象
OrderBO orderBO = new OrderBO(user, product, dto.getQuantity());

// 执行业务逻辑(封装在 BO 中)
if (!orderBO.validateStock()) {
throw new RuntimeException("库存不足");
}
orderBO.calculatePrice();

// 持久化
orderMapper.insert(orderBO.toOrderPO());
}

关键点

  • BO 是 “业务逻辑的家”,让 Service 方法像在读 “剧本”
  • BO 不跨层传输,只在 Service 内部使用
  • BO 适合封装:复杂计算、多对象组合、业务规则校验

10.3 数据流转的完整链路 —— 从前端到数据库的旅程

10.3.1 查询场景的数据流转

mermaid-diagram (5)

关键转换点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Service 层:PO → VO
public UserDetailVO getUserById(Long id) {
// 1. Mapper 返回 PO
User user = userMapper.selectById(id);

// 2. PO → VO 转换
UserDetailVO vo = new UserDetailVO();
vo.setId(user.getId());
vo.setUsername(user.getUsername());
vo.setEmail(user.getEmail());
vo.setStatusText(user.getStatus() == 1 ? "正常" : "禁用"); // ✅ 枚举转文本
// ❌ 不设置 password

return vo;
}

10.3.2 新增场景的数据流转

mermaid-diagram (6)

关键转换点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Service 层:DTO → PO
public Long createUser(1UserCreateDTO dto) {
// 1. DTO → PO 转换
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(BCrypt.hashpw(dto.getPassword())); // ✅ 密码加密
user.setEmail(dto.getEmail());

// 2. 补全系统字段(DTO 中没有的)
user.setStatus(1); // ✅ 默认状态
user.setCreateTime(LocalDateTime.now()); // ✅ 创建时间

// 3. 插入数据库
userMapper.insert(user);

return user.getId();
}

10.3.3 复杂查询场景的数据流转

mermaid-diagram (7)

关键转换点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Service 层:QO → 查询条件,PO 列表 → VO 列表
public PageResult<UserListVO> listUsers(UserQueryDTO query) {
// 1. QO → 查询条件
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.like(query.getUsername() != null, User::getUsername, query.getUsername())
.eq(query.getStatus() != null, User::getStatus, query.getStatus());

// 2. 分页查询
Page<User> page = new Page<>(query.getPageNum(), query.getPageSize());
Page<User> userPage = userMapper.selectPage(page, wrapper);

// 3. PO 列表 → VO 列表(批量转换)
List<UserListVO> voList = userPage.getRecords().stream()
.map(this::convertToListVO) // 每个 PO 转换为 VO
.collect(Collectors.toList());

// 4. 封装分页结果
PageResult<UserListVO> result = new PageResult<>();
result.setRecords(voList);
result.setTotal(userPage.getTotal());

return result;
}

10.4 对象转换的最佳实践 —— 优雅地搬运数据

10.4.1 转换工具的选择

工具性能易用性类型安全适用场景
手动转换⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐字段少、逻辑复杂
BeanUtil⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐字段多、逻辑简单
MapStruct⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐大型项目

10.4.2 Hutool BeanUtil —— 开箱即用的转换工具

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import cn.hutool.core.bean.BeanUtil;

// 示例 1:简单复制
public UserDetailVO convertToDetailVO(User user) {
UserDetailVO vo = new UserDetailVO();

// ✅ 自动复制同名同类型字段:id, username, email, createTime...
BeanUtil.copyProperties(user, vo);

// ✅ 手动处理特殊字段
vo.setStatusText(user.getStatus() == 1 ? "正常" : "禁用");

// ✅ password 字段因为 VO 中不存在,会被自动忽略
return vo;
}

// 示例 2:忽略特定字段
BeanUtil.copyProperties(user, vo, "password", "updateTime");

// 示例 3:批量转换
List<UserListVO> voList = BeanUtil.copyToList(userList, UserListVO.class);

注意事项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ❌ 陷阱 1:浅拷贝问题
User user1 = new User();
user1.setRoles(Arrays.asList("admin"));

User user2 = new User();
BeanUtil.copyProperties(user1, user2);

user2.getRoles().add("user"); // user1 的 roles 也会被修改!

// ✅ 解决方案:手动深拷贝集合字段
BeanUtil.copyProperties(user1, user2, "roles");
user2.setRoles(new ArrayList<>(user1.getRoles()));

// ❌ 陷阱 2:字段名必须完全相同
class Source { private String userName; }
class Target { private String username; }
BeanUtil.copyProperties(source, target); // ❌ 复制失败

// ✅ 解决方案:使用 CopyOptions 自定义映射
CopyOptions options = CopyOptions.create()
.setFieldMapping(Map.of("userName", "username"));
BeanUtil.copyProperties(source, target, options);

10.4.3 转换策略决策树

mermaid-diagram (8)


10.5 架构决策指南 —— 不同项目规模的最佳实践

10.5.1 小型项目(< 10 个实体)

对象使用策略

对象是否使用说明
PO✅ 必须与数据库表对应
DTO✅ 必须接收前端数据
VO✅ 必须返回前端数据
QO❌ 可选查询条件简单可以不用
BO❌ 不用业务逻辑简单,直接在 Service 中处理

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Controller:接收 DTO,返回 VO
@PostMapping
public Result createUser(@RequestBody UserCreateDTO dto) {
Long userId = userService.createUser(dto);
return Result.success(userId);
}

// Service:DTO → PO,PO → VO
@Transactional
public Long createUser(UserCreateDTO dto) {
// DTO → PO
User user = new User();
BeanUtil.copyProperties(dto, user);
user.setStatus(1);
user.setCreateTime(LocalDateTime.now());

// 保存
userMapper.insert(user);
return user.getId();
}

10.5.2 中型项目(10-50 个实体)

对象使用策略

对象是否使用说明
PO✅ 必须与数据库表对应
DTO✅ 必须接收前端数据
VO✅ 必须返回前端数据
QO✅ 推荐查询条件多,用 QO 简化参数
BO⚠️ 按需复杂业务逻辑用 BO 封装

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Controller:使用 QO 简化查询参数
@GetMapping("/search")
public Result searchUsers(UserQueryDTO query) {
PageResult<UserListVO> result = userService.listUsers(query);
return Result.success(result);
}

// Service:QO → 查询条件
public PageResult<UserListVO> listUsers(UserQueryDTO query) {
LambdaQueryWrapper<User> wrapper = buildQueryWrapper(query);
Page<User> userPage = userMapper.selectPage(
new Page<>(query.getPageNum(), query.getPageSize()),
wrapper
);

// PO 列表 → VO 列表
List<UserListVO> voList = BeanUtil.copyToList(
userPage.getRecords(),
UserListVO.class
);

return PageResult.of(voList, userPage.getTotal());
}

10.5.3 大型项目(> 50 个实体)

对象使用策略

对象是否使用说明
PO✅ 必须与数据库表对应
DTO✅ 必须接收前端数据
VO✅ 必须返回前端数据
QO✅ 必须统一查询参数封装
BO✅ 推荐复杂业务逻辑封装

额外建议

  • 使用 MapStruct 替代 BeanUtil(编译期生成代码,性能更好)
  • 引入 领域驱动设计(DDD),BO 采用充血模型
  • 建立统一的 对象转换层(Converter/Assembler)

示例代码

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
// 使用 MapStruct(编译期生成转换代码)
@Mapper(componentModel = "spring")
public interface UserConverter {
UserDetailVO toDetailVO(User user);

@Mapping(target = "statusText", expression = "java(convertStatus(user.getStatus()))")
UserListVO toListVO(User user);

default String convertStatus(Integer status) {
return status == 1 ? "正常" : "禁用";
}
}

// Service:注入 Converter
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
private final UserConverter userConverter; // 专门的转换器

public UserDetailVO getUserById(Long id) {
User user = userMapper.selectById(id);
return userConverter.toDetailVO(user); // 使用转换器
}
}

10.6 常见反模式与避坑指南

10.6.1 反模式 1:PO 直接返回给前端

1
2
3
4
5
6
7
8
9
10
11
// ❌ 错误示例
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userMapper.selectById(id); // 密码泄露!
}

// ✅ 正确示例
@GetMapping("/{id}")
public Result<UserDetailVO> getUser(@PathVariable Long id) {
return Result.success(userService.getUserById(id)); // 返回 VO
}

10.6.2 反模式 2:Controller 注入 Mapper

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 错误示例
@RestController
public class UserController {
@Autowired
private UserMapper userMapper; // 跨层依赖
}

// ✅ 正确示例
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService; // 只注入 Service
}

10.6.3 反模式 3:在 DTO/VO 中写业务逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌ 错误示例
@Data
public class UserVO {
private Integer status;

// ❌ 不应该在 VO 中写业务逻辑
public String getStatusText() {
return status == 1 ? "正常" : "禁用";
}
}

// ✅ 正确示例
@Data
public class UserVO {
private String statusText; // 在 Service 转换时直接赋值
}

// Service 中转换
vo.setStatusText(user.getStatus() == 1 ? "正常" : "禁用");

10.6.4 反模式 4:所有操作共用一个 DTO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ❌ 错误示例
@Data
public class UserDTO {
private Long id; // 创建时不需要
private String username;
private String password;
private String email;
// ... 所有字段都在一个 DTO 中
}

// ✅ 正确示例:不同操作使用不同 DTO
public class UserCreateDTO {
private String username;
private String password;
// 只包含创建所需字段
}

public class UserUpdateDTO {
private Long id;
private String email;
// 只包含更新所需字段
}

10.7 完整架构图 —— 一图胜千言

mermaid-diagram (9)