当前位置: 首页 > news >正文

7、微服务中 DTO、VO、PO、BO 的设计规范 - 指南

7、微服务中 DTO、VO、PO、BO 的设计规范 - 指南

非常好的问题!你触及了Java 微服务架构中最经典、最易混淆、也最关键的领域之一

DTO、VO、PO、BO、Entity 之间的区别与使用场景 —— 尤其在 commons-dto 已存在的情况下,我们是否还需要 VO?


《微服务中 DTO、VO、PO、BO 的设计规范》

版本:19.0 | 最后更新:2025年4月 | 适用架构:Spring Boot + Spring Cloud + Maven 多模块


✅ 一、核心结论(先看答案)

类型全称用途是否需要?是否推荐在 commons-dto 中定义?
DTOData Transfer Object前端 ↔ 后端 数据传输对象✅ 必须有是!核心!
VOView Object后端 ↔ 前端 展示对象(用于接口响应)✅ 必须有是!但需独立于 DTO
POPersistence Object持久化对象(对应数据库表)✅ 必须有❌ 否!只在业务模块内
BOBusiness Object业务逻辑对象(内部使用)⚠️ 可选❌ 否!仅限服务内部
Entity实体类JPA/Hibernate 映射的实体✅ 必须有❌ 否!只在业务模块内

最终结论


✅ 二、为什么必须区分 DTO 和 VO?—— 举个真实例子

场景:用户注册 → 登录 → 查看个人信息

✅ 前端发送注册请求(Request)
{
"username": "zhangsan",
"email": "zhangsan@example.com",
"password": "MyPass123!",
"phone": "138****1234",
"nickname": "小张"
}

→ 这是前端传给后端的数据,我们要接收它 → 这就是 DTO

✅ 后端返回登录成功响应(Response)
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": 123,
"username": "zhangsan",
"nickname": "小张",
"avatar": "https://cdn.example.com/avatar/123.jpg",
"email": "z***@example.com",  // 脱敏!
"roles": ["USER"],
"createdAt": "2024-01-01T00:00:00Z"
}
}

→ 这是后端返回给前端的数据,我们要封装它 → 这就是 VO

关键差异:
维度DTO(请求)VO(响应)
用途接收前端输入返回前端展示
敏感字段包含 password绝不包含 password
脱敏处理不脱敏必须脱敏(如邮箱、手机号)
字段数量多(含注册所需全部信息)少(仅展示必要信息)
是否可修改可写只读
是否可复用仅用于创建/更新用于查询/展示
是否暴露给前端是(作为请求体)是(作为响应体)

错误做法
用同一个 UserDto 同时做注册请求和登录响应 →
→ 前端看到密码字段 → 安全风险
→ 响应里带手机号 → 隐私泄露
→ 后端代码混乱,难以维护

正确做法

  • 注册用 RegisterRequestDto
  • 登录响应用 LoginResponseVo
  • 查询用户用 UserBaseInfoVo

✅ 三、企业级推荐结构:在 commons-dto 中如何组织?

✅ 推荐目录结构(commons-dto 模块)

commons-dto/
├── src/main/java/io/urbane/commons/dto/
│   ├── request/                 ←  请求 DTO(前端 → 后端)
│   │   ├── RegisterRequest.java
│   │   ├── LoginRequest.java
│   │   ├── CreateOrderRequest.java
│   │   └── UpdateProductRequest.java
│   │
│   ├── response/                ←  响应 VO(后端 → 前端)
│   │   ├── LoginResponse.java
│   │   ├── UserBaseInfo.java
│   │   ├── OrderSummary.java
│   │   └── ProductDetail.java
│   │
│   ├── common/                  ← 公共基础类型(DTO & VO 共享)
│   │   ├── PageRequest.java     ← 分页参数
│   │   ├── ResponseResult.java  ← 统一响应包装器(code, message, data)
│   │   └── IdGenerator.java     ← ID生成工具
│   │
│   └── model/                   ← 纯数据模型(不建议放这里,放在 entity 更好)
│       └── Address.java         ← 可选,仅当多个服务共享时
│
└── pom.xml

