
通过C#语言特性记录类型与初始化器简化数据传输对象创建
你好,我是源码库的一名技术博主。在多年的后端开发中,我处理过无数个用于API交互、层间通信的数据传输对象(DTO)。从早期的繁琐属性声明、手写构造函数,到后来借助各种工具生成,这个过程总是伴随着大量的样板代码。直到C# 9.0引入了记录(Record)类型,并结合对象初始化器(Object Initializer)等特性,我才真正感受到DTO创建的优雅与高效。今天,我就结合自己的实战经验,和你分享一下如何利用这些现代C#特性来简化你的代码,并避开一些我踩过的“坑”。
一、传统DTO的痛点:我们曾经写了多少样板代码?
在记录类型出现之前,我们创建一个不可变的DTO通常需要做以下几件事:
// 传统方式:一个“合格”的不可变DTO
public class TraditionalUserDto
{
// 1. 声明属性(通常带有getter和私有setter,或者只读字段+属性)
public int Id { get; private set; }
public string UserName { get; private set; }
public string Email { get; private set; }
public DateTime CreatedAt { get; private set; }
// 2. 一个包含所有参数的构造函数
public TraditionalUserDto(int id, string userName, string email, DateTime createdAt)
{
Id = id;
UserName = userName;
Email = email;
CreatedAt = createdAt;
}
// 3. (可选但常见)重写Equals, GetHashCode, ToString 以实现值语义
public override bool Equals(object obj) { /* 冗长的比较逻辑 */ }
public override int GetHashCode() { /* 计算哈希值 */ }
// ... 可能还有实现 IEquatable
}
每次新增一个属性,都需要修改构造函数和相关的比较方法,繁琐且容易出错。更别提为了临时创建一个对象进行测试,还得专门写一个构建器或者使用别扭的命名参数构造函数。
二、救星登场:使用记录类型定义DTO
C# 9.0的记录类型简直就是为DTO量身定做的。它用一行声明就解决了我们上面的大部分问题。
// 使用记录类型声明DTO - 主构造函数语法
public record UserDto(int Id, string UserName, string Email, DateTime CreatedAt);
// 就这么简单!这一行代码等价于上面TraditionalUserDto的所有功能。
这行代码自动为我们生成了:
- 具有 `init` 访问器的公共属性(C# 9+)。
- 一个主构造函数。
- 基于值的 `Equals()` 和 `GetHashCode()` 实现。
- 一个打印所有属性名和值的 `ToString()` 方法。
- 一个用于非破坏性更新的 `with` 表达式(这个特性超级好用,后面会讲)。
实战提示: 记录类型默认是引用类型(`class`),但具有值语义。如果你需要它是一个值类型(通常在性能极端敏感场景),可以使用 `record struct`(C# 10引入)。
三、灵活初始化:结合对象初始化器与`with`表达式
记录类型的主构造函数语法在创建时很简洁,但有时我们希望在创建后能更灵活地设置属性,或者进行部分更新。这时就需要结合其他特性。
1. 使用对象初始化器
从C# 9开始,记录类型的属性默认具有 `init` 访问器,这意味着它们可以在对象构造期间被设置。这让我们可以使用非常清晰的对象初始化器语法。
// 定义一个包含可初始化属性的记录
public record UserDto
{
public int Id { get; init; }
public string UserName { get; init; }
public string Email { get; init; }
public DateTime CreatedAt { get; init; }
}
// 使用对象初始化器创建实例
var newUser = new UserDto
{
Id = 1,
UserName = "源码库博主",
Email = "blog@sourcecodebase.com",
CreatedAt = DateTime.UtcNow
};
// 注意:一旦对象创建完成,属性就无法再更改。
// newUser.Id = 2; // 这行代码会导致编译错误!
踩坑提示: 如果你混合使用主构造函数和对象初始化器,初始化器中的赋值会覆盖构造函数参数提供的值。这个顺序要清楚。
public record DemoRecord(int Id, string Name)
{
public string Name { get; init; } = Name.ToUpper(); // 在初始化器之前执行
}
var demo = new DemoRecord(1, "hello") { Name = "World" };
Console.WriteLine(demo.Name); // 输出: "World",初始化器覆盖了构造函数和属性初始化器的结果
2. 使用`with`表达式进行非破坏性更新
这是记录类型最优雅的特性之一。当你需要基于一个现有对象创建新对象,并只修改其中几个字段时,`with`表达式让你无需编写任何克隆代码。
public record UserDto(int Id, string UserName, string Email, DateTime CreatedAt);
var originalUser = new UserDto(1, "OldName", "old@email.com", DateTime.UtcNow);
// 创建一个新对象,只更新UserName,其他属性值从originalUser复制
var updatedUser = originalUser with { UserName = "NewName" };
Console.WriteLine(originalUser); // 输出: UserDto { Id = 1, UserName = OldName, ... }
Console.WriteLine(updatedUser); // 输出: UserDto { Id = 1, UserName = NewName, ... }
// originalUser 保持不变,符合不可变性的要求。
实战经验: 在API的更新端点或事件溯源模式中,`with`表达式非常有用。例如,处理一个“更新用户资料”的请求:
public UserDto UpdateUserEmail(int userId, string newEmail)
{
var existingUser = _repository.GetUser(userId);
// 清晰、安全地创建一个更新后的版本
return existingUser with { Email = newEmail };
}
四、进阶技巧与模式
掌握了基础用法后,我们来看看如何将这些特性组合起来,解决更复杂的问题。
1. 嵌套记录与复杂对象
记录类型可以很好地嵌套使用,用于构建复杂的数据结构。
public record Address(string Street, string City, string PostalCode);
public record OrderItem(int ProductId, string ProductName, decimal UnitPrice, int Quantity);
public record OrderDto(
int OrderId,
DateTime OrderDate,
Address ShippingAddress,
List Items
);
// 初始化嵌套记录
var order = new OrderDto(
101,
DateTime.UtcNow,
new Address("科技园路123号", "深圳", "518000"),
new List
{
new(1, "C#高级编程", 99.90m, 1),
new(2, ".NET设计模式", 88.80m, 2)
}
);
// 使用with更新嵌套对象的一部分 - 这需要为嵌套对象也创建一个新实例
var updatedOrder = order with
{
ShippingAddress = order.ShippingAddress with { City = "广州" }
};
2. 为记录添加行为或验证
记录不只是数据的容器。你可以在其中添加方法、计算属性,甚至在主构造函数中加入简单的验证逻辑。
public record ValidatedUserDto
{
public int Id { get; init; }
private string _userName = null!;
public string UserName
{
get => _userName;
init => _userName = string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("用户名不能为空", nameof(UserName))
: value;
}
public string Email { get; init; }
// 添加一个行为方法
public string GetDisplayInfo() => $"{UserName} ({Email})";
}
// 使用
try
{
var badUser = new ValidatedUserDto { Id = 1, UserName = " " };
}
catch (ArgumentException ex)
{
Console.WriteLine($"验证失败: {ex.Message}");
}
3. 与序列化库的协作
这是DTO的常见场景。好消息是,像System.Text.Json和Newtonsoft.Json这样的主流序列化库都能很好地支持记录类型。
using System.Text.Json;
var user = new UserDto(1, "TestUser", "test@example.com", DateTime.UtcNow);
// 序列化
string json = JsonSerializer.Serialize(user);
Console.WriteLine(json);
// 输出: {"Id":1,"UserName":"TestUser","Email":"test@example.com","CreatedAt":"2023-10-27T..."}
// 反序列化 - 注意:需要为属性提供set访问器或使用适当的构造函数
// 使用“位置记录”(主构造函数语法)时,反序列化默认使用该构造函数
var deserializedUser = JsonSerializer.Deserialize(json);
重要提示: 如果你使用主构造函数语法的记录类型,确保反序列化时传入的参数顺序和类型与主构造函数一致。对于复杂的反序列化场景(如私有构造函数、多个构造函数),你可能需要自定义转换器或使用对象初始化器语法的记录(即属性式记录)。
五、总结与选择建议
经过上面的探索,我们可以看到,C#的记录类型与初始化器特性极大地简化了DTO的创建和维护:
- 代码量锐减: 一行声明替代数十行样板代码。
- 意图清晰: `record` 关键字明确表达了“这是一个值语义的数据载体”。
- 安全可靠: 默认的不可变性和基于值的比较减少了意外的副作用和Bug。
- 表达力强: `with` 表达式让非破坏性更新变得轻而易举。
在我的项目中,我现在遵循以下选择策略:
- 纯数据载体,属性简单: 优先使用位置记录(`public record Dto(int Id, string Name)`)。
- 需要属性验证或复杂初始化逻辑: 使用属性记录(`public record Dto { get; init; }`)并在`init`访问器中添加逻辑。
- 需要频繁进行部分更新: 充分利用 `with` 表达式。
- 遗留项目或需要可变DTO(虽然不推荐): 可以将属性设置为 `get; set;`,但这样就失去了记录类型的大部分优势。
拥抱这些现代C#特性,不仅能让你写出更简洁、更安全的代码,更能提升你对“数据对象”设计的思考层次。希望这篇教程能帮助你在下一个项目中更优雅地处理数据传输对象。如果你在实践过程中遇到其他有趣的问题或技巧,欢迎在源码库社区分享讨论!

评论(0)