☰
DTO --- 数据封装
2026/10/20 18:18:04 网站建设 项目流程

在 C#/.NET 生态的 Web API 开发中,DTO(Data Transfer Object)是最常被提及却也最容易被窄化的概念。许多初学者的认知停留在“DTO 就是只有属性的类”,甚至将其与数据库实体(Entity)混为一谈,仅仅改个类名就当作接口模型使用。事实上,DTO是系统分层边界上的数据契约——它的价值不在于“承载数据”,而在于“隔离边界、约定接口、控制进出”。

一、DTO

1.1 定义

DTO(Data Transfer Object,数据传输对象)是一种架构模式,它是位于系统分层或进程边界、专门用于跨边界传输数据的数据契约对象,本身不承载业务逻辑,在ASP.NET Core WebAPI场景中用来隔离对外接口模型与内部领域/数据库实体,精准控制接口可接收、可返回的字段,以此规避过度提交风险、防止敏感信息泄露,同时实现内外模型解耦、精简网络载荷。

在早期分布式系统中,DTO 的作用是批量传输——将多次细粒度调用的数据打包成一次传输,降低远程调用开销。而在现代 Web 架构中,它的意义早已超越“减少调用”,更重要的作用是定义系统与外部世界的数据边界。

1.2 三层数据视角

一个典型的后端系统可以划分为三层数据模型,每层都有自己的语言和职责:

  • 数据库层:Entity(实体)
    面向持久化设计,与数据库表结构一一对应,包含表中所有字段、约束与关联关系,归宿是 EF Core 与数据库。
  • 领域层:Domain Model(领域模型)
    面向业务逻辑设计,封装业务状态、业务规则与领域行为,包含实体、值对象、聚合等概念,归宿是业务逻辑。
  • 接口层:DTO(数据传输对象)
    面向外部客户端设计,定义 API 的输入输出契约,精确控制哪些数据可以进、哪些数据可以出,归宿是 HTTP 接口与外部消费者。

DTO 就处在接口层与外部世界的边界上:外部的 JSON/HTTP 请求进来,先反序列化为 DTO;内部的业务数据要返回,先转换成 DTO 再序列化为 JSON。它不包含业务逻辑,不负责数据持久化,唯一的职责是:把“一方需要的数据”整理成“另一方应该看到的数据”。

1.3 最直观的例子

微软官方 ASP.NET Core 教程给出了经典场景:数据库中的TodoItem实体包含Id、Name、IsComplete,甚至包含Secret等内部管理字段。如果直接把 Entity 作为 API 返回值,客户端就能拿到所有字段,包括不该暴露的敏感信息。

而通过定义TodoItemDTO只暴露Id、Name、IsComplete三个字段,就精确控制了输出范围。

生产应用通常使用模型的子集来限制输入和返回的数据,安全是主要原因之一。

二、DTO 解决的问题:耦合、安全与数据整形

DTO 的价值是解耦系统内外的模型,并衍生出安全、性能、可维护性三方面收益。

2.1 解除架构耦合:防止数据库结构“渗漏”到接口

如果没有 DTO,API 直接接收和返回 Entity,会形成一条刚性依赖链:

数据库表结构 → Entity 类 → API 接口 → 客户端代码

一旦数据库字段改名、增减字段、调整关联,接口契约就会被动变化,客户端代码也可能跟着失效。这是典型的“实现细节泄露”——持久化层的结构直接穿透到了对外接口。

引入 DTO 后,依赖链变成:

数据库表结构 → Entity 类 → 映射转换 → DTO → API 接口 → 客户端代码

Entity 的变化不会自动传递到 DTO。只要接口契约不变,数据库重构、领域模型调整都不会影响外部客户端。这就是接口模型与持久化模型解耦,也是分层架构的重要意义之一。

2.2 安全边界:防止敏感字段泄露与 Over-posting 攻击

敏感字段隐藏

用户实体中的密码哈希、盐值、权限标记、内部状态等字段,绝对不能暴露给客户端。DTO 可以精确控制输出字段白名单,从根源上避免敏感数据泄露。

防止 Over-posting(过度提交/批量赋值攻击)

Over-posting 攻击(也称为 Mass Assignment Attack)是 Web 应用中常见的安全风险:恶意用户通过在请求中添加服务端未预期的字段,利用模型绑定的自动映射机制,将非法数据写入数据库。

假设 Student 实体包含Secret字段,而页面表单并没有提供该字段的输入。但攻击者可以通过 Fiddler 等工具直接构造 POST 请求,提交Secret=OverPost字段。如果后端直接用 Entity 接收,模型绑定系统会自动将该值绑定到实体上并写入数据库,造成数据篡改。

DTO 通过只定义允许客户端提交的字段,从输入侧截断非法数据。无论请求中夹带多少额外字段,都只会绑定到 DTO 中定义的属性,多余数据被直接丢弃。

2.3 数据整形:优化传输与简化消费

