POJO、DTO、VO 详解:区别、联系与最佳实践
一、概念定义
1.1 POJO (Plain Old Java Object)
定义:普通的 Java 对象,不继承任何特殊类,不实现任何特殊接口。
特点:
- 只包含属性和 getter/setter 方法
- 通常对应数据库表结构
- 用于持久层(Mapper/DAO)
示例:
java
@Data
@TableName("user")
public class User {
@TableId(type = IdType.AUTO)
private Long userId;
private String username;
private String password;
private String nickname;
private Integer gender;
private String phone;
private String avatar;
private Integer role;
private Integer status;
@TableLogic
private Integer deleted;
@Version
private Integer version;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
1.2 DTO (Data Transfer Object)
定义:数据传输对象,用于不同层之间传递数据。
特点:
- 只包含需要传输的字段(精简)
- 可以组合多个 POJO 的字段
- 用于 Service 层和 Controller 层之间
示例:
java
@Data
public class BillStatisticsDTO {
private Long userId; // 用户ID
private BigDecimal totalIncome; // 总收入
private BigDecimal totalExpense; // 总支出
private BigDecimal balance; // 结余
private Integer billCount; // 账单数量
private LocalDate startDate; // 统计开始日期
private LocalDate endDate; // 统计结束日期
}
为什么需要 DTO?
- 直接返回 POJO:会暴露敏感字段(如 password、deleted)
- 使用 DTO:只返回需要的字段,更安全、更灵活
1.3 VO (View Object)
定义:视图对象,专门用于前端展示。
特点:
- 字段名和格式符合前端需求
- 可以包含格式化后的数据(如日期格式化)
- 可以包含计算后的字段(如"已读/未读")
- 用于 Controller 层返回给前端
示例:
java
@Data
public class AnnouncementVO {
private Long announcementId;
private String title;
private String content;
private String priorityText; // "普通" 或 "重要"(格式化后)
private String statusText; // "草稿"、"已发布"、"已下线"(格式化后)
private Boolean isRead; // 当前用户是否已读
private String createTimeText; // "2024-01-15 10:30"(格式化后)
private String creatorName; // 创建人姓名
}
为什么需要 VO?
- 直接返回 DTO:前端需要自己处理格式化和计算
- 使用 VO:后端处理好所有展示逻辑,前端直接显示
二、三者对比
| 特性 | POJO | DTO | VO |
|---|---|---|---|
| 用途 | 数据库映射 | 层间数据传输 | 前端展示 |
| 使用层 | Mapper/DAO 层 | Service ↔ Controller | Controller → 前端 |
| 字段来源 | 对应数据库表 | 可组合多个 POJO | 可组合 DTO + 格式化 |
| 是否包含敏感字段 | 是(如 password) | 否 | 否 |
| 是否包含格式化字段 | 否 | 否 | 是 |
| 是否包含计算字段 | 否 | 可能有 | 是 |
| 典型注解 | @TableName、@TableId |
无特殊注解 | 无特殊注解 |
三、实际应用场景
3.1 场景一:用户登录
需求:用户登录后返回用户信息,但不能返回密码。
错误做法(直接返回 POJO)
java
@PostMapping("/login")
public Result<User> login(@RequestBody User user) {
User loginUser = userService.login(user.getUsername(), user.getPassword());
return Result.success(loginUser); // 会返回 password、deleted 等敏感字段
}
正确做法(使用 VO)
java
// 定义 UserVO
@Data
public class UserVO {
private Long userId;
private String username;
private String nickname;
private String avatar;
private Integer role;
// 不包含 password、deleted 等敏感字段
}
// Controller
@PostMapping("/login")
public Result<UserVO> login(@RequestBody User user) {
User loginUser = userService.login(user.getUsername(), user.getPassword());
// 转换为 VO
UserVO userVO = new UserVO();
BeanUtils.copyProperties(loginUser, userVO);
return Result.success(userVO); // 只返回需要的字段
}
3.2 场景二:账单统计
需求:统计用户的收支情况,需要从多个表查询并计算。
数据流转
tex
数据库(多表)
↓ Mapper 查询
POJO (Bill, BillType)
↓ Service 组合计算
DTO (BillStatisticsDTO)
↓ Controller 格式化
VO (BillStatisticsVO)
↓ 返回前端
JSON
代码实现
java
// 1. Mapper 层:返回 POJO
@Mapper
public interface BillMapper extends BaseMapper<Bill> {
List<Bill> selectByUserId(Long userId);
}
// 2. Service 层:组合成 DTO
@Service
public class BillServiceImpl implements BillService {
public BillStatisticsDTO getStatistics(Long userId) {
List<Bill> bills = billMapper.selectByUserId(userId);
// 计算统计数据
BigDecimal totalIncome = bills.stream()
.filter(b -> b.getBillFlag() == 1)
.map(Bill::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalExpense = bills.stream()
.filter(b -> b.getBillFlag() == 0)
.map(Bill::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 封装成 DTO
BillStatisticsDTO dto = new BillStatisticsDTO();
dto.setUserId(userId);
dto.setTotalIncome(totalIncome);
dto.setTotalExpense(totalExpense);
dto.setBalance(totalIncome.subtract(totalExpense));
dto.setBillCount(bills.size());
return dto;
}
}
// 3. Controller 层:转换成 VO
@RestController
public class BillController {
@GetMapping("/bill/statistics")
public Result<BillStatisticsVO> getStatistics(HttpServletRequest request) {
Long userId = TokenHelper.getUserId(request);
BillStatisticsDTO dto = billService.getStatistics(userId);
// 转换成 VO(格式化)
BillStatisticsVO vo = new BillStatisticsVO();
vo.setTotalIncome(dto.getTotalIncome().toString() + " 元");
vo.setTotalExpense(dto.getTotalExpense().toString() + " 元");
vo.setBalance(dto.getBalance().toString() + " 元");
vo.setBillCount(dto.getBillCount() + " 笔");
return Result.success(vo);
}
}
3.3 场景三:公告列表(带已读状态)
需求:查询公告列表,显示当前用户是否已读。
数据来源
announcement表(公告信息)user_announcement_read表(用户已读记录)
代码实现
java
// 1. POJO
@Data
@TableName("announcement")
public class Announcement {
private Long announcementId;
private String title;
private String content;
private Integer priority; // 0=普通,1=重要
private Integer status; // 0=草稿,1=已发布,2=已下线
private Long creatorId;
private LocalDateTime createTime;
}
// 2. DTO(组合多表数据)
@Data
public class AnnouncementDTO {
private Long announcementId;
private String title;
private String content;
private Integer priority;
private Integer status;
private String creatorName; // 从 user 表关联查询
private Boolean isRead; // 从 user_announcement_read 表关联查询
private LocalDateTime createTime;
}
// 3. VO(格式化展示)
@Data
public class AnnouncementVO {
private Long announcementId;
private String title;
private String content;
private String priorityText; // "普通" 或 "重要"
private String statusText; // "草稿"、"已发布"、"已下线"
private String creatorName;
private String readStatusText; // "已读" 或 "未读"
private String createTimeText; // "2024-01-15 10:30"
}
// 4. Service 层:POJO → DTO
@Service
public class AnnouncementServiceImpl implements AnnouncementService {
public List<AnnouncementDTO> getAnnouncementList(Long userId) {
// 查询公告列表
List<Announcement> announcements = announcementMapper.selectList(null);
// 转换成 DTO
return announcements.stream().map(announcement -> {
AnnouncementDTO dto = new AnnouncementDTO();
BeanUtils.copyProperties(announcement, dto);
// 查询创建人姓名
User creator = userMapper.selectById(announcement.getCreatorId());
dto.setCreatorName(creator.getNickname());
// 查询是否已读
Boolean isRead = userAnnouncementReadMapper.isRead(userId, announcement.getAnnouncementId());
dto.setIsRead(isRead);
return dto;
}).collect(Collectors.toList());
}
}
// 5. Controller 层:DTO → VO
@RestController
public class AnnouncementController {
@GetMapping("/announcement/list")
public Result<List<AnnouncementVO>> getList(HttpServletRequest request) {
Long userId = TokenHelper.getUserId(request);
List<AnnouncementDTO> dtoList = announcementService.getAnnouncementList(userId);
// 转换成 VO
List<AnnouncementVO> voList = dtoList.stream().map(dto -> {
AnnouncementVO vo = new AnnouncementVO();
vo.setAnnouncementId(dto.getAnnouncementId());
vo.setTitle(dto.getTitle());
vo.setContent(dto.getContent());
vo.setPriorityText(dto.getPriority() == 1 ? "重要" : "普通");
vo.setStatusText(getStatusText(dto.getStatus()));
vo.setCreatorName(dto.getCreatorName());
vo.setReadStatusText(dto.getIsRead() ? "已读" : "未读");
vo.setCreateTimeText(dto.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));
return vo;
}).collect(Collectors.toList());
return Result.success(voList);
}
private String getStatusText(Integer status) {
switch (status) {
case 0: return "草稿";
case 1: return "已发布";
case 2: return "已下线";
default: return "未知";
}
}
}
四、最佳实践
4.1 分层原则
┌─────────────────────────────────────────┐
│ 前端 (JSON) │
└─────────────────────────────────────────┘
↑
VO (格式化、计算)
↑
┌─────────────────────────────────────────┐
│ Controller 层 │
└─────────────────────────────────────────┘
↑
DTO (组合、传输)
↑
┌─────────────────────────────────────────┐
│ Service 层 │
└─────────────────────────────────────────┘
↑
POJO (数据库映射)
↑
┌─────────────────────────────────────────┐
│ Mapper/DAO 层 │
└─────────────────────────────────────────┘
↑
┌─────────────────────────────────────────┐
│ 数据库 │
└─────────────────────────────────────────┘
4.2 命名规范
- POJO :直接用实体名,如
User、Bill、Announcement - DTO :实体名 + DTO,如
BillStatisticsDTO、UserLoginDTO - VO :实体名 + VO,如
UserVO、AnnouncementVO
4.3 包结构
我的项目下的结构,给大家做个例子:

4.4 转换工具:BeanUtils 详解
基本用法
java
// POJO → DTO
BillStatisticsDTO dto = new BillStatisticsDTO();
BeanUtils.copyProperties(bill, dto);
// DTO → VO
UserVO vo = new UserVO();
BeanUtils.copyProperties(dto, vo);
工作原理
BeanUtils.copyProperties(source, target) 会自动匹配同名同类型的字段进行复制:
java
// 源对象
class User {
private Long userId; // 会被复制
private String username; // 会被复制
private String password; // UserVO 没有这个字段,跳过
private Integer age; // UserVO 中是 String 类型,跳过
}
// 目标对象
class UserVO {
private Long userId; // 接收 user.userId
private String username; // 接收 user.username
private String age; // 类型不匹配,不会接收
private String email; // User 没有这个字段,保持 null
}
注意事项
1. 只复制同名同类型的字段
java
class Source {
private Integer age; // Integer 类型
}
class Target {
private String age; // String 类型
}
Target target = new Target();
BeanUtils.copyProperties(source, target);
// target.age 仍然是 null,因为类型不匹配
2. null 值会被复制
java
User user = new User();
user.setUserId(1L);
user.setUsername("张三");
user.setNickname(null); // null 值
UserVO vo = new UserVO();
vo.setNickname("默认昵称"); // 先设置一个默认值
BeanUtils.copyProperties(user, vo);
// vo.nickname 会被覆盖为 null!
解决方案:复制后手动处理 null 值
java
BeanUtils.copyProperties(user, vo);
if (vo.getNickname() == null) {
vo.setNickname("默认昵称");
}
3. 浅拷贝问题
java
class User {
private List<String> tags; // 引用类型
}
User user1 = new User();
user1.setTags(Arrays.asList("标签1", "标签2"));
User user2 = new User();
BeanUtils.copyProperties(user1, user2);
// 修改 user2 的 tags
user2.getTags().add("标签3");
// user1 的 tags 也被修改了!
System.out.println(user1.getTags()); // [标签1, 标签2, 标签3]
原因 :BeanUtils 是浅拷贝,只复制引用,不复制对象本身。
解决方案:需要深拷贝时,手动处理
java
BeanUtils.copyProperties(user1, user2);
user2.setTags(new ArrayList<>(user1.getTags())); // 创建新的 List
4. 嵌套对象不会递归复制
java
class User {
private Address address; // 嵌套对象
}
class Address {
private String city;
}
User user1 = new User();
user1.setAddress(new Address());
user1.getAddress().setCity("北京");
User user2 = new User();
BeanUtils.copyProperties(user1, user2);
// user2.address 和 user1.address 是同一个对象!
user2.getAddress().setCity("上海");
System.out.println(user1.getAddress().getCity()); // 上海
解决方案:手动复制嵌套对象
java
BeanUtils.copyProperties(user1, user2);
Address newAddress = new Address();
BeanUtils.copyProperties(user1.getAddress(), newAddress);
user2.setAddress(newAddress);
参数顺序
注意 :Spring 的 BeanUtils 和 Apache 的 BeanUtils 参数顺序相反!
java
// Spring BeanUtils(推荐)
BeanUtils.copyProperties(source, target); // 源在前,目标在后
// Apache BeanUtils(不推荐)
BeanUtils.copyProperties(target, source); // 目标在前,源在后
记忆技巧:Spring 的顺序更符合直觉(从哪里复制到哪里)。
性能对比
| 工具 | 性能 | 使用难度 | 推荐场景 |
|---|---|---|---|
| BeanUtils | 中等(反射) | 简单 | 一般业务场景 |
| BeanCopier | 快(字节码) | 中等 | 高性能场景 |
| MapStruct | 最快(编译期生成代码) | 复杂 | 大型项目 |
| 手动 set | 最快 | 最麻烦 | 字段少的场景 |
建议:
- 一般项目用
BeanUtils,够用且简单 - 高并发场景用
BeanCopier或MapStruct - 不要过度优化,先保证代码可读性
最佳实践
java
// 推荐:先复制,再手动设置特殊字段
UserVO vo = new UserVO();
BeanUtils.copyProperties(user, vo);
vo.setRoleText(user.getRole() == 1 ? "管理员" : "普通用户"); // 格式化字段
vo.setCreateTimeText(user.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
// 不推荐:全部手动 set(太麻烦)
UserVO vo = new UserVO();
vo.setUserId(user.getUserId());
vo.setUsername(user.getUsername());
vo.setNickname(user.getNickname());
// ... 20 个字段要写 20 行
// 不推荐:复制后不处理特殊字段(不灵活)
UserVO vo = new UserVO();
BeanUtils.copyProperties(user, vo);
// role、createTime 等字段没有格式化,前端还要自己处理
五、常见问题
问题1:什么时候可以不用 DTO/VO?
答:
- 简单的 CRUD 操作,且不涉及敏感字段
- 内部接口,不对外暴露
- 对外接口,必须使用 DTO/VO
问题2:DTO 和 VO 可以合并吗?
答:
- 小项目可以合并,减少类的数量
- 大项目建议分开,职责更清晰
问题3:POJO 可以直接返回给前端吗?
答:
- 不推荐,会暴露敏感字段(password、deleted、version)
- 不灵活,无法自定义返回字段
- 使用 VO 更安全、更灵活
问题4:为什么不直接在 POJO 上加
@JsonIgnore?
答:
- POJO 是数据库映射,不应该包含展示逻辑
- 不同接口可能需要返回不同的字段,
@JsonIgnore无法灵活控制 - 使用 VO 可以针对不同接口定制不同的返回字段
六、总结
核心要点
- POJO:数据库映射,只在 Mapper 层使用
- DTO:层间传输,Service 和 Controller 之间使用
- VO:前端展示,Controller 返回给前端使用
记忆口诀
- POJO:我是数据库的镜像
- DTO:我是层间传输的信使
- VO:我是前端展示的模特
使用建议
- 严格分层,职责清晰
- 不要在 POJO 中包含展示逻辑
- 不要直接返回 POJO 给前端
- 使用 BeanUtils 简化转换
- 根据项目规模灵活调整(小项目可以简化)
如果这篇文章对你有帮助,请点赞、收藏、关注!有问题欢迎在评论区讨论。
作者:[识君啊]
不要做API的搬运工,要做原理的探索者!