POJO、DTO、VO 详解:区别、联系与最佳实践

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 :直接用实体名,如 UserBillAnnouncement
  • DTO :实体名 + DTO,如 BillStatisticsDTOUserLoginDTO
  • VO :实体名 + VO,如 UserVOAnnouncementVO

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,够用且简单
  • 高并发场景用 BeanCopierMapStruct
  • 不要过度优化,先保证代码可读性
最佳实践
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 可以针对不同接口定制不同的返回字段

六、总结

核心要点

  1. POJO:数据库映射,只在 Mapper 层使用
  2. DTO:层间传输,Service 和 Controller 之间使用
  3. VO:前端展示,Controller 返回给前端使用

记忆口诀

  • POJO:我是数据库的镜像
  • DTO:我是层间传输的信使
  • VO:我是前端展示的模特

使用建议

  • 严格分层,职责清晰
  • 不要在 POJO 中包含展示逻辑
  • 不要直接返回 POJO 给前端
  • 使用 BeanUtils 简化转换
  • 根据项目规模灵活调整(小项目可以简化)

如果这篇文章对你有帮助,请点赞、收藏、关注!有问题欢迎在评论区讨论。

作者:[识君啊]

不要做API的搬运工,要做原理的探索者!

相关推荐
寻寻觅觅☆9 小时前
东华OJ-基础题-106-大整数相加(C++)
开发语言·c++·算法
l1t9 小时前
在wsl的python 3.14.3容器中使用databend包
开发语言·数据库·python·databend
青云计划10 小时前
知光项目知文发布模块
java·后端·spring·mybatis
赶路人儿10 小时前
Jsoniter(java版本)使用介绍
java·开发语言
Victor35610 小时前
MongoDB(9)什么是MongoDB的副本集(Replica Set)?
后端
Victor35610 小时前
MongoDB(8)什么是聚合(Aggregation)?
后端
ceclar12310 小时前
C++使用format
开发语言·c++·算法
探路者继续奋斗11 小时前
IDD意图驱动开发之意图规格说明书
java·规格说明书·开发规范·意图驱动开发·idd
码说AI11 小时前
python快速绘制走势图对比曲线
开发语言·python
Gofarlic_OMS11 小时前
科学计算领域MATLAB许可证管理工具对比推荐
运维·开发语言·算法·matlab·自动化