DTO 还可以对数据做结构化处理,提升接口易用性与传输效率:

  • 减小 Payload 体积:剔除不必要的字段,减少网络传输大小;
  • 扁平化对象图:把嵌套的实体关联(如 Order → OrderItem → Product)拍平成一维结构,降低客户端解析成本;
  • 统一格式约定:统一日期格式、枚举字符串化、字段命名规范(如 camelCase),与内部实现解耦;
  • 聚合多源数据:将多个领域对象的数据聚合到一个 DTO 中,减少客户端请求次数。

三、为什么 Request DTO 与 Response DTO 一定要分开

定义一个UserDto同时当输入和输出:

public class UserDto { public int Id { get; set; } public string UserName { get; set; } public DateTime CreatedAt { get; set; } }

然后 Create 接口接收它,Get 接口也返回它。这会带来两个问题:

  1. 输入侧冗余与风险:创建用户时,Id、CreatedAt是服务端生成的,客户端不需要也不应该提交;如果用同一个 DTO,要么字段闲置无用,要么存在被恶意赋值的风险;
  2. 输出侧信息失配:查询接口可能需要返回更多展示字段,而创建接口只需要接收少量参数,混用会导致接口语义模糊,字段职责不清。

“输入输出分离”的设计思路,通过专门的 DTO 分别限制客户端可以提交和查看的内容

  • Request DTO(请求 DTO):描述“客户端允许提交什么”,只包含创建/更新所需的字段,命名如CreateUserRequest、UpdateUserRequest;
  • Response DTO(响应 DTO):描述“客户端允许看到什么”,包含查询时需要返回的字段,命名如UserResponse、UserListItem。

以用户创建场景为例:

// 请求 DTO:只接收用户名和邮箱publicrecordCreateUserRequest([Required]stringUserName,[Required,EmailAddress]stringEmail);// 响应 DTO:返回 ID、用户名、创建时间publicrecordUserResponse(intId,stringUserName,DateTimeCreatedAt);

在 Controller 中:

