通过C#语言特性记录类型与初始化器简化数据传输对象创建插图

通过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` 表达式让非破坏性更新变得轻而易举。

在我的项目中,我现在遵循以下选择策略:

  1. 纯数据载体,属性简单: 优先使用位置记录(`public record Dto(int Id, string Name)`)。
  2. 需要属性验证或复杂初始化逻辑: 使用属性记录(`public record Dto { get; init; }`)并在`init`访问器中添加逻辑。
  3. 需要频繁进行部分更新: 充分利用 `with` 表达式。
  4. 遗留项目或需要可变DTO(虽然不推荐): 可以将属性设置为 `get; set;`,但这样就失去了记录类型的大部分优势。

拥抱这些现代C#特性,不仅能让你写出更简洁、更安全的代码,更能提升你对“数据对象”设计的思考层次。希望这篇教程能帮助你在下一个项目中更优雅地处理数据传输对象。如果你在实践过程中遇到其他有趣的问题或技巧,欢迎在源码库社区分享讨论!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。