做订单详情接口改造那阵子,我把一个 Spring 项目里所有叫XxxVO、XxxQO、XxxBO的类全导出来列了一遍,结果挺难看:同一个业务对象有七个版本,其中三个内容完全一样,还有两个 VO 里躺着数据库字段is_deleted和内部风控标记。这类问题在中小团队里极其普遍——VO、QO、BO 在 Spring 项目中的位置,表面看是包怎么建、类放哪儿的命名问题,往深了说其实是"谁拥有这份数据、谁有权修改它、谁能看见它"的边界问题。放对了,接口改动不牵连业务逻辑,换前端不动后端;放错了,一个字段的增删能在三层之间引发连锁反应。这篇内容面向写过 Spring Boot、但对象分层一直靠感觉走的开发者,我会把每个对象该落在哪一层、包怎么建、转换写在哪儿、以及我踩过的那些坑讲透,看完你可以直接照着改自己手头的项目结构。
1. 三个"O"到底在解决什么问题
1.1 从一次订单接口改造说起
当时的场景很典型:运营要在订单列表加上"最近一次退款时间",前端要在详情页加一个"预计送达倒计时"。需求本身不复杂,但代码是这样的——Mapper 直接返回OrderVO,Service 原样透传,Controller 直接return orderVO。也就是说,OrderVO同时兼任了数据库映射对象、业务对象和接口出参三个角色。加一个退款时间字段,你得同时改 Mapper 的 resultMap、改 Service 的计算逻辑、还得确认前端序列化不受影响;更麻烦的是详情页要的倒计时,依赖的是一个耗时计算,如果放在列表接口里做,几百条数据会直接把响应时间拉长到秒级。
问题的根子不在技术,在于这个类没有明确的"归属层"。当同一个类同时被三层引用,任何一层的新需求都会侵入其他层。后来我们做的第一件事不是重构代码,而是先定义清楚:OrderVO只属于接口层,它只描述"前端看到的样子";订单的业务状态流转属于业务层,用OrderBO或者干脆用领域实体表达;数据库那张表长什么样,只有持久层的OrderPO说了算。三件事拆开之后,列表接口的 VO 里不放倒计时字段,详情接口单独组装,性能问题自然就没了。
这个案例说明一件事:对象分层不是为了好看,是为了让变化被隔离在最小的范围内。前端改版只动 VO,业务规则调整只动 BO,表结构变更只动 PO。任何一次修改的影响半径,取决于你当初把对象放在了哪儿。
1.2 VO、QO、BO 与 DO/PO/DTO 的角色对照
很多人分不清这几个"O",是因为不同团队、不同文章给的缩写含义不一致。我按国内 Spring 项目里最常见的一套约定来梳理,这套约定在大多数中后台系统里足够用:
| 缩写 | 全称 | 中文 | 归属层 | 核心职责 | 典型包路径 |
|---|---|---|---|---|---|
| VO | View Object | 视图对象 | 接口层 | 面向前端/调用方的出参结构,做字段裁剪与展示组装 | interfaces.vo或web.vo |
| QO | Query Object | 查询对象 | 接口层/应用层 | 收敛查询条件、分页排序参数,负责入参校验 | interfaces.qo或application.query |
| BO | Business Object | 业务对象 | 领域层 | 承载业务状态与业务行为,是业务规则的落点 | domain.model或service.bo |
| PO | Persistent Object | 持久化对象 | 持久层 | 与数据库表结构一一对应,只做数据搬运 | infrastructure.persistence.po |
| DTO | Data Transfer Object | 传输对象 | 应用层/跨服务 | 跨进程、跨模块传输,比如 RPC 调用参数与返回 | application.dto或api.dto |
| DO | Data Object | 数据对象 | 持久层 | 多数团队里等价于 PO,叫法不同而已 | 同上 |
这张表里最关键的一列不是"缩写",是"归属层"。VO 之所以叫视图对象,是因为它的唯一消费者是视图(页面或者外部调用方);BO 之所以叫业务对象,是因为它必须能表达"订单已支付但不能发货"这种业务状态,而不只是几个字段的容器。QO 容易被忽略,很多项目直接拿实体类接@RequestBody,参数一多就混乱,查询条件的默认值、上限、日期区间校验全散落在 Service 里,最后谁也不敢动。
我把 DTO 单独列出来,是因为它在 Spring 微服务项目里有实际用途:服务 A 调服务 B 的 Feign 接口,传的参数和接收的结果,既不该用 B 的内部 BO(耦合太深),也不该用 B 的 VO(VO 是给前端看的,字段口径不同),这时候用一组稳定的 DTO 做契约最合适。DTO 的定位是"跨边界传输",边界在哪儿,DTO 就定义在哪儿。
1.3 为什么 Spring 的注解让边界更容易糊掉
Spring 这套东西太顺手了,顺手到容易让人忘记对象之间的层次差异。@Entity一贴,类就成了表映射;@RestController一写,方法返回值直接被序列化成 JSON;@RequestBody一加,请求体自动变成对象。三个注解串起来,一条"从数据库到浏览器"的直通车就打通了,中间不需要任何转换代码。项目初期这么写效率极高,类少、代码短、上手快。
代价在半年后显现。@Entity标注的类被 Jackson 序列化时,懒加载的关联对象会触发额外查询,甚至抛LazyInitializationException;实体里加了@JsonIgnore解决序列化问题,结果内部 RPC 调用也用这个类,把需要的字段一起忽略了;为了防止前端看到敏感字段,在实体字段上贴一堆@JsonIgnore和@JsonProperty,实体类变成了注解垃圾场,谁也说不清它到底是数据库映射还是接口契约。
Spring 的设计本身没有问题,问题在于它默认鼓励"少写类"。而分层的本质恰恰是"多写几个类,换来看得清的边界"。我的判断标准很简单:如果一个类上同时出现了持久化注解(@Entity、@TableName、@TableId)和序列化/校验注解(@JsonFormat、@NotBlank、@ApiModelProperty),那这个类的归属就已经乱了,它至少有两条变化的理由,迟早要拆。
提醒:不是所有项目都必须拆到 PO/BO/VO 全套。小项目、内部工具、原型阶段,用两三个类换开发速度是合理取舍。但你要清楚这是"主动选择"而不是"不知道还能拆",因为一旦业务变复杂、多人协作、对外提供接口,边界成本会以指数级还回来。
2. 分层视角下每个对象该落在哪个包
2.1 经典分层与对象的归属位置
Spring 项目最通用的分层是四层:接口层(interfaces/web/adapter)、应用层(application)、领域层(domain)、基础设施层(infrastructure)。这个结构来自六边形架构和整洁架构的简化版,核心思想是依赖方向单向向内:外层依赖内层,内层不知道外层的存在。
落到目录上,我常用的一种结构长这样:
com.example.order ├── interfaces // 接口层:面向外部 │ ├── controller // OrderController │ ├── vo // OrderDetailVO、OrderItemVO │ ├── qo // OrderPageQO、OrderExportQO │ └── assembler // OrderVoAssembler:BO/DO -> VO ├── application // 应用层:编排用例 │ ├── service // OrderAppService │ ├── dto // 对外RPC契约 │ └── converter // QO -> 查询条件、DTO <-> BO ├── domain // 领域层:业务规则 │ ├── model // OrderBO、OrderStatus │ ├── service // OrderDomainService │ └── repository // OrderRepository 接口(只有接口) └── infrastructure // 基础设施层:技术实现 ├── persistence │ ├── po // OrderPO、OrderItemPO │ ├── mapper // OrderMapper(MyBatis) │ └── repository // OrderRepositoryImpl └── config对照着看,每个对象的落点就清楚了:VO 和 QO 在interfaces下,BO 在domain.model下,PO 在infrastructure.persistence.po下。注意domain.repository里只有接口,实现放在基础设施层,这就是依赖倒置——领域层定义"我需要什么能力",基础设施层负责"怎么实现",领域层完全不认识 MyBatis 和数据库。
有人会问 QO 是不是该放应用层。两种做法我都用过,结论是看 QO 的用途:如果 QO 只服务于 HTTP 接口,放interfaces.qo;如果同一个查询条件还要被定时任务、消息消费复用,那它其实是应用层的输入模型,放application.query更合适。关键是全项目统一,别一半在接口层一半在应用层,找代码的时候来回跳。
2.2 按层分包还是按功能分包,怎么选
包组织有两种流派。按层分包是上面那种,controller、service、dao各一个大包,所有业务的类都往里塞。按功能分包(也叫按模块分包)是先按业务域切分,每个域内部再分层:
com.example ├── order // 订单域 │ ├── interfaces │ ├── application │ ├── domain │ └── infrastructure ├── payment // 支付域 │ └── ... └── user // 用户域 └── ...我的实际体验是:业务域少于五个、团队三五个人的项目,按层分包够用;一旦域变多、开始有跨域调用,按功能分包明显更好维护。原因是按层分包时,改一个订单需求要横跨四个大包找文件,而且包内类数量膨胀到几十个之后,IDE 的包视图基本失去导航价值;按功能分包时,一个需求的代码集中在一个目录子树里,边界也天然清晰——订单域不该直接引用支付域的 PO。
还有一个容易被忽视的细节:不要把 VO 和 PO 放在同一个模块里。如果项目是多模块 Maven/Gradle 结构,接口契约(VO/QO/DTO)应该放在独立的xxx-api模块,业务实现放在xxx-service模块。这样前端团队或者其他服务只依赖 api 模块,就能拿到契约类,不会把持久层、MyBatis 依赖一起拖进去。我见过一个项目把 PO 和 VO 混在一个模块,结果调用方为了用一个 VO,被迫引入了整个 ORM 框架和数据库驱动,启动时还试图连库,排查了半天。
2.3 依赖方向:谁能看见谁
对象放对位置只是第一步,更关键的是约束"谁能引用谁"。我总结成几条硬规则,团队里可以直接当约定用:
- 接口层可以依赖应用层、可以依赖 VO/QO,但不允许直接依赖 PO 和 Mapper。
- 应用层可以依赖领域层,可以引用 BO,但不应该出现 VO 类型——Service 返回 VO 是典型的层次倒置。
- 领域层只依赖自身的模型和仓储接口,不能出现 Spring Web、MyBatis、Jackson 的任何类型。
- 持久层实现仓储接口,PO 只在持久层内部流转,出持久层必须转成 BO 或明确的查询结果对象。
这几条里最常被破的是第二条。Controller 图省事,让 Service 直接返回 VO,理由是"反正就这几个字段"。一旦这么写,Service 就被绑死在当前接口上了:同一个业务逻辑要给后台管理端和小程序端用,两边字段要求不同,你只能在 Service 里写 if-else 判断调用来源,或者复制一份方法。正确的做法是 Service 返回 BO 或者应用层 DTO,由接口层的 Assembler 各自组装成不同的 VO。多写一个转换类,换来的是 Service 可以复用。
怎么保证这些规则不被悄悄破坏?纯靠 Code Review 不靠谱,人会疲劳。我推荐用 ArchUnit 写几条架构测试,把它变成 CI 的一部分:
@AnalyzeClasses(packages = "com.example.order") class LayerArchitectureTest { @ArchTest static final ArchRule domain_should_not_depend_on_infrastructure = noClasses().that().resideInAPackage("..domain..") .should().dependOnClassesThat() .resideInAnyPackage("..infrastructure..", "org.springframework.web..", "org.apache.ibatis.."); @ArchTest static final ArchRule vo_should_only_live_in_interfaces = classes().that().haveSimpleNameEndingWith("VO") .should().resideInAPackage("..interfaces.vo.."); }这类测试写起来十分钟,跑起来几毫秒,但能在别人(包括三个月后的你)不小心 import 错包时立刻报错。比在文档里写十条规范有用得多。
3. 字段设计与对象转换的实操细节
3.1 VO 的字段裁剪与组装策略
VO 的设计原则只有一条:前端需要什么就给什么,一个字都不多给。听起来简单,做起来要处理三类问题。
第一类是字段形态。数据库存的是status的枚举值1/2/3,前端要显示"待付款/已发货/已完成",那 VO 里就该同时有status和statusDesc,让前端不用维护一份状态字典。金额字段数据库存的是BigDecimal分,前端要展示元,VO 里给格式化好的字符串或者明确单位的数值,注释写清楚。时间字段统一用yyyy-MM-dd HH:mm:ss格式的字符串,或者明确带时区的 ISO 格式,别让前端猜。
第二类是字段裁剪。列表接口和详情接口必须用不同的 VO。列表页只需要订单号、状态、金额、下单时间,详情页才需要收货地址、商品明细、物流轨迹。我见过太多项目用一个OrderVO打天下,列表接口返回几百条,每条都带着几 KB 的商品详情 JSON,响应体轻松上兆,前端解析都卡。拆成OrderListVO和OrderDetailVO之后,列表接口响应体直接降了一个数量级。
第三类是组装逻辑的位置。VO 的组装放在接口层的 Assembler 里,不要塞进 Controller 方法体,也不要让 Service 参与。Assembler 的职责很纯粹:拿 BO 或查询结果,补齐字典翻译、格式化、字段映射,输出 VO。
@Component public class OrderVoAssembler { private final DictClient dictClient; public OrderVoAssembler(DictClient dictClient) { this.dictClient = dictClient; } public OrderDetailVO toDetailVO(OrderBO bo) { if (bo == null) { return null; } OrderDetailVO vo = new OrderDetailVO(); vo.setOrderNo(bo.getOrderNo()); vo.setStatus(bo.getStatus().getCode()); vo.setStatusDesc(bo.getStatus().getDesc()); vo.setPayAmount(bo.getPayAmount().setScale(2, RoundingMode.HALF_UP).toPlainString()); vo.setCreateTime(bo.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); vo.setItems(bo.getItems().stream().map(this::toItemVO).collect(Collectors.toList())); return vo; } }这里有个性能上的注意点:字典翻译不要在循环里逐个查。上面代码里的statusDesc是从枚举直接取的,没有远程调用;如果状态描述来自配置中心或者数据库字典表,一定要先收集所有需要的 key,批量查一次再回填,否则列表页一百条数据就是一百次远程调用。这个坑我在两个项目里都踩过,接口 P99 从 80ms 涨到 1.2s,最后定位就是 Assembler 里的循环调用。
3.2 QO 的收敛、校验与分页参数
QO 最容易出问题的地方不是字段怎么定义,是默认值和边界校验。分页参数没有上限,前端传个pageSize=100000,数据库直接被打挂;日期区间不校验先后顺序,开始时间大于结束时间,SQL 查不出数据还被当成 bug 排查半天;字符串条件不 trim,用户复制粘贴带了个空格,模糊查询全表扫描。
我的做法是把 QO 当成一个独立的输入契约来处理,字段上直接挂校验注解,Controller 加@Validated,边界值在 QO 内部兜底:
public class OrderPageQO { private static final int MAX_PAGE_SIZE = 200; @Min(value = 1, message = "页码不能小于1") private Integer pageNum = 1; @Min(value = 1, message = "每页条数不能小于1") private Integer pageSize = 20; @Size(max = 50, message = "关键字长度不能超过50") private String keyword; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime startTime; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime endTime; public Integer getPageSize() { if (pageSize == null || pageSize < 1) { return 20; } return Math.min(pageSize, MAX_PAGE_SIZE); } public String getKeyword() { return keyword == null ? null : keyword.trim(); } public LocalDateTime getStartTime() { if (startTime != null && endTime != null && startTime.isAfter(endTime)) { throw new IllegalArgumentException("开始时间不能晚于结束时间"); } return startTime; } }pageSize的 getter 里做上限截断,看起来有点"偷偷改用户输入"的意味,但这是防御性设计——前端传错了不该让数据库承担后果,截断到 200 比直接拒绝请求体验更好,日志里记一条 warn 就够了。关键字 trim 也是同理,用户从表格里复制的内容经常带首尾空格。
还有一个设计决策:QO 要不要包含排序字段。我的建议是包含,但必须是白名单。orderBy传个任意字符串拼进 SQL,那就是注入漏洞;维护一个允许排序的字段列表,传不在列表里的值就忽略或者报错。这个细节很多团队在项目上线被安全扫描打回来之后才补。
3.3 BO 承载业务行为的写法
BO 和 VO 最大的区别在于:BO 有行为,VO 只有数据。如果一个 BO 里全是 getter/setter,没有任何方法,那它本质上就是个 DTO,叫 BO 有点名不副实。
举个例子,订单状态流转。用贫血模型写,逻辑散落在 Service 里:
public void ship(Long orderId) { OrderPO po = orderMapper.selectById(orderId); if (po.getStatus() != 2) { throw new BizException("只有已支付订单可以发货"); } po.setStatus(3); orderMapper.updateById(po); }这种写法的问题是,业务规则"已支付才能发货"这个知识,被埋在了某个 Service 方法里。别的入口(比如批量发货、运营后台强制发货)如果没调用这个方法,就没走这条校验。时间一长,规则会以复制粘贴的形式散落在五六个地方,改一次规则要全局搜索。
改成 BO 承载行为:
public class OrderBO { private Long id; private String orderNo; private OrderStatus status; private BigDecimal payAmount; private LocalDateTime payTime; private List<OrderItemBO> items; public void ship(String logisticsNo) { if (this.status != OrderStatus.PAID) { throw new BizException("只有已支付订单可以发货"); } if (StringUtils.isBlank(logisticsNo)) { throw new BizException("物流单号不能为空"); } this.status = OrderStatus.SHIPPED; } public boolean canRefund() { return this.status == OrderStatus.PAID || this.status == OrderStatus.SHIPPED; } }规则跟着状态走,任何入口只要操作 BO,校验都会执行。这就是领域模型的价