多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

从产品原型到代码骨架:QUERY、DTO、VO三层实体抽取实战

从产品原型到代码骨架:QUERY、DTO、VO三层实体抽取实战 做后端开发这几年我见过太多“原型很好看代码很混乱”的项目。很多人拿到产品原型后第一件事就是建表、写接口结果写到一半发现QUERY、DTO、VO这些实体完全没理清——查询条件不知道往哪放、接口返回的字段和页面对不上、前端提交的数据缺东少西最后只能靠临时补丁硬撑。如果你正在做项目或者准备系统性地学一遍后端开发这篇文章要解决的问题就是如何从产品原型中系统性地抽取QUERY、DTO、VO三类实体把页面上的信息流转成一套清晰、稳定、可维护的代码骨架。1. 从产品原型到实体抽象这一步为什么直接决定代码质量1.1 原型不只是画页面它是需求的结构化表达很多刚入行的开发对“产品原型”的理解停留在表面觉得原型就是一张张图片照着把界面做出来就行。但实际项目里原型承载着远比页面样式更重要的信息——它画出了数据从哪来、到哪去、以什么形态展示、如何被操作。举个例子一个列表里的“订单状态”列在原型上可能只是一个文本占位。但它背后代表着订单状态从创建到完成的完整生命周期不同状态下前端展示的文案、颜色、可执行操作完全不同。这些信息如果不提前抽象成实体字段写代码时就会变成一堆if-else散落在模板和Service方法里。我个人的习惯是拿到原型后不急着看UI细节而是先通读一遍回答三个问题页面展示哪些数据这些数据从什么表、什么服务来结构是什么用户能执行哪些操作这些操作的入参和返回值分别是什么操作之后数据如何变化这会决定状态字段、时间字段的设计。这三个问题的答案正好是QUERY、DTO、VO三类实体设计的主线。原型上的模型图、交互说明、字段明细这些看似琐碎的细节其实都是需求的结构化表达。抽象实体的过程本质上就是把这种“结构”翻译成代码世界里的类型定义。1.2 三层抽象接口入参、应用传输、视图展示分属不同层次在一个标准的后端分层架构里通常有Controller、Service、DAO三层。QUERY、DTO、VO正好对应不同层次的数据载体千万别混着用。QUERY是接口入参的载体。它承载的是前端发起一次请求时用户填写的查询条件、分页信息、排序规则。Controller层接收参数时直接用QUERY对象而不是一个个散落的参数。DTO是应用层内部的数据传输载体。Service层从DAO层拿到数据后经过业务处理再传给上层这个过程中数据形态可能和数据库实体通常叫PO或Entity不一样DTO用来承载这些跨层数据不直接跟UI绑定。VO是视图展示层的载体。它决定接口返回给前端的数据长什么样是扁平结构还是嵌套结构、字段是原始值还是格式化后的文案都在VO里定义清楚。这三类实体有一个共同的价值它们拼出了系统的“接口契约”。QUERY定入参格式VO定出参格式DTO则在内部把持久化数据转成适合业务计算的形状三者共同保证外部接口稳定。一旦它们不对齐最常见的症状就是Controller里用Map接收参数返回结构天天变前端联调时苦不堪言。1.3 跳过实体抽象会怎样一个真实的“拆东墙补西墙”案例有次我带一个功能需求产品原型是标准列表页加筛选。团队里一位同事拿到原型后直接建了一张订单表把订单相关字段全部塞进去然后Controller里写了一个接口把表字段一股脑查出来返给前端。前端一看列表发现里面多了很多不需要的内部字段比如创建人ID、内部备注。前端只能逐个字段过滤JS代码比后端还长每次新增需求变更列表字段前端就得跟着改。后来又加了分页后端直接在接口里写了两个可选参数page和size页面筛选条件则是Controller里散落的五六个参数每次调用接口传参顺序错一个查询结果就莫名其妙地变了。这个项目最后花了整整一个迭代来还债重新定义查询对象、拆分VO、整理DTO。复盘下来问题根源不在技术而在拿到原型时少做了“实体抽象”这一步。所以这篇文章讲的并不只是三个名词而是一个在动手写代码前必须完成的设计动作。2. QUERY、DTO、VO的职责边界一句话说清别混着用2.1 QUERY把散落参数装进一个“查询条件容器”很多老项目里查询接口长这样public ListOrder queryOrder(String orderNo, String mobile, Integer status, String startTime, String endTime, Integer pageNum, Integer pageSize)参数一多方法签名变得臃肿调用方还得记清楚每个参数顺序稍有不慎就把status传到了mobile的位置。更麻烦的是以后想加一个排序参数又得改方法签名所有调用处全要跟着改。QUERY对象的出现就是为了解决这个问题。把查询条件封装成一个类public class OrderQuery { private String orderNo; // 订单号精确匹配 private String mobile; // 手机号模糊匹配 private Integer status; // 订单状态精确匹配 private LocalDateTime startTime; // 下单时间范围-起点 private LocalDateTime endTime; // 下单时间范围-终点 private Integer pageNum; // 页码从1开始 private Integer pageSize; // 每页条数 }接口变成public ListOrderVO queryOrder(OrderQuery query)好处很明显参数结构稳定、语义清晰、扩展方便。更关键的是QUERY对象的字段命名能够直接映射到页面上每一个筛选控件。我在抽实体时会拿着原型一个控件一个控件地对照确保每个筛选条件都有对应字段不多不少。2.2 DTO跨层之间运送数据的“标准集装箱”DTO这个词很多初学者容易绕晕其实它就是数据传输对象Data Transfer Object作用是在不同层之间搬运数据。打个比方数据库表结构是仓库Service层是加工车间Controller层是包装车间DTO就是装着货的标准集装箱。仓库里的货数据库实体PO不能直接送进包装车间因为包装车间需要的是加工后的部件加工车间的半成品也不能用仓库的原始容器装因为形态对不上。听上去复杂核心就一句DTO是层与层之间的“传输格式”不是UI的展示格式也不是数据库的存储格式。比如订单表里存的是“订单类型”数据库中是int枚举值。Service层处理完业务后需要把它转换成更方便计算的类型同时去掉内部字段那就定义OrderDTO只包含业务层需要关心的字段。DTO本身不掺和页面的展示逻辑——用户下单时提交的数据、Service内部传的数据都用DTO承载但不要往里面塞“前端要展示什么”这类UI判断。2.3 VO页面要什么后端就给什么VOView Object是后端返回给前端的数据模型英文直译是“视图对象”。它存在的意义非常朴素前端页面要什么后端就给什么别多也别少。很多接口被前端吐槽就是因为返回了不该带的东西比如内部字段或者格式不对——明明要“订单状态已付款”接口却返回一个status: 2前端还得自己翻译。VO就是用来解决这类问题的。VO的设计原则应当是“面向展示”而不是“面向存储”。一个列表页的每一列基本就是VO的一个字段详情页的每个展示区块对应VO的字段或嵌套子对象。举一个最常见的例子前端需要“实付金额”后端存的是BigDecimal但页面要显示成“¥1,299.00”。我建议数值本身还是返回JSON里的数字格式化由前端做如果团队约定后端直接返回格式化好的字符串那就在VO里用String类型承载。两种方式都能跑关键是团队内保持一致别在同一项目里换来换去。2.4 一张速查表理清三者差异实体类型核心作用使用位置典型字段是否包含业务逻辑QUERY封装查询条件、分页、排序Controller接收请求参数orderNo、status、pageNum否纯数据容器DTO层间数据传输Service接口传入传出业务计算所需字段可含少量转换辅助方法VO视图层展示数据Controller返回前端展示字段、格式化文案否纯数据容器有一点要特别强调这三者不能互相替代原因在于它们的生命周期和变化频率不一样。查询条件的变更频率很高前端筛选控件一变QUERY就要跟着变DTO是中间形态依赖Service和DAO的边界VO跟着页面改版走UI一改VO可能就要调整。把它们混在一起等于把三个变化节奏不同的模块绑在一起牵一发动全身。3. 从UI原型识别QUERY筛选区、分页、排序都是线索3.1 筛选区控件与查询字段的映射关系产品原型里最显眼的筛选区域就是QUERY模型的主要来源。我一般会把控件类型和查询字段的匹配规则定下来这样快速扫一遍原型就能写出QUERY不遗漏也不臆造。单行输入框“订单号”“商品名称”映射成一个String字段匹配方式可能是精确匹配也可能是模糊匹配。比如订单号精确匹配更好手机号通常用模糊匹配。下拉选择框“订单状态”“支付方式”映射成一个字段或枚举值匹配方式为精确匹配。下拉框里的“全部”选项传过来一般是null或空字符串后端要统一处理。日期范围选择器“下单时间”“发货时间”映射成起始、结束两个字段比如startTime和endTime类型建议用LocalDateTime。级联选择“省市区”“商品分类”映射成多个字段或者一个用于拼接查询的对象。一次开发订单列表时原型筛选区有6个控件订单号输入框、用户手机号输入框、订单状态下拉框、支付方式下拉框、下单时间范围、发货时间范围。我对照着在草稿纸上逐个写好字段名和类型OrderQuery一口气写了出来整个过程大概十分钟。3.2 分页与排序QUERY对象里的固定成员大多数列表页都有分页极少数没有。分页参数通常在QUERY里固定占据两个字段private Integer pageNum; // 页码从1开始 private Integer pageSize; // 每页条数我给这两个字段加默认值pageNum 1、pageSize 10。这样即使前端不传分页参数查询也不会翻车。有些团队喜欢在QUERY里再嵌一个Pagination对象我不太推荐会让查询参数出现嵌套层级序列化和校验都多一层麻烦。平铺两个字段是最直观的。排序参数更值得注意。原型上可能不会专门画排序控件但业务里列表一般有“默认排序”逻辑比如订单列表按创建时间倒序。如果产品原型里没有明确的排序交互我建议在QUERY里预留orderBy和orderDirection两个字段开发时默认处理。等产品提出按金额、按时间排序的需求时后端只需透传这两个字段就行不用大改接口。分页参数还有一个容易踩的细节前端的分页组件传参名和后端QUERY字段名对不齐。有的前端传currentPage有的传pageNo后端写的却是pageNum。联调时因为这些小事反复沟通很浪费时间。最佳方式是在项目一开始就定一份“前后端字段名清单”或者让后端直接兼容多种命名。3.3 QUERY设计里我踩过的三个坑一个大而全的QUERY类。以前试过把订单、用户、商品的所有查询条件塞进一个Query类美其名曰“统一管理”。结果订单列表查询时用户相关条件字段全是空的代码可读性直线下降。正确做法是每个具体查询场景一个Query类哪怕个别字段重复也不要强行合并。时间参数类型不统一。有的接口用LocalDateTime有的用String前端传的格式还可能是“2024-08-01 00:00:00”或“2024-08-01”。建议QUERY里统一使用LocalDateTimeController层用DateTimeFormat注解统一转换Service层直接使用类型安全的对象。空字符串查询条件没处理。下拉框“全部”传过来是空字符串后端如果直接拿空字符串去SQL里查往往查不到任何数据。我在QUERY构造查询条件前会把空字符串统一转成null再交给查询条件构造器。4. 从UI原型识别VO和DTO列表、详情、表单三种页面读法不同4.1 列表页读VO显示列就是字段来源列表页是最常见的页面形态它的每一列几乎就是一个VO字段。分析时我会把原型上的表头挨个列出来判断每个字段的来源直接来自某张表某个字段如“订单号”“用户手机号”照搬进VO。需要计算的字段如“实付金额”“商品总数”VO里放的是计算结果而不是哪张表的原始字段。需要格式化的字段如“下单时间”数据库存的是DateTime类型前端要求显示“2024-08-01 12:30:45”。如果后端直接返回原始值前端自己在展示层格式化VO字段保持原类型如果团队规定后端负责格式化VO里就要用String类型配合JsonFormat注解。还要关注列表页每一行的操作按钮。“查看详情”意味着前端要通过订单号或某个ID再去请求详情接口“退款”可能要求该行数据里有退款所需的信息。这些操作依赖的字段也要在VO里预留。4.2 详情页读VO合并、嵌套、格式化详情页比列表页复杂因为一个详情页往往由多个区块组成数据来源也可能横跨多张表或多个服务。分析时我习惯把详情页按区块切分每个区块对应VO里的一个子对象。比如一个订单详情页可能包含订单基础信息区订单号、状态、下单时间、用户信息。商品明细区商品列表每个商品有名称、规格、单价、数量。金额汇总区商品总额、运费、优惠、实付金额。物流信息区物流公司、运单号、状态。这四个区块在VO里就是四个嵌套对象public class OrderDetailVO { private OrderBaseVO base; private ListOrderItemVO items; private AmountVO amount; private ExpressVO express; }嵌套的好处是清晰。前端对接时按区块取数不必在一长串扁平结构里手工拼接。坏处是嵌套太深会让前端取数路径很长代码写起来痛苦。我的经验是嵌套层次尽量控制在两层以内信息层级超过三层就平铺或者拆成多个接口。详情页还会遇到“状态枚举值转文案”的问题。比如原型上显示“订单状态待付款”VO里可以有一个statusText字段放中文文案同时保留status原始枚举值。有些团队习惯后端直接返回文案这样前端不需要维护状态映射表我比较倾向这种做法但也能理解另一派的思路关键是队伍内部定好规范别一会儿后端返文案、一会儿返枚举值。4.3 表单页读DTO提交数据的边界要从原型里抠出来表单页在原型上对应的是“新建”“编辑”“提交”等操作提交的数据是DTO的来源。分析表单页时要区分三类字段用户填写字段如“收货人姓名”“联系电话”“收货地址”进入DTO。用户选择字段如“支付方式”“订单状态”进入DTO通常为枚举值。后端自动生成字段如“创建时间”“创建人ID”“订单号”不应该出现在DTO里。有一个容易混淆的点同一个表单的新增和编辑场景要不要共用同一个DTO我的经验是如果两个场景提交的字段基本一致共用即可用可空字段来区分“编辑时ID必填新增时ID为空”如果差异较大建议拆成OrderCreateDTO和OrderUpdateDTO两个类避免DTO里堆满if判断。表单页的另一个关键点是校验。DTO的字段可以加JSR-303校验注解比如NotBlank、NotNull、DecimalMin。这样Controller在参数绑定阶段就能完成基础校验不需要Service层再手工写一堆参数判断。这一步在实战中能省下大量冗余代码也降低了漏校验的风险。4.4 一个场景下三类实体的协作关系为了直观说明我用“查询订单列表并创建新订单”这个典型请求串一遍流程前端发起GET /api/orders?pageNum1pageSize10status3Controller层用OrderQuery接收查询参数。Controller调用Service的queryOrderPage(OrderQuery query)。Service内部用PO从数据库取数再转换成一页DTO例如OrderPageDTO { ListOrderDTO records; Long total; }。Controller把Service返回的PageDTO组装成OrderPageVO过滤掉内部字段加上状态文案返回给前端。前端填好新订单表单提交到POST /api/ordersController用OrderCreateDTO接收校验通过后传给ServiceService完成持久化。这个流程里QUERY、DTO、VO各司其职没有一处跨层混用。5. 实操示例从“订单管理”原型完整抽取三类实体5.1 原型页面描述与信息点拆解假设我拿到一个“订单管理”模块的产品原型核心是订单列表页和订单详情页。列表页的信息点筛选区订单号输入框、用户手机号输入框、订单状态下拉框全部/待付款/已付款/已发货/已完成/已退款、下单时间范围选择器。表格列订单号、用户手机号、商品总数、实付金额、订单状态、下单时间、操作查看详情、退款。分页默认每页10条。详情页的信息点基础信息区订单号、订单状态、下单时间、支付时间、用户手机号、收货地址。商品明细区商品名、规格、单价、数量、小计。金额汇总区商品总额、运费、优惠金额、实付金额。退款操作区退款原因下拉多选、退款备注输入框、退款金额输入框。我拿到原型后第一件事是把每个信息点拆成“字段名来源类型”的小卡片贴在白板上然后开始按QUERY、DTO、VO归类。5.2 提取OrderQuery筛选条件如何落到字段对照筛选区控件写出public class OrderQuery { private String orderNo; // 订单号精确匹配 private String mobile; // 手机号模糊匹配 private Integer status; // 订单状态枚举值 private LocalDateTime startTime; // 下单时间-起点 private LocalDateTime endTime; // 下单时间-终点 private Integer pageNum 1; // 分页页码 private Integer pageSize 10; // 每页条数 }几个细节说明orderNo和mobile都是String但语义不同订单号精确匹配手机号模糊匹配LIKE %xxx%。这个区别在写SQL时会体现。status用Integer跟数据库枚举值对齐前端下拉框直接传1、2、3这些数字。startTime和endTime用LocalDateTime前端传UTC字符串Controller统一转后端不需要跟字符串格式打交道。pageNum默认1pageSize默认10。我在实战中发现前端某些场景下可能不传分页参数给默认值能避免后端和前端来回确认。5.3 提取OrderVO与OrderDetailVO列表页和详情页分开设计列表VO根据表格列来定义public class OrderVO { private Long orderId; private String orderNo; private String mobile; private Integer totalCount; private BigDecimal actualAmount; private Integer status; private String statusText; private LocalDateTime createTime; }这里statusText就是“待付款”“已付款”这类中文文案由后端从状态枚举映射出来。很多团队喜欢在VO里加orderType之类的冗余字段但页面没有直接展示需求我通常不加。详情VO根据详情页区块来定义public class OrderDetailVO { private OrderVO base; private ListOrderItemVO items; private AmountVO amount; } public class OrderItemVO { private String productName; private String spec; private BigDecimal price; private Integer quantity; private BigDecimal subtotal; } public class AmountVO { private BigDecimal totalAmount; private BigDecimal freightAmount; private BigDecimal discountAmount; private BigDecimal actualAmount; }这样设计的好处是前端按区块取数结构清晰。哪怕金额字段在列表页和详情页重复也不会造成混乱。列表VO的statusText和详情VO的statusText语义一致可以在一个枚举转换工具类里统一处理。5.4 提取OrderCreateDTO与OrderRefundDTO表单提交与操作入参详情页的“退款操作区”对应一个退款DTO。从原型中提取public class OrderRefundDTO { NotNull(message 订单ID不能为空) private Long orderId; NotBlank(message 退款原因不能为空) private String refundReason; DecimalMin(value 0.01, message 退款金额必须大于0) private BigDecimal refundAmount; private String remark; }注意orderId不是用户在退款表单里填写的而是通过页面上下文带过来的通常放在路径参数或Hidden字段里。DTO层面依然需要校验它不为空防止直接构造请求绕过前端。如果还有“创建订单”的表单页会有对应的OrderCreateDTOpublic class OrderCreateDTO { NotBlank private String mobile; NotBlank private String receiverName; NotBlank private String receiverAddress; Valid Size(min 1) private ListOrderItemCreateDTO items; } public class OrderItemCreateDTO { NotNull private Long productId; Min(1) private Integer quantity; }很多初学者在设计DTO时爱犯一个错把“返回给前端展示”的字段也放进DTO。比如设计OrderCreateDTO时有人想“订单创建成功之后前端要显示订单号”于是加了一个orderNo字段。但订单号是后端生成的不是前端提交的所以根本不该出现在这个DTO里。规定一条底线DTO里只放前端提交的数据和业务计算需要的数据其他的一律不加。5.5 实体在分层架构中的完整流转整个“订单管理”模块从Controller到DAO实体流转大致如下Controller层 接收OrderQuery / OrderCreateDTO / OrderRefundDTO 返回OrderVO / OrderDetailVO Service层 接收QUERY / DTO 内部处理PO / DTO 转换 输出VO 所需的组装数据 DAO层 接收OrderQuery 或由其构造的查询条件 返回数据库实体 OrderPO流转过程中有两条原则要守住。第一Controller层不直接操作PO数据库实体避免数据库字段泄露到前端。第二Service层不直接返回PO给ControllerService返回的数据形态应该是对应DTO或者就是VO结构的数据。一旦发现某个Service方法把PO直接return了说明这里少了一层转换要停下来补上。6. 实体抽取的避坑经验这些错我基本都犯过6.1 涉及金额的字段BigDecimal是对的选择别省事用Double早年间我用Double表示金额结果是前端展示和数据库里存的值对不上排查半天发现是浮点数精度问题。金额相关字段在QUERY、DTO、VO里统一使用BigDecimal这一点在抽实体时就要定下来不要等到写SQL时才想起来转换。除了金额远程调用里的汇率、费率等金融字段也一样。6.2 命名混乱别再Query、Req、Param、DTO混用了很多项目里团队对入参对象的命名各有各的习惯。有人用XxxQuery有人用XxxReq有人用XxxParam还有人直接用XxxDTO接收查询参数导致代码仓库里同一类东西出现四五个名字。时间一长新人根本分不清哪个类对应哪个场景。我建议在团队规范里明确查询场景的入参对象统一叫XxxQuery。非查询的请求入参统一叫XxxDTO或XxxCommand比如表单提交、命令操作。返回给前端展示的统一叫XxxVO。规范一写清楚代码Review效率会明显提升新人上手也会更快。6.3 实体抽取检查清单我把自己每次抽完实体后都要过一遍的清单放在这里新手可以直接抄作业[ ] 筛选区的每个控件都能在某个QUERY里找到对应字段。[ ] 时间范围字段统一使用LocalDateTime不接受字符串散落传参。[ ] 分页参数有默认值且和前端提前约定好字段名。[ ] 列表页的每一列都能在VO里找到输出字段没有多余的内部字段。[ ] 状态相关字段同时有原始枚举值和展示文案。[ ] DTO只包含前端提交的数据后端生成的数据订单号、创建时间、创建人没有混入。[ ] 金额字段统一BigDecimal。[ ] PO字段没有直接泄露到VO中。[ ] 新增和编辑场景如果字段差异较大已拆成独立DTO。6.4 最后一点个人体会实体抽象这件事做得越早越轻松。现在我一般在产品评审阶段就对照原型把QUERY、DTO、VO的初步清单列出来哪怕当场只有字段名也能帮助团队提前发现字段缺失、类型冲突、接口边界不合理的问题。等到排期和开发真正开始时剩下的工作只是在清单上填充代码。相比写完再重构这个习惯帮我省下了大量返工时间。
返回列表