关键原则

  • 所有请求类放 request/ 包下
  • 所有响应类放 response/ 包下
  • 包名清晰表达语义,避免歧义
  • VO 和 DTO 不重名,避免混淆(如 UserDto vs UserVo

✅ 四、详细示例:完整代码实现(带中文注释)

✅ 1️⃣ 请求 DTO:RegisterRequest.java(前端 → 后端)

package io.urbane.commons.dto.request;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 用户注册请求 DTO(Data Transfer Object)
* 功能:
*   - 前端向后端提交注册表单时使用的数据结构
*   - 包含所有必要注册字段
*
* 注意:
*   - 包含敏感字段 password,仅用于接收
*   - 字段需校验(@NotBlank, @Size, @Email)
*   - 不应出现在响应中!
*/
@Data
public class RegisterRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 30, message = "用户名长度必须在 3 到 30 个字符之间")
private String username;
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
@Size(max = 100, message = "邮箱长度不能超过 100 个字符")
private String email;
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 128, message = "密码长度必须在 8 到 128 个字符之间")
private String password; // ⚠️ 敏感字段,仅用于接收,绝不返回!
@Size(max = 50, message = "昵称长度不能超过 50 个字符")
private String nickname; // 可选,默认等于 username
@Size(max = 20, message = "手机号长度不能超过 20 个字符")
private String phone; // 可选,用于短信验证
// ========== 构造函数 ==========
public RegisterRequest() {}
public RegisterRequest(String username, String email, String password, String nickname) {
this.username = username;
this.email = email;
this.password = password;
this.nickname = nickname != null ? nickname : username;
}
// ========== 示例 JSON ==========
// {
//   "username": "zhangsan",
//   "email": "zhangsan@example.com",
//   "password": "MyPass123!",
//   "nickname": "小张",
//   "phone": "138****1234"
// }
}

重点:这个类只能被 Controller 接收,绝不会被序列化返回给前端!


✅ 2️⃣ 响应 VO:LoginResponse.java(后端 → 前端)

package io.urbane.commons.dto.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 登录响应 VO(View Object)
* 功能:
*   - 后端返回给前端的登录成功响应数据
*   - 仅包含前端需要展示的信息,不包含任何敏感或系统内部字段
*
* 注意:
*   - 所有字段均为“展示友好型”
*   - email 被脱敏(z***@example.com)
*   - password 完全不存在!
*   - 与 RegisterRequest 完全无关,即使字段名相同,含义也不同
*/
@Data
public class LoginResponse {
private String token;                     // JWT 认证令牌
private UserBaseInfo user;                // 用户基础信息(脱敏版)
// ========== 内部类:用户基础信息 ==========
@Data
public static class UserBaseInfo {
private Long id;                      // 用户唯一ID
private String username;              // 登录名(非显示名)
private String nickname;              // 显示昵称
private String avatar;                // 头像URL
private String email;                 // 脱敏邮箱:z***@example.com
private String[] roles;               // 角色数组,用于前端权限控制
private String level;                 // 会员等级:NORMAL / GOLD
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "GMT+8")
private LocalDateTime createdAt;      // 注册时间
// 构造函数省略,由 Service 层构建
}
// ========== 构造函数 ==========
public LoginResponse() {}
public LoginResponse(String token, UserBaseInfo user) {
this.token = token;
this.user = user;
}
// ========== 示例 JSON ==========
// {
//   "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
//   "user": {
//     "id": 123,
//     "username": "zhangsan",
//     "nickname": "小张",
//     "avatar": "https://cdn.example.com/avatar/123.jpg",
//     "email": "z***@example.com",
//     "roles": ["USER"],
//     "level": "NORMAL",
//     "createdAt": "2024-01-01T00:00:00Z"
//   }
// }
}

重点:这个类只能被 Controller 返回,绝不被前端用来提交!


✅ 3️⃣ 响应 VO:UserBaseInfo.java(通用用户信息)