[HttpPost]publicasyncTask<ActionResult<UserResponse>>Create(CreateUserRequestrequest){varuser=newUserEntity{UserName=request.UserName,Email=request.Email,IsAdmin=false,// 服务端控制,不接受客户端输入CreatedAt=DateTime.UtcNow// 服务端生成};_context.Users.Add(user);await_context.SaveChangesAsync();returnnewUserResponse(user.Id,user.UserName,user.CreatedAt);}

输入模型 ≠ 输出模型 ≠ 数据库模型。三者各司其职,边界清晰,这是成熟项目的标准设计。

四、概念辨析:DTO、Entity、Domain Model、ViewModel、Value Object

区别在于:这个类型为谁服务,职责是什么。

类型所处层级核心职责身份标识行为逻辑
Entity数据访问层映射数据库表,持久化数据有唯一 id极少,主要是数据属性
Domain Model领域层封装业务状态与业务规则有唯一 id丰富,包含业务方法
DTO接口层边界数据传输与契约定义可有可无无,纯数据载体
ViewModel表现层为特定 UI 页面准备数据无少量展示逻辑
Value Object领域层描述业务值概念,按值相等无唯一 id有值相关行为

4.1 DTO vs Value Object

  • Value Object 是领域概念:比如Money(amount, currency),它有业务语义,可以有方法(如 Add、Convert),是领域模型的一部分,活在系统内部;
  • DTO 是边界概念:比如PaymentResponse(amount, currency),它只是数据的传输载体,没有业务行为,只用于跨边界传递,站在系统门口。

简单说:Value Object 用来表达业务含义,DTO 用来跨边界搬运数据。

4.2 DTO vs ViewModel

  • ViewModel 面向 UI 页面:通常包含页面状态、按钮可用性、提示文案等展示相关逻辑,服务于前端渲染;
  • DTO 面向 API 接口:是纯粹的数据契约,不关心展示逻辑,服务于接口调用。

在前后端分离架构中,后端 API 返回的是 DTO;前端拿到 DTO 后再转换成自己的 ViewModel 渲染页面。

五、C# 中 DTO 的实现:class 与 record

DTO 没有规定必须用 class,C# 提供了两种主要实现方式,各有适用场景。

5.1 class 版本

publicclassUserResponse{publicintId{get;set;}publicstringUserName{get;set;}=string.Empty;publicDateTimeCreatedAt{get;set;}}

这是最传统的写法,灵活度高,可以随意添加方法、构造函数、字段,但样板代码多,语义不够明确。

5.2 record 版本

publicrecordUserResponse(intId,stringUserName,DateTimeCreatedAt);

C# 9 引入的 record 类型,天生适合 DTO 场景。record 类型专门为封装数据而设计,提供值相等性、简洁语法和对不可变数据模型的良好支持。

对于 DTO 场景,record 的优势非常明显:

  • 声明式语法:一行定义一个数据契约,语义清晰,一眼可知是纯数据类型;
  • 值相等性:两个 DTO 只要所有字段值相同就判定为相等,符合数据传输的语义;
  • 不可变友好:默认 positional record 的属性是 init-only 的,天然适合作为数据契约,避免传输过程中被意外修改;
  • 原生序列化支持:System.Text.Json 原生支持 record 类型的序列化与反序列化,无需额外配置。

注意:DTO 不一定非得是 record。DTO 是架构角色,record/class 是语言特性——前者回答“用来做什么”,后者回答“用什么定义”。不要形成“DTO 必须是 record”的教条。

六、DTO 在 ASP.NET Core 中的落地

DTO 嵌入在 ASP.NET Core 的模型绑定、验证、数据访问整条链路中,形成完整的数据处理流水线。

6.1 输入侧:JSON → 模型绑定 → 验证 → DTO

当 HTTP 请求到达 Controller,ASP.NET Core 会自动完成三道工序:

  1. 输入格式化(Input Formatter)
    对于 JSON 请求体,由 System.Text.Json 反序列化为 DTO 对象。API 控制器默认使用 System.Text.Json 作为输入格式化器。
  2. 模型绑定(Model Binding)
    从 Route、Query、Form、Body、Header 等数据源提取数据,按照名称匹配规则填充到 DTO 的属性中。对于[ApiController]标记的控制器,请求体默认通过[FromBody]绑定。
  3. 模型验证(Model Validation)
    根据 DTO 属性上标注的[Required]、[Range]、[StringLength]、[RegularExpression]等验证特性进行校验。

对于标注[ApiController]的控制器,模型验证失败会自动返回 HTTP 400 响应,包含验证错误详情,无需手动判断ModelState.IsValid。

示例:

publicrecordCreateDeviceRequest([Required(ErrorMessage="设备名称不能为空")][StringLength(50,ErrorMessage="设备名称不能超过50个字符")]stringName,[Required(ErrorMessage="IP地址不能为空")][RegularExpression(@"^(\d{1,3}\.){3}\d{1,3}$",ErrorMessage="IP地址格式不正确")]stringIpAddress);

6.2 转换层:DTO 与 Entity 的映射

输入 DTO 要转成 Entity 才能入库,Entity 要转成响应 DTO 才能返回。常见的映射方式有两种:

手动映射

// Request → Entityvarentity=newDeviceEntity{Name=request.Name,IpAddress=request.IpAddress};// Entity → Responsevarresponse=newDeviceResponse(entity.Id,entity.Name,entity.IpAddress);

优点是显式、易读、易调试、编译时可检查,对于字段不多的项目是最可靠的选择。

自动映射库
如 AutoMapper、Mapster,适合字段多、层级深的大型项目,可以减少重复的映射代码。注意:映射库只是简化映射代码的工具,不是 DTO 本身。

6.3 查询侧:EF Core 直接投影到 DTO

不要先查 Entity 再转 DTO——这会从数据库读取所有字段,浪费 IO 和内存。

更好的做法是用 LINQSelect直接投影:

varusers=await_context.Users.Select(u=>newUserResponse(u.Id,u.UserName,u.CreatedAt)).ToListAsync();

EF Core 会将这个 Select 翻译成 SQL:

SELECTId,UserName,CreatedAtFROMUsers

只查询需要的字段,从数据库层面减少数据读取。将数据库实体直接投影为展示模型,避免加载不必要的数据。

这种做法的收益是链式的:

SQL 查询字段减少 → 数据库 IO 减少 → 内存占用降低 → JSON 体积缩小 → 网络传输加快

七、最佳实践与常见误区

7.1 最佳实践

  1. 接口分层命名:用XxxRequest/XxxResponse命名,而非统一叫XxxDto,语义更清晰;
  2. 保持扁平化:避免深度嵌套的 DTO,必要时做扁平化处理,降低客户端复杂度;
  3. 不可变优先:输入输出 DTO 尽量使用不可变属性(init-only 或 positional record);
  4. 验证前置:把格式校验、长度限制放在 DTO 上,业务规则校验放在领域层;
  5. 查询即投影:EF Core 查询直接 Select 到 DTO,避免全字段读取;
  6. 单一职责:一个 DTO 只服务一个接口场景,不追求复用。

7.2 常见误区

  • ❌误区1:DTO 就是 Entity 改个名字
    本质是架构解耦,不是换类名。如果字段完全一样,那 DTO 就失去了意义。
  • ❌误区2:一个 DTO 走天下,输入输出混用
    输入输出职责不同,应当分离。混用既带来安全风险,也导致语义模糊。
  • ❌误区3:DTO 里写业务逻辑
    DTO 是数据契约,不包含业务规则。业务逻辑应该放在领域层或应用服务层。
  • ❌误区4:必须用 AutoMapper
    小项目手动映射更清晰可靠,过度依赖映射库反而会增加调试成本。
  • ❌误区5:DTO 必须是 record
    按场景选择 class 或 record。需要复杂构造或可变逻辑时用 class,纯数据传输用 record。

总结

DTO 是系统边界上的数据防火墙:向内,它拦截非法输入,保护内部模型不被越权访问;向外,它控制输出内容,约定接口契约,隔离实现细节。它用一层薄薄的数据结构,换来了架构上的解耦、安全上的保障和演化上的自由度。

回到最初的问题:为什么不用 Entity 直接当接口参数?
因为 Entity 的归宿是数据库,而 DTO 的归宿是接口。让数据的归数据,让契约的归契约——这,就是 DTO 的架构意义。

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

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

立即咨询