⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 http://www.iocoder.cn/Fight/biezaihunxiaolePOVOBODTODAOPOJO-yiwenchedigaodong/ 「芋道源码」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

第一次打开公司项目,包结构里一堆类:OrderPO、OrderVO、OrderDTO、OrderBO……同一个订单,为什么要写四个类?

做了几个项目才搞明白:不是为了显得复杂,是数据在系统里每流过一层,关心的东西就不一样。数据库关心字段映射,业务层关心逻辑判断,前端关心显示格式。一个类全干,改一处就崩一片。

一张图看懂数据怎么流

三层架构对象分布图:表现层用VO,业务逻辑层用BO和DTO,数据持久层用PO和DAO,POJO作为所有对象的基础形态

数据从数据库到用户屏幕,经过三道转手:

  • 持久层:PO 对应数据库表,DAO 负责 CRUD
  • 业务层:BO 执行业务逻辑,DTO 在层与层之间搬运数据
  • 表现层:VO 把数据格式化成前端想要的样子

每个对象的边界明确,改一层不影响另一层。

POJO:所有对象的白板底座

Plain Old Java Object——普通的 Java 对象,不继承框架基类,不实现框架接口,没有任何注解依赖。

// 这就是一个 POJO:干净、独立、不依赖任何框架
public class Order {
private Long id;
private Long userId;
private BigDecimal amount;
private Integer status;
private LocalDateTime createTime;

// getter / setter 省略
}

PO 是加了 @Entity 的 POJO,DTO 是加了 Serializable 的 POJO,VO 是专门用于展示的 POJO。POJO 是白纸,加上不同的用途约束就变成了其他对象。

PO:数据库长什么样,它就长什么样

Persistent Object。一张 t_order 表对应一个 OrderPO,字段名、类型严格映射:

@Entity
@Table(name = "t_order")
public class OrderPO {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "user_id", nullable = false)
private Long userId;

@Column(name = "total_amount", precision = 10, scale = 2)
private BigDecimal totalAmount;

@Column(name = "status")
private Integer status; // 0-待支付 1-已支付 2-已取消 3-已退款

@Column(name = "pay_time")
private LocalDateTime payTime;

@Column(name = "create_time", updatable = false)
private LocalDateTime createTime;

// getter / setter
}

MyBatis 项目里 PO 不需要 JPA 注解,就是个干净的 POJO,映射关系写在 XML 里。

最常见的错误:Controller 直接返回 PO。后果——密码、盐值、删除标记等敏感字段直接暴露给前端;数据库改个字段名,前端立刻崩。

DAO:数据库的接线员

Data Access Object——封装所有数据库操作,让业务层看不到 SQL。

// JPA 风格:接口 + 方法名自动推导 SQL
@Repository
public interface OrderDAO extends JpaRepository<OrderPO, Long> {

List<OrderPO> findByUserIdAndStatus(Long userId, Integer status);

@Query("SELECT o FROM OrderPO o WHERE o.totalAmount > :minAmount AND o.createTime > :since")
List<OrderPO> findLargeOrders(@Param("minAmount") BigDecimal minAmount,
@Param("since") LocalDateTime since);
}
// MyBatis 风格:接口 + XML 映射
public interface OrderMapper {

OrderPO selectById(@Param("id") Long id);

List<OrderPO> selectByUserId(@Param("userId") Long userId);

int insert(OrderPO order);

int updateStatus(@Param("id") Long id,
@Param("status") Integer status,
@Param("updateTime") LocalDateTime updateTime);
}

DAO 的价值:今天用 MySQL,明天换 PostgreSQL,业务代码一行不改。 数据源切换只影响 DAO 实现层。

BO:业务规则活在这里

Business Object——承载业务逻辑的对象,活在 Service 层,可能由多个 PO 组合而成。

“能不能下单”这个判断不属于 PO(PO 只管存数据),也不属于 DAO(DAO 只管读写),它属于 BO:

public class OrderBO {
private OrderPO order;
private UserPO user;
private List<OrderItemPO> items;
private CouponPO coupon;

/**
* 判断订单是否可以发起退款
* 规则:已支付 + 未发货 + 下单不超过7天
*/
public boolean canRefund() {
if (order.getStatus() != 1) return false; // 非已支付
if (order.getShipTime() != null) return false; // 已发货
return Duration.between(order.getCreateTime(), LocalDateTime.now()).toDays() <= 7;
}

/**
* 计算实付金额:商品总价 - 优惠券抵扣
*/
public BigDecimal calculatePayAmount() {
BigDecimal itemTotal = items.stream()
.map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);

if (coupon != null && coupon.isValid()) {
return itemTotal.subtract(coupon.getDiscount()).max(BigDecimal.ZERO);
}
return itemTotal;
}
}