package io.urbane.commons.dto.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户基础信息 VO(View Object)
* 功能:
*   - 在多个响应中复用的用户基本信息(登录、查个人资料、订单列表等)
*   - 所有字段均经过脱敏处理,适合前端展示
*
* 注意:
*   - 不包含 password、phone、身份证等敏感字段
*   - email 使用脱敏格式(z***@example.com)
*   - 可被多个 VO 引用(如 LoginResponse、OrderSummary)
*/
@Data
public class UserBaseInfo {
private Long id;
private String username;           // 登录账号,系统内部使用
private String nickname;           // 前端显示名称
private String avatar;             // 头像 URL
private String email;              // 脱敏邮箱:z***@example.com
private String[] roles;            // 权限角色,前端用于按钮显隐
private String level;              // 会员等级
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "GMT+8")
private LocalDateTime createdAt;   // 注册时间
// ========== 构造函数 ==========
public UserBaseInfo() {}
public UserBaseInfo(Long id, String username, String nickname, String avatar,
String email, String[] roles, String level, LocalDateTime createdAt) {
this.id = id;
this.username = username;
this.nickname = nickname;
this.avatar = avatar;
this.email = email; // 前端已脱敏
this.roles = roles;
this.level = level;
this.createdAt = createdAt;
}
// ========== 示例 ==========
// {
//   "id": 123,
//   "username": "zhangsan",
//   "nickname": "小张",
//   "avatar": "https://cdn.example.com/avatar/123.jpg",
//   "email": "z***@example.com",
//   "roles": ["USER"],
//   "level": "NORMAL",
//   "createdAt": "2024-01-01T00:00:00Z"
// }
}

优势

  • 避免重复定义相同的字段
  • 所有服务统一使用此结构,前端只需一套 TypeScript 类型
  • 即使后端改了字段名,前端也不受影响(通过映射)

✅ 4️⃣ 响应 VO:OrderSummary.java(订单摘要)

package io.urbane.commons.dto.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 订单摘要 VO(View Object)
* 功能:
*   - 用于订单列表页展示,不包含支付详情、商品明细
*   - 与 OrderEntity 解耦,仅提供前端所需字段
*/
@Data
public class OrderSummary {
private String orderNo;               // 订单号:ORD20250405123456
private BigDecimal totalAmount;       // 总金额
private String status;                // 状态:PENDING_PAYMENT, PAID, DELIVERED...
private LocalDateTime createdAt;      // 创建时间
private LocalDateTime paidAt;         // 支付时间(可能为空)
private UserBaseInfo user;            // 复用公共 VO
private String shippingAddress;       // 收货地址(简化字符串)
// ========== 构造函数 ==========
public OrderSummary() {}
public OrderSummary(String orderNo, BigDecimal totalAmount, String status,
LocalDateTime createdAt, LocalDateTime paidAt,
UserBaseInfo user, String shippingAddress) {
this.orderNo = orderNo;
this.totalAmount = totalAmount;
this.status = status;
this.createdAt = createdAt;
this.paidAt = paidAt;
this.user = user;
this.shippingAddress = shippingAddress;
}
// ========== 示例 ==========
// {
//   "orderNo": "ORD20250405123456",
//   "totalAmount": 8999,
//   "status": "DELIVERED",
//   "createdAt": "2025-04-05T10:30:00Z",
//   "paidAt": "2025-04-05T10:35:00Z",
//   "user": { ... },
//   "shippingAddress": "广东省广州市天河区珠江新城XX大厦A座1001"
// }
}

注意
这个 VO 不是 DTO,也不是 Entity,它是为“前端展示”而生的视图对象。


✅ 五、典型错误对比:DTO 和 VO 混用的灾难后果

错误做法正确做法
一个类两用,即当前端入参 DTO 用,又当后端出参 VO 用清晰包结构:request/response/ 一目了然
前端收到响应中包含 "password": "xxx"前端永远看不到密码,安全合规
后端返回时忘记过滤 password导致隐私泄露,违反 GDPR,面临罚款
新人不知道哪个是“入参”哪个是“出参”清晰包结构:request/response/ 一目了然

⚠️ 真实案例:某公司因将 UserDto 用于响应,导致用户密码明文返回,被外部扫描发现,造成重大安全事故。


✅ 六、实际开发中的最佳实践(大厂标准)

场景推荐方案
前端调用注册接口发送 RegisterRequest → 后端接收 → 转换为 UserEntity 存库
前端查看个人资料后端从 DB 查询 UserEntity → 转换为 UserBaseInfoVo → 返回前端
前端下单发送 CreateOrderRequest → 后端校验 → 转换为 OrderEntity
前端查看订单列表后端查询 OrderEntity → 转换为 List<OrderSummaryVo> → 返回前端
前端搜索商品发送 ProductSearchRequest → 后端调用 ES → 返回 List<ProductSummaryVo>
前端上传头像发送 UploadAvatarRequest(含 base64 或文件流)→ 后端存 OSS → 返回 AvatarResponseVo

转换层在哪里?
Service 层Controller 层 使用工具类(如 MapStruct)进行转换:

