04-项目统一VO/DO/DTO分层规范与数据库映射
2026/8/22 0:25:37 网站建设 项目流程

项目统一VO/DO/DTO分层规范与数据库映射

黒漂技术佬 · 2026年7月

前言

前面三篇文章,我们把"从数据库到接口"的技术链路打通了:连接池配置 → MyBatis CRUD → MyBatis-Plus 提效。看起来代码跑起来了,功能也 OK——但当你接手一个运行了两年的项目时,你可能看到的是这样的场景:

  • Controller 层直接拿数据库实体返回给前端,于是用户密码的哈希值被暴露在了接口响应里
  • Service 层的 DTO 和 Controller 层的 VO 混在一起,想改一个字段名称要全局搜索
  • 三张表联查的 VO 字段一股脑塞进了一个"万金油 DO",DO 膨胀到了 80 个字段

这,就是没有分层规范的代价。今天这篇文章,把我踩过的坑和经验总结成一套清晰的分层规范,看完你就能在自己的项目里落地。

一、为什么要分层?一个真实反例

假设你在开发无人售货柜的订单系统,数据库表长这样:

CREATETABLEt_order(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_idBIGINT,device_idBIGINT,total_amountDECIMAL(10,2),pay_channelTINYINT,-- 支付渠道编码statusTINYINT,-- 订单状态编码is_deletedTINYINTDEFAULT0,create_timeDATETIME,update_timeDATETIME);

如果不分层,你从 Controller 直接返回Order实体:

@GetMapping("/{id}")publicOrdergetOrder(@PathVariableLongid){returnorderService.getById(id);}

返回的 JSON 就会是这样的:

{"id":1,"userId":1001,"deviceId":5,"totalAmount":15.50,"payChannel":2,"status":1,"isDeleted":0,"createTime":"2026-07-30T10:00:00","updateTime":"2026-07-30T10:05:00"}

问题出在哪?

  1. isDeleted暴露了:这是逻辑删除标记,前端不应该知道它的存在
  2. payChannel = 2status = 1:前端看到数字一脸懵——“微信支付"还是"支付宝”?“待付款"还是"已完成”?
  3. 字段名和含义不匹配:如果前端想要"支付方式名称"、“订单状态文本”,你还得在后端挨个翻译
  4. userId泄密:虽然不是明文密码,但用户 ID 直接暴露也可能有安全隐患

分层的目的:在数据在每一层之间流动时,保持合适的"信息密度"——不是越少越好,而是只暴露上层真正需要的东西。

二、分层规范定义

业界成熟的实践是四层模型。但注意:四个层不代表四套代码文件,小项目可以灵活取舍。

┌─────────────────────────────────────────────┐ │ 前端 (Web / App / Mini Program) │ └─────────────────┬───────────────────────────┘ │ 接收 VO ┌─────────────────▼───────────────────────────┐ │ Controller 层 ←── 接收和返回 VO │ └─────────────────┬───────────────────────────┘ │ 传递 DTO ┌─────────────────▼───────────────────────────┐ │ Service 层 ←── 使用 DTO/BO 传输数据 │ └─────────────────┬───────────────────────────┘ │ 使用 DO ┌─────────────────▼───────────────────────────┐ │ Mapper 层 ←── DO 映射数据库表 │ └─────────────────┬───────────────────────────┘ │ ┌─────────────────▼───────────────────────────┐ │ MySQL 数据库 │ └─────────────────────────────────────────────┘

各对象定义

简称全称职责所在层
DOData Object与数据库表字段一一对应,不包含业务逻辑Mapper 层 / DAO 层
DTOData Transfer Object服务间传输数据,可能包含多个 DO 的聚合字段Service 层之间
VOView Object返回给前端的视图对象,包含翻译后的展示字段Controller 层 → 前端
BOBusiness Object封装复杂业务逻辑的中间对象(可选)Service 层内部

DO:数据库的"照相机"

