Home
avatar

.𝙃𝙖𝙣

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; }
}

这里面很多字段对数据库和系统内部很重要,但对前端未必有意义。

比如:

  • IsDeleted
  • UpdateTime
  • 某些内部状态码

这些字段不应该因为“某个页面刚好暂时用不上”就从实体里删掉;同样,也不应该因为“前端现在想多看一个字段”就让实体跟着页面来回变形。

所以我给实体的一个提醒一直是:

实体先服务业务对象本身,再考虑怎么被接口使用。

4. DTO:先别管它像不像实体,先看这个接口到底要收什么

DTO 这层我现在主要从输入边界去理解。

它首先是接口层对象。

当前端发一个请求过来时,后端先要有个东西把这次请求收住。

这个对象更关心的是:

  • 接口允许前端传哪些字段
  • 哪些字段是必填
  • 哪些字段在当前动作里根本不该出现

举个例子,如果是“新增素材”接口,DTO 可能更像这样:

public class CreateMaterialDto
{
    public string Name { get; set; }
    public string Url { get; set; }
    public int ActivityId { get; set; }
}

这里就很明显了。

前端这次请求只该传:

  • 名称
  • 地址
  • 关联活动

它不该传:

  • Id
  • CreateTime
  • UpdateTime
  • IsDeleted

这些字段如果直接暴露给输入模型,风险和混乱都会跟着上来。

所以 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 有 NameUrl
  • Material 实体也有 NameUrl
  • 返回模型里可能还有 NameUrl

肉眼一看,好像就是同一套字段。

但真正的区别不在字段名,而在语义位置

  • 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. 这篇先记一个当前够用的判断标准

如果后面再碰到“这个类到底该怎么放”,我现在先按下面这套去判断:

  1. DTO 只收当前接口允许传进来的字段
  2. 实体优先描述业务对象本身,不优先服务某个页面
  3. 返回模型优先表达前端当前需要看到的结果
  4. 字段长得像,不代表职责一样
  5. Service 层负责把输入、业务对象和输出结果接起来

这篇先记到这里。

对我现在这个阶段来说,把 DTO、实体、返回模型分清楚,最大的好处是:

边界清楚以后,接口改需求、页面改展示、表结构改字段时,互相拖拽会少很多。

这才是它最值钱的地方。

.NET DTO 实体 返回模型 后端