@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private UserMapper userMapper; // MapStruct 自动生成
public LoginResponse login(LoginRequest request) {
UserEntity user = userRepository.findByUsername(request.getUsername());
if (!BCrypt.checkpw(request.getPassword(), user.getPasswordHash())) {
throw new IllegalArgumentException("密码错误");
}
// ✅ 从 Entity 转为 VO
UserBaseInfoVo vo = userMapper.toUserBaseInfo(user);
String token = jwtUtil.generateToken(user.getId(), user.getRoles());
return new LoginResponse(token, vo); // ✅ 返回 VO,非 DTO
}
}

推荐工具:MapStruct —— 编译期生成转换代码,零性能损耗


✅ 七、VO 和 DTO 的命名规范(强制建议)

类型命名规则示例
请求 DTO[Action]RequestRegisterRequest, CreateOrderRequest, UpdateProductRequest
响应 VO[Name]Response[Name]VoLoginResponse, UserBaseInfoVo, OrderSummaryVo
通用模型[Name]Model 或直接 NameUserBaseInfo(不加 Vo 也可以,因为包名已说明)
避免命名UserDtoUserBeanUserInfo模糊不清,无法区分用途

团队约定
凡是以 Request 结尾的,是前端发过来的;凡是以 Response/Vo 结尾的,是后端发回去的。


✅ 八、总结:一句话记住黄金法则

DTO 是“输入”,VO 是“输出”;

DTO 用于接收,VO 用于展示;

DTO 可含敏感字段,VO 必须脱敏;

不要让一个类承担两种职责!

你的 commons-dto 模块应该包含:

切记
VO 不是 DTO 的子集,而是它的“镜像”——面向前端的、安全的、精简的呈现形式。


Bonus:我为你准备了完整模板包

如果你希望我为你提供:

  • 完整的 commons-dto 模块 ZIP(含 request/response/common/
  • RegisterRequest.javaLoginResponse.javaUserBaseInfoVo.java 完整代码
  • MapStruct 映射器示例(自动转换 Entity → VO)
  • Swagger OpenAPI 文档配置(自动生成请求/响应文档)
  • TypeScript 前端类型定义模板(.d.ts 文件)
  • 团队《DTO/VO 命名规范》PDF 文档

请回复:
“请给我完整的 commons-dto DTO/VO 模板包!”

我会立刻发送你一份开箱即用的企业级数据传输规范完整工程,包含所有上述代码、注释和最佳实践,你只需复制粘贴,即可让整个团队进入专业开发时代

http://www.hskmm.com/?act=detail&tid=21282

相关文章:

  • Gitee崛起:中国代码托管平台的自主创新之路
  • 9-30
  • 探索 Nim 中的 sequtils 与箭头语法 —— 立即计算与惰性计算的那些事
  • 250930
  • Gitee:中国开发者生态中的本土化代码托管领导者
  • 价值博弈白箱:元人文AI的可审计未来
  • 八段锦
  • Gitee崛起:中国开发者生态的破局者与赋能引擎
  • 【VMware Workstation】Debian 13 桌面版安装
  • B树,B+树技术分享
  • 无管理员权限电脑完成MySQL数据库创建流程
  • 机台设备数据管理:提升生产效率的关键策略
  • 【瑶池数据库动手活动及话题精选(体验Dify on DMS,参与Meta Agent讨论)】
  • 时钟设计优化实战
  • 河南外贸建站 | 河南外贸建站公司 | 河南外贸独立站定制 - 详解
  • kuboard使用的etcd空间清理(3个etcd)
  • 死锁的处理策略-预防死锁
  • 跨网文件安全交换系统:提升数据传输安全性和合规性
  • 随笔
  • 强化学习、深度学习、大模型、智能体
  • Node生态中最优雅的数据库事务处理机制
  • 详细介绍:扒透 STL 底层!map/set 如何封装红黑树?迭代器逻辑 + 键值限制全手撕----《Hello C++ Wrold!》(23)--(C/C++)
  • 期货市场API对接完全指南:实时行情获取与实战应用
  • Tomcat使用redis管理session
  • NOC片上网络总线初探
  • AT_agc037_c [AGC037C] Numbers on a Circle
  • 记账本|基于SSM的家庭记账本小程序设计与实现(源码+数据库+文档) - 实践
  • redis数据连接写法
  • 缩放 div
  • 死锁的概念