十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

04-项目统一VO/DO/DTO分层规范与数据库映射 项目统一VO/DO/DTO分层规范与数据库映射黒漂技术佬 · 2026年7月前言前面三篇文章我们把从数据库到接口的技术链路打通了连接池配置 → MyBatis CRUD → MyBatis-Plus 提效。看起来代码跑起来了功能也 OK——但当你接手一个运行了两年的项目时你可能看到的是这样的场景Controller 层直接拿数据库实体返回给前端于是用户密码的哈希值被暴露在了接口响应里Service 层的 DTO 和 Controller 层的 VO 混在一起想改一个字段名称要全局搜索三张表联查的 VO 字段一股脑塞进了一个万金油 DODO 膨胀到了 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}问题出在哪isDeleted暴露了这是逻辑删除标记前端不应该知道它的存在payChannel 2、status 1前端看到数字一脸懵——“微信支付还是支付宝”“待付款还是已完成”字段名和含义不匹配如果前端想要支付方式名称、“订单状态文本”你还得在后端挨个翻译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数据库的照相机DataTableName(t_order)publicclassOrderDO{TableId(typeIdType.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订单明细不能为空)privateListOrderItemDTOitems;}DatapublicclassOrderItemDTO{privateLongproductId;privateIntegerquantity;}DTO 的特点是它不需要和数据库表一一对应。一个OrderCreateDTO可能包含用户 ID、设备 ID、商品列表——这背后是多个 DO 的聚合。VO前端的展示菜单DatapublicclassOrderVO{privateLongid;privateStringdeviceName;// 不是 deviceId而是翻译后的柜子名称privateStringtotalAmount;// 不是 BigDecimal而是如 ¥15.50privateStringpayChannelText;// 不是数字 2而是 支付宝privateStringstatusText;// 不是数字 1而是 已付款privateStringstatusColor;// 前端要的颜色标记 green / redprivateStringcreateTime;// 格式化好的字符串 2026-07-30 10:00privateListOrderItemVOitems;}VO 的终极目标前端拿到就能直接用不需要再做任何数据转换。后端多做点翻译工作前端少写一堆 if-else。BO可选但有用的业务对象DatapublicclassOrderBO{privateOrderDOorder;privateListOrderDetailDOdetails;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){OrderVOvonewOrderVO();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小心陷阱OrderVOvonewOrderVO();BeanUtils.copyProperties(orderDO,vo);简单粗暴但有两个坑只能同名字段转换payChannel不会自动变成payChannelText浅拷贝嵌套对象会共用引用改一个对象可能影响另一个适合简单的同名字段拷贝超出这个范围就力不从心了。3.3 MapStruct中大型项目推荐MapStruct 是编译期生成转换代码的工具兼顾了性能和开发效率Mapper(componentModelspring)publicinterfaceOrderConverter{OrderConverterINSTANCEMappers.getMapper(OrderConverter.class);Mapping(targetdeviceName,sourcedeviceName)// 来自联查Mapping(targettotalAmount,expressionjava(\¥\ order.getTotalAmount().toString()))Mapping(targetpayChannelText,expressionjava(OrderConverter.translatePayChannel(order.getPayChannel())))Mapping(targetstatusText,expressionjava(OrderConverter.translateStatus(order.getStatus())))Mapping(targetcreateTime,dateFormatyyyy-MM-dd HH:mm)Mapping(targetisDeleted,ignoretrue)// 敏感字段不映射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 打交道永远不会直接拿到一个 DOService 内部用 DO 操作数据库用 DTO 做跨服务调用用 BO 做复杂业务计算Mapper 只知道 DO 的存在完全不关心中间层对象Converter 专注做转换不掺杂业务逻辑五、分层实战无人售货柜订单模块来看一个完整的订单创建流程串联所有层。第一步Controller 接收 DTO返回 VORestControllerRequestMapping(/api/order)publicclassOrderController{AutowiredprivateOrderServiceorderService;PostMappingpublicResultOrderVOcreate(ValidRequestBodyOrderCreateDTOdto){OrderVOvoorderService.createOrder(dto);returnResult.success(vo);}GetMapping(/{id})publicResultOrderVOdetail(PathVariableLongid){returnResult.success(orderService.getOrderDetail(id));}}第二步Service 编排业务逻辑ServicepublicclassOrderServiceImplimplementsOrderService{AutowiredprivateOrderMapperorderMapper;AutowiredprivateProductMapperproductMapper;OverrideTransactionalpublicOrderVOcreateOrder(OrderCreateDTOdto){// 1. DTO → DOOrderDOorderDOnewOrderDO();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);}}第三步转换器组装 VOprivateOrderVObuildOrderVO(OrderDOorderDO){OrderVOvonewOrderVO();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 简单DOVO 足够订单模块业务流程复杂、联表多、对前端暴露字段敏感建议严格四层。总结分层规范就这么几点记住就行DO 数据库表的一面镜子不加工任何业务信息DTO 服务间传递的包裹不一定会对应一张表VO 给前端看的成品数据已经翻译好、格式化好、脱敏好转换器 层与层之间的翻译官手写或 MapStruct 都行但别直接用BeanUtils.copyProperties简单项目不必全部分层但数据流的方向和边界必须有意识把分层做好代码的可读性和可维护性会有质的飞跃。更重要的是——下次有人接手你的代码时不用从头看到尾才能理解这个字段到底代表什么。
返回列表