DTO、实体、返回模型到底该怎么分
这篇不讲项目故事,直接记一个我在后端里反复撞到的问题:DTO、实体、返回模型到底该怎么分。
刚开始写接口时,最省事的做法通常是一个类从头用到尾:接参数用它,落库用它,返回前端还用它。短期看很快,代码一多就开始拧巴。前端要一个字段,数据库是一套结构,服务层里又想补一点业务含义,最后全挤在一个类上,哪里都不舒服。
所以这篇就专门把这几个对象拆开记一下,后面回头看。
1. 先记结论:这三类东西看着像一回事,其实各管各的边界
刚接触分层时,最容易把这几个词看成“差不多的东西换了几个名字”:
- DTO
- Entity
- 返回模型
真正写一段时间以后,会发现它们不能混着用。
原因很简单:
这三个对象分别服务三种不同的边界。
我现在给自己记的最短版本是:
- DTO:接口层收什么、传什么
- 实体:业务对象和数据结构本身长什么样
- 返回模型:前端最后真正要看到什么
这三个边界一旦混在一起,后面问题会一个接一个冒出来。
2. 为什么一开始总想把它们合成一个类
因为这么写最快。
比如一个“新增素材”接口,很自然就会想写成这样:
public class Material
{
public int Id { get; set; }
public string Name { get; set; }
public string Url { get; set; }
public int Status { get; set; }
public DateTime CreateTime { get; set; }
}然后:
- Controller 收参数用它
- Service 往下传也用它
- 数据库存储映射也用它
- 最后返回给前端还用它
这种写法前期非常顺。
因为:
- 类少
- 不用来回转
- 一眼看上去很“省事”
问题是,接口一多、页面一变、字段一复杂,这个类就会越来越不像一个单一职责的对象。
它会同时承受:
- 前端输入结构
- 数据库存储结构
- 服务层业务字段
- 前端输出字段
最后的结果通常是:
- 前端不该传的字段也混进来了
- 数据库内部字段暴露给前端了
- 某些页面只想返回三四个字段,却被迫带上一整坨无关信息
- 类越改越大,任何一层变动都可能牵动另外两层
所以“一个类走到底”最麻烦的地方,在于边界会慢慢糊掉。
3. 先说实体:它优先服务业务对象本身,不优先服务某个接口
我现在对实体的理解还是偏务实一点。
实体先回答的是:
系统里这个业务对象本身长什么样。
比如素材实体,重点应该是:
- 它有什么核心字段
- 它当前状态是什么
- 它和哪些对象有关联
- 它在系统内部以什么结构存在
一个比较直观的例子:
public class Material
{
public int Id { get; set; }
public string Name { get; set; }
public string Url { get; set; }
public int Status { get; set; }
public DateTime CreateTime { get; set; }
public DateTime UpdateTime { get; set; }
public bool IsDeleted { get; set; }
}这里面很多字段对数据库和系统内部很重要,但对前端未必有意义。
比如:
IsDeletedUpdateTime- 某些内部状态码
这些字段不应该因为“某个页面刚好暂时用不上”就从实体里删掉;同样,也不应该因为“前端现在想多看一个字段”就让实体跟着页面来回变形。
所以我给实体的一个提醒一直是:
实体先服务业务对象本身,再考虑怎么被接口使用。
4. DTO:先别管它像不像实体,先看这个接口到底要收什么
DTO 这层我现在主要从输入边界去理解。
它首先是接口层对象。
当前端发一个请求过来时,后端先要有个东西把这次请求收住。
这个对象更关心的是:
- 接口允许前端传哪些字段
- 哪些字段是必填
- 哪些字段在当前动作里根本不该出现
举个例子,如果是“新增素材”接口,DTO 可能更像这样:
public class CreateMaterialDto
{
public string Name { get; set; }
public string Url { get; set; }
public int ActivityId { get; set; }
}这里就很明显了。
前端这次请求只该传:
- 名称
- 地址
- 关联活动
它不该传:
IdCreateTimeUpdateTimeIsDeleted
这些字段如果直接暴露给输入模型,风险和混乱都会跟着上来。
所以 DTO 这一层的价值非常直接:
把接口输入边界钉死。
谁能传什么、这次动作允许什么字段进来,先在这里收住。
5. 返回模型:先别盯数据库里有什么,先看前端这次到底要看什么
返回模型最容易被忽略。
很多人前面好不容易把 DTO 和实体分开了,最后一返回数据,又顺手把实体直接丢给前端。
这样短期当然也能用,但后面页面一多就会很难受。
因为前端平时并不关心“数据库完整长什么样”,它更关心的是:
当前这个页面、这个列表、这个详情卡片,到底要展示什么。
比如素材列表页,前端可能只关心:
- Id
- 素材名称
- 当前状态文案
- 缩略图地址
- 所属活动名
- 最近更新时间
这时候返回模型就没必要长得和实体一模一样。
更合适的做法反而是明确一点:
public class MaterialListItemVo
{
public int Id { get; set; }
public string Name { get; set; }
public string StatusText { get; set; }
public string PreviewUrl { get; set; }
public string ActivityName { get; set; }
public DateTime UpdateTime { get; set; }
}这里有两个很典型的差异:
第一,字段是裁过的
只保留前端当前需要的。
第二,字段是翻译过的
比如实体里可能是状态码,返回模型里直接给状态文案。
所以我现在更愿意把返回模型理解成:
前端视角的数据结果。
它既不是数据库结构,也不该只是服务层里的中间对象。它就是最终交给页面展示的那一层。
6. 这三层最容易混掉的根源,是“都长得很像”
所以它们在项目初期特别容易被偷懒合并。
因为很多时候,看起来字段确实差不多。
比如:
- 创建素材 DTO 有
Name、Url - Material 实体也有
Name、Url - 返回模型里可能还有
Name、Url
肉眼一看,好像就是同一套字段。
但真正的区别不在字段名,而在语义位置:
- DTO 代表“前端允许传什么进来”
- 实体代表“系统内部怎么存这个对象”
- 返回模型代表“前端最后该看到什么出去”
它们就算某几个字段一样,也不代表职责一样。
这点如果不先想清楚,后面代码很容易一直停在“看起来都差不多,就先共用吧”这个阶段里出不来。
7. 我现在区分这三类对象时,会先看这个类到底站在哪一侧
这是我后来给自己总结的一个比较管用的问法。
看见一个类时,不先问它该叫 DTO、Entity 还是 VO,而是先问:
它是在接外面的输入吗?
如果是,那大概率更像 DTO。
它是在描述系统内部业务对象吗?
如果是,那更像实体。
它是在准备返回给页面吗?
如果是,那更像返回模型。
这个问法好处很明显。
因为它不靠名词记忆,而是直接从边界判断职责。
很多时候,只要边界一清楚,命名反而是后面的事。
8. 为什么 DTO 和实体最好不要混用:输入边界一松,后面就会开始漏
DTO 和实体混用,最直接的问题就是输入边界会失控。
比如前端本来只该传三四个字段,但因为直接拿实体接参数,结果这些字段也一起能传进来了:
- Id
- Status
- CreateTime
- IsDeleted
哪怕前端当前没乱传,这种结构本身就是松的。
后面一旦:
- 某个页面复用接口
- 某个调试请求自己组参数
- 某个新同事没留意字段边界
问题就会出来。
所以我现在对 DTO 的要求很简单:
只收这次动作真的允许进来的字段。
多一个都先警惕。
这比后面在业务层补一堆“虽然你传了,但我其实不用”要干净很多。
9. 为什么返回模型和实体最好不要混用:输出一旦裸奔,前端和后端都会被拖住
实体直接返回前端,麻烦通常不只在字段多,还在下面这些地方:
- 后端内部结构会暴露出去
- 前端开始依赖一些本来不稳定的字段
- 后面实体一调整,前端也要跟着改
尤其是当页面越来越多时,这个问题会很明显。
因为不同页面要看的其实不是同一套东西:
- 列表页要轻一点
- 详情页要多一点
- 弹窗页可能只要几个关键字段
- 某些管理页面还会需要拼接后的展示字段
如果一直拿实体直接回,最后往往会出现两种情况:
第一种:实体越来越臃肿
为了适配页面,后端不停往实体上补“其实更像展示用”的字段。
第二种:前端开始自己做过多翻译
状态码、字段含义、拼接逻辑全跑到前端去了。
这两种结果都不太舒服。
所以我现在会更认同一件事:
返回模型就是为了替前端把结果整理好。
这一层并不多余。它一边在保护实体边界,一边也在防止前端过度依赖后端内部结构。
10. Service 层在这三类对象之间,承担的其实是“翻译”和“组装”
这也是我后来想清楚的一点。
如果 Controller 只是入口,Entity 是业务对象,返回模型是页面结果,那中间总得有个地方把这些东西接起来。
这个地方很多时候就是 Service。
它干的事大概是:
- 把 DTO 转成业务动作需要的数据
- 查询或构造实体
- 执行业务逻辑
- 最后把实体或结果再组装成返回模型
Service 除了“调数据库”,还要承担一部分对象转换和语义翻译。
这个认识挺重要。
因为它会直接影响代码摆放:
- DTO 别自己跑去背业务逻辑
- 实体别被迫背页面展示逻辑
- 返回模型别反过来驱动数据库结构
中间这一层要有人收,而 Service 通常就是那个收口点。
11. 什么时候可以偷懒,什么时候最好别偷懒
这块我也想给自己留个现实一点的判断。
因为理论上当然可以分得很细,但项目推进时也不能每个接口都起十几个类。
我现在更倾向于这样看:
可以稍微偷懒的时候
- 很简单的内部工具页
- 字段特别少
- 生命周期很短
- 前后端都明确知道这层只是临时使用
最好别偷懒的时候
- 会长期维护的后台接口
- 有状态流转和权限边界的业务对象
- 需要多个页面复用的数据
- 以后明显会继续长字段、长逻辑的模块
对后面这种情况,我现在更愿意一开始就把边界拆清楚。
因为前面省下来的那点类文件数,后面往往会以更大的维护成本还回来。
12. 这篇先记一个当前够用的判断标准
如果后面再碰到“这个类到底该怎么放”,我现在先按下面这套去判断:
- DTO 只收当前接口允许传进来的字段
- 实体优先描述业务对象本身,不优先服务某个页面
- 返回模型优先表达前端当前需要看到的结果
- 字段长得像,不代表职责一样
- Service 层负责把输入、业务对象和输出结果接起来
这篇先记到这里。
对我现在这个阶段来说,把 DTO、实体、返回模型分清楚,最大的好处是:
边界清楚以后,接口改需求、页面改展示、表结构改字段时,互相拖拽会少很多。
这才是它最值钱的地方。