@Data@TableName("t_order")publicclassOrderDO{@TableId(type=IdType.AUTO)privateLongid;privateLonguserId;privateLongdeviceId;privateBigDecimaltotalAmount;privateIntegerpayChannel;// 1=微信 2=支付宝 3=银行卡privateIntegerstatus;// 0=待付款 1=已付款 2=已取消privateIntegerisDeleted;// 逻辑删除privateLocalDateTimecreateTime;privateLocalDateTimeupdateTime;}

DO 的精髓是"纯粹":字段必须和表字段严格对应,不加任何业务逻辑,不加任何多余字段。你改表结构的时候,只需要改 DO 和对应的 XML SQL。

DTO:服务间的"快递包裹"

@DatapublicclassOrderCreateDTO{@NotNull(message="用户ID不能为空")privateLonguserId;@NotNull(message="设备ID不能为空")privateLongdeviceId;@NotEmpty(message="订单明细不能为空")privateList<OrderItemDTO>items;}@DatapublicclassOrderItemDTO{privateLongproductId;privateIntegerquantity;}

DTO 的特点是:它不需要和数据库表一一对应。一个OrderCreateDTO可能包含用户 ID、设备 ID、商品列表——这背后是多个 DO 的聚合。

VO:前端的"展示菜单"

@DatapublicclassOrderVO{privateLongid;privateStringdeviceName;// 不是 deviceId,而是翻译后的柜子名称privateStringtotalAmount;// 不是 BigDecimal,而是如 "¥15.50"privateStringpayChannelText;// 不是数字 2,而是 "支付宝"privateStringstatusText;// 不是数字 1,而是 "已付款"privateStringstatusColor;// 前端要的颜色标记 "green" / "red"privateStringcreateTime;// 格式化好的字符串 "2026-07-30 10:00"privateList<OrderItemVO>items;}

VO 的终极目标:前端拿到就能直接用,不需要再做任何数据转换。后端多做点翻译工作,前端少写一堆 if-else。

BO:可选但有用的业务对象

@DatapublicclassOrderBO{privateOrderDOorder;privateList<OrderDetailDO>details;privateDeviceDOdevice;privateUserDOuser;/** 计算实际支付金额(含优惠) */publicBigDecimalgetActualAmount(){// 复杂的优惠计算逻辑returnorder.getTotalAmount().subtract(calculateDiscount());}/** 判断是否可退款 */publicbooleanisRefundable(){returnorder.getStatus().equals(1)&&order.getCreateTime().plusDays(7).isAfter(LocalDateTime.now());}}

BO 是"有行为的对象",内置复杂计算逻辑,比 DTO 多了一层业务语义。不是所有项目都需要 BO——如果你的业务逻辑都在 Service 类里,BO 就是多余的。

三、各层间如何转换

对象定义好了,怎么在两个对象之间安全地搬数据?

3.1 手写转换(小项目首选)

// DO → VOpublicOrderVOtoVO(OrderDOorderDO){OrderVOvo=newOrderVO();vo.setId(orderDO.getId());vo.setDeviceName("货柜-"+orderDO.getDeviceId());// demo 写法,实际应查表vo.setTotalAmount("¥"+orderDO.getTotalAmount());vo.setPayChannelText(translatePayChannel(orderDO.getPayChannel()));vo.setStatusText(translateStatus(orderDO.getStatus()));vo.setCreateTime(orderDO.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));returnvo;}privateStringtranslatePayChannel(Integercode){returnswitch(code){case1->"微信支付";case2->"支付宝支付";case3->"银行卡支付";default->"未知";};}

写起来确实有点啰嗦,但好处是完全透明——你可以清晰地看到每个字段是如何转换的。

3.2 BeanUtils.copyProperties(小心陷阱)

OrderVOvo=newOrderVO();BeanUtils.copyProperties(orderDO,vo);

简单粗暴,但有两个坑:

  1. 只能同名字段转换payChannel不会自动变成payChannelText
  2. 浅拷贝:嵌套对象会共用引用,改一个对象可能影响另一个

适合简单的同名字段拷贝,超出这个范围就力不从心了。

3.3 MapStruct(中大型项目推荐)

MapStruct 是编译期生成转换代码的工具,兼顾了性能和开发效率:

@Mapper(componentModel="spring")publicinterfaceOrderConverter{OrderConverterINSTANCE=Mappers.getMapper(OrderConverter.class);@Mapping(target="deviceName",source="deviceName")// 来自联查@Mapping(target="totalAmount",expression="java(\"¥\" + order.getTotalAmount().toString())")@Mapping(target="payChannelText",expression="java(OrderConverter.translatePayChannel(order.getPayChannel()))")@Mapping(target="statusText",expression="java(OrderConverter.translateStatus(order.getStatus()))")@Mapping(target="createTime",dateFormat="yyyy-MM-dd HH:mm")@Mapping(target="isDeleted",ignore=true)// 敏感字段不映射OrderVOtoVO(OrderDOorder,StringdeviceName);}

MapStruct 在编译时生成实现类,运行时和手写代码一样快,没有反射开销。对于字段超过 15 个的复杂对象,用它比手写节省大量时间。

四、实际项目目录结构

结合无人售货柜订单模块,一个清晰的分层目录结构应该是这样的:

com.smartretail.order ├── controller │ └── OrderController.java # 接收请求,返回 VO ├── service │ ├── OrderService.java # 接口 │ └── impl │ └── OrderServiceImpl.java # 实现,编排 BO/DTO ├── mapper │ └── OrderMapper.java # MyBatis-Plus BaseMapper ├── model │ ├── entity # DO(数据库实体) │ │ ├── OrderDO.java │ │ └── OrderDetailDO.java │ ├── dto # DTO(服务间传输) │ │ ├── OrderCreateDTO.java │ │ └── OrderQueryDTO.java │ ├── vo # VO(前端视图) │ │ ├── OrderVO.java │ │ └── OrderDetailVO.java │ └── bo # BO(业务对象,可选) │ └── OrderCheckoutBO.java └── converter └── OrderConverter.java # MapStruct 转换器

各层的职责边界非常清晰

  • Controller 只和 VO、DTO 打交道,永远不会直接拿到一个 DO
  • Service 内部用 DO 操作数据库,用 DTO 做跨服务调用,用 BO 做复杂业务计算
  • Mapper 只知道 DO 的存在,完全不关心中间层对象
  • Converter 专注做转换,不掺杂业务逻辑

五、分层实战:无人售货柜订单模块

来看一个完整的订单创建流程,串联所有层。

第一步:Controller 接收 DTO,返回 VO

@RestController@RequestMapping("/api/order")publicclassOrderController{@AutowiredprivateOrderServiceorderService;@PostMappingpublicResult<OrderVO>create(@Valid@RequestBodyOrderCreateDTOdto){OrderVOvo=orderService.createOrder(dto);returnResult.success(vo);}@GetMapping("/{id}")publicResult<OrderVO>detail(@PathVariableLongid){returnResult.success(orderService.getOrderDetail(id));}}

第二步:Service 编排业务逻辑

@ServicepublicclassOrderServiceImplimplementsOrderService{@AutowiredprivateOrderMapperorderMapper;@AutowiredprivateProductMapperproductMapper;@Override@TransactionalpublicOrderVOcreateOrder(OrderCreateDTOdto){// 1. DTO → DOOrderDOorderDO=newOrderDO();orderDO.setUserId(dto.getUserId());orderDO.setDeviceId(dto.getDeviceId());orderDO.setStatus(0);// 待付款orderDO.setTotalAmount(calculateTotal(dto.getItems()));// 2. 保存订单orderMapper.insert(orderDO);// 3. 保存订单明细(这里省略循环插入的代码)// 4. DO → VO 并返回returnbuildOrderVO(orderDO);}}

第三步:转换器组装 VO

privateOrderVObuildOrderVO(OrderDOorderDO){OrderVOvo=newOrderVO();vo.setId(orderDO.getId());vo.setDeviceName(getDeviceName(orderDO.getDeviceId()));vo.setTotalAmount("¥"+orderDO.getTotalAmount());vo.setPayChannelText(translatePayChannel(orderDO.getPayChannel()));vo.setStatusText(translateStatus(orderDO.getStatus()));vo.setCreateTime(orderDO.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));returnvo;}

整个流程下来,前端拿到的OrderVO干干净净,没有一个多余字段,所有数据都已经翻译好——前端只需要渲染,不需要理解数据库结构。

六、分层的取舍:简单CRUD是否也要分层?

这是一个常说常新的话题。我的建议是:

原则:看项目的复杂度和生命周期。

  • 三天能写完的DEMO→ 直接 DO 走天下,不分层。过度的架构设计是另一种形式的技术债。
  • 内部工具/后台管理系统→ DO + VO 两层就够了。DTO 可省略,因为通常没有跨服务调用的场景。
  • 正式产品/对外接口→ DO + DTO + VO + 转换器,四件套配齐。因为接口一旦发布,前端就会依赖你的字段格式——你今天把payChannel改成payType,明天前端的报错邮件就会塞满你的邮箱。
  • 微服务/多团队协作→ 严格四层,甚至 DTO 要放到独立的 API 模块中,作为服务间的"契约"。

无人售货柜场景:设备管理模块 CRUD 简单,DO+VO 足够;订单模块业务流程复杂、联表多、对前端暴露字段敏感,建议严格四层。

总结

分层规范就这么几点,记住就行:

  1. DO= 数据库表的一面镜子,不加工任何业务信息
  2. DTO= 服务间传递的包裹,不一定会对应一张表
  3. VO= 给前端看的"成品",数据已经翻译好、格式化好、脱敏好
  4. 转换器= 层与层之间的"翻译官",手写或 MapStruct 都行,但别直接用BeanUtils.copyProperties
  5. 简单项目不必全部分层,但数据流的方向和边界必须有意识

把分层做好,代码的可读性和可维护性会有质的飞跃。更重要的是——下次有人接手你的代码时,不用从头看到尾才能理解"这个字段到底代表什么"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询