在 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 接口也返回它。这会带来两个问题:
- 输入侧冗余与风险 :创建用户时,
Id、CreatedAt是服务端生成的,客户端不需要也不应该提交;如果用同一个 DTO,要么字段闲置无用,要么存在被恶意赋值的风险; - 输出侧信息失配:查询接口可能需要返回更多展示字段,而创建接口只需要接收少量参数,混用会导致接口语义模糊,字段职责不清。
"输入输出分离"的设计思路,通过专门的 DTO 分别限制客户端可以提交和查看的内容
- Request DTO(请求 DTO) :描述"客户端允许提交什么",只包含创建/更新所需的字段,命名如
CreateUserRequest、UpdateUserRequest; - Response DTO(响应 DTO) :描述"客户端允许看到什么",包含查询时需要返回的字段,命名如
UserResponse、UserListItem。
以用户创建场景为例:
csharp
// 请求 DTO:只接收用户名和邮箱
public record CreateUserRequest(
[Required] string UserName,
[Required, EmailAddress] string Email
);
// 响应 DTO:返回 ID、用户名、创建时间
public record UserResponse(
int Id,
string UserName,
DateTime CreatedAt
);
在 Controller 中:
csharp
[HttpPost]
public async Task<ActionResult<UserResponse>> Create(CreateUserRequest request)
{
var user = new UserEntity
{
UserName = request.UserName,
Email = request.Email,
IsAdmin = false, // 服务端控制,不接受客户端输入
CreatedAt = DateTime.UtcNow // 服务端生成
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
return new UserResponse(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 版本
csharp
public class UserResponse
{
public int Id { get; set; }
public string UserName { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
这是最传统的写法,灵活度高,可以随意添加方法、构造函数、字段,但样板代码多,语义不够明确。
5.2 record 版本
csharp
public record UserResponse(int Id, string UserName, DateTime CreatedAt);
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 会自动完成三道工序:
- 输入格式化(Input Formatter)
对于 JSON 请求体,由 System.Text.Json 反序列化为 DTO 对象。API 控制器默认使用 System.Text.Json 作为输入格式化器。 - 模型绑定(Model Binding)
从 Route、Query、Form、Body、Header 等数据源提取数据,按照名称匹配规则填充到 DTO 的属性中。对于[ApiController]标记的控制器,请求体默认通过[FromBody]绑定。 - 模型验证(Model Validation)
根据 DTO 属性上标注的[Required]、[Range]、[StringLength]、[RegularExpression]等验证特性进行校验。
对于标注 [ApiController] 的控制器,模型验证失败会自动返回 HTTP 400 响应 ,包含验证错误详情,无需手动判断 ModelState.IsValid。
示例:
csharp
public record CreateDeviceRequest(
[Required(ErrorMessage = "设备名称不能为空")]
[StringLength(50, ErrorMessage = "设备名称不能超过50个字符")]
string Name,
[Required(ErrorMessage = "IP地址不能为空")]
[RegularExpression(@"^(\d{1,3}\.){3}\d{1,3}$", ErrorMessage = "IP地址格式不正确")]
string IpAddress
);
6.2 转换层:DTO 与 Entity 的映射
输入 DTO 要转成 Entity 才能入库,Entity 要转成响应 DTO 才能返回。常见的映射方式有两种:
手动映射
csharp
// Request → Entity
var entity = new DeviceEntity
{
Name = request.Name,
IpAddress = request.IpAddress
};
// Entity → Response
var response = new DeviceResponse(entity.Id, entity.Name, entity.IpAddress);
优点是显式、易读、易调试、编译时可检查,对于字段不多的项目是最可靠的选择。
自动映射库
如 AutoMapper、Mapster,适合字段多、层级深的大型项目,可以减少重复的映射代码。注意:映射库只是简化映射代码的工具,不是 DTO 本身。
6.3 查询侧:EF Core 直接投影到 DTO
不要先查 Entity 再转 DTO------这会从数据库读取所有字段,浪费 IO 和内存。
更好的做法是用 LINQ Select 直接投影:
csharp
var users = await _context.Users
.Select(u => new UserResponse(u.Id, u.UserName, u.CreatedAt))
.ToListAsync();
EF Core 会将这个 Select 翻译成 SQL:
sql
SELECT Id, UserName, CreatedAt FROM Users
只查询需要的字段,从数据库层面减少数据读取。将数据库实体直接投影为展示模型,避免加载不必要的数据。
这种做法的收益是链式的:
text
SQL 查询字段减少 → 数据库 IO 减少 → 内存占用降低 → JSON 体积缩小 → 网络传输加快
七、最佳实践与常见误区
7.1 最佳实践
- 接口分层命名 :用
XxxRequest/XxxResponse命名,而非统一叫XxxDto,语义更清晰; - 保持扁平化:避免深度嵌套的 DTO,必要时做扁平化处理,降低客户端复杂度;
- 不可变优先:输入输出 DTO 尽量使用不可变属性(init-only 或 positional record);
- 验证前置:把格式校验、长度限制放在 DTO 上,业务规则校验放在领域层;
- 查询即投影:EF Core 查询直接 Select 到 DTO,避免全字段读取;
- 单一职责:一个 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 的架构意义。