实际项目里 BO 有时候就是 @Service 类本身,有时候是 Service 内部的中间对象。不用纠结形式,核心是:业务判断放这一层,不散落到 Controller 和 DAO 里。

DTO:跨边界的搬运工

Data Transfer Object——在层之间、服务之间搬运数据,只搬不算。

微服务时代 DTO 用得极多:A 服务调 B 服务,不可能把整个 PO 扔过去(字段暴露 + 耦合)。DTO 只暴露必要字段:

// 服务间传输用的 DTO,只包含对方需要的字段
public class OrderDTO implements Serializable {
private static final long serialVersionUID = 1L;

private Long orderId;
private Long userId;
private String userName; // 跨服务带过来的冗余字段
private BigDecimal payAmount;
private String statusDesc; // "已支付",不是 Integer 1
private LocalDateTime createTime;
// 没有密码、没有内部状态码、没有关联对象

// getter / setter
}
// 请求参数也常用 DTO 封装
public class CreateOrderDTO {
@NotNull(message = "商品ID不能为空")
private Long productId;

@Min(value = 1, message = "数量至少为1")
private Integer quantity;

private Long couponId; // 可选

// getter / setter
}

DTO 通常实现 Serializable(走网络得序列化),结构扁平,不含业务方法。

VO:前端要什么就给什么

Value Object——离用户最近的对象,所有字段按前端需要来组织:

public class OrderVO {
private Long id;
private String orderNo; // "2024061800001"
private String statusText; // "待发货",不是 Integer 2
private String statusColor; // "#FF6B00",前端直接渲染
private String totalAmountDisplay; // "¥ 299.00"
private String createTimeDisplay; // "2024年6月18日 14:30"
private String payTimeDisplay; // "3分钟前"(相对时间)
private List<OrderItemVO> items; // 嵌套的商品列表 VO
private Boolean canCancel; // 按钮是否可点
private Boolean canRefund;

// getter / setter
}

数据库存的是 status = 2、total_amount = 299.00、create_time = 2024-06-18 14:30:22。前端不需要知道这些原始值——VO 直接给格式化好的字符串、计算好的状态。

VO 和 DTO 最容易混淆:DTO 强调”传输”,是中间载体,任意层都能用;VO 强调”展示”,是终点站,只给前端。

一次请求的完整对象流转

对象转换链路图:从数据库经PO→BO→DTO→VO,最终到达前端,DAO负责数据库与PO之间的桥接

以”查询订单详情”为例:

前端请求 → Controller 接收参数
→ Service 调 DAO.selectById() 拿回 OrderPO
→ PO 组装成 BO,执行业务判断(canRefund? canCancel?)
→ BO 转成 DTO 返回给 Controller
→ Controller 把 DTO 转成 VO(格式化时间、金额、状态文案)
→ 返回前端

每一步转换的理由:

  • PO 不暴露前端 → 安全
  • BO 不跨服务传输 → 解耦
  • DTO 不带业务方法 → 职责清晰
  • VO 不对应数据库结构 → 前后端独立演进

什么时候该分、什么时候不必

项目规模 建议
个人/Demo 项目 一个 Model 走天下,别折腾
3-5 人小项目 PO + VO 就够,中间层按需加
多人协作/微服务 严格分层,PO/BO/DTO/VO 全套

判断标准:如果你改了一个数据库字段,前端 API 也得跟着改——说明你需要分层了。如果服务 A 改了内部逻辑,服务 B 也得重新编译——说明你的 DTO 边界没划好。

分层的本质不是为了”看起来专业”,是为了改一处不牵动全身。当项目只有你一个人维护的时候,这些分层只是负担。但只要超过三个人协作,这套规矩就是省心的选择。

文章目录
  1. 1. 一张图看懂数据怎么流
  2. 2. POJO:所有对象的白板底座
  3. 3. PO:数据库长什么样,它就长什么样
  4. 4. DAO:数据库的接线员
  5. 5. BO:业务规则活在这里
  6. 6. DTO:跨边界的搬运工
  7. 7. VO:前端要什么就给什么
  8. 8. 一次请求的完整对象流转
  9. 9. 什么时候该分、什么时候不必