在 .NET 中使用强类型 ID 处理实体标识的更好方法

科技   2024-10-14 05:57   上海  


强类型 ID 是自定义类型,用于表示应用程序中的实体标识符 (ID),而不是使用 int、Guid 或 string 等基元类型。您不是直接使用这些基元类型来表示 ID,而是创建一个封装 ID 值的特定类或结构。此方法有助于使代码更具表现力、更安全且更易于维护。

简而言之,Primitive Obsession 是一种使用基本数据类型来表示更复杂的概念的趋势。当基本数据类型(如 int、string 或 DateTime)被过度使用来表示域中的复杂概念时,就会出现 Primitive obsession

这是一种常见的反模式,可能导致代码不清晰和系统更难维护。

如果项目中有多个实体,则标识符 (ID) 也会遇到同样的问题。如果您使用所有实体标识符(如 CustomerId、OrderId、OrderItemId),则可能会导致错误地传递 a 所在的位置。这一切都是因为到处都使用相同的类型。GuidOrderIdOrderItemId

这个原始的痴迷问题可以通过使用 Value Objects 来解决。值对象表示域中没有标识但由其属性定义的值。

实体标识符的原始痴迷问题是通过使用强类型 ID 来解决的,强类型 ID 是一种 Value Objects,但仅适用于实体标识符。

Primitive Obsession with Entity Identifiers 示例

让我们探索一个具有 and 实体的应用程序示例。这两个实体都有 guid 作为其实体标识符类型:OrderOrderItem

public class Order  
{
public Guid Id { get; set; }
}

public class OrderItem
{
public Guid Id { get; set; }
}

假设您正在调用该方法:ProcessOrderAsync

public Task ProcessOrderAsync(Guid orderId, Guid orderItemId)  
{
// Logic to process the order and its item
}

// Correct usage
ProcessOrder(order.Id, orderItem.Id);

// Incorrect usage - No compile-time error
ProcessOrder(orderItem.Id, order.Id);

此代码将成功编译并执行。您是否注意到问题?

由于我们对 和 - 都使用 type,因此您可以按错误的顺序传递参数。您最终会在数据库中得到错误的数据,这可能会导致严重的问题。GuidOrder.IdOrderItem.Id

此问题的解决方案是使用强类型 ID,它将实体标识符封装到一个自定义的有意义的单元中。

什么是强类型 ID?

强类型 ID 是自定义类型,用于表示应用程序中的实体标识符 (ID),而不是使用 int、Guid 或 string 等基元类型。您不是直接使用这些基元类型来表示 ID,而是创建一个封装 ID 值的特定类或结构。此方法有助于使代码更具表现力、更安全且更易于维护。

强类型 ID 的主要特征:

  • 不可变性:强类型 ID 一旦创建,就无法更改。任何修改都会导致新实例。

  • 相等性:强类型 ID 根据其值(而不是引用)进行比较。

强类型 ID 的优点:

  • **增强的类型安全性:**强类型 ID 的最显著好处之一是它们增强了类型安全性。这减少了意外混淆不同类型 ID 的可能性,从而减少了错误和运行时错误。

  • **改进的代码清晰度:**强类型 ID 使您的代码更具表现力和自文档性。当您看到接受 OrderId 的方法时,可以立即清楚地知道需要哪种 ID,而不是通用的 Guid 或 int。

  • **更好的域建模:**在域驱动设计 (DDD) 中,强类型 ID 有助于强化实体及其标识的概念。这使得域模型更加健壮,并与它所代表的实际概念保持一致。

  • **支持未来增强功能:**如果 ID 要求发生更改(例如,需要更改实体标识符的类型),则可以更新或扩展强类型 ID 类以支持这些新要求,而不会破坏现有代码。

  • **更轻松的重构:**强类型 ID 使重构更容易,因为可以在单个位置对 ID 相关逻辑进行更改,从而减少在此过程中引入错误的可能性。

现在,让我们探讨一下在 .NET 中创建强类型 ID 的选项。

示例应用程序

今天,我将向您展示如何为负责创建和更新已订购产品的客户、订单和发货的 Shipping 应用程序实施强类型 ID

此应用程序具有以下实体:

  • 客户

  • Orders、OrderItems

  • Shipments, ShipmentItems

我正在为我的实体使用域驱动设计实践。让我们来探索一下使用基元类型作为实体标识符的 an 和 entities:OrderOrder Item

public class Order  
{
public Guid Id { get; private set; }

public OrderNumber OrderNumber { get; private set; }

public Guid CustomerId { get; private set; }

public Customer Customer { get; private set; }

public DateTime Date { get; private set; }

public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

private readonly List<OrderItem> _items = new();

private Order() { }

public static Order Create(OrderNumber orderNumber, Customer customer, List<OrderItem> items)
{
var order = new Order
{
Id = Guid.NewGuid(),
OrderNumber = orderNumber,
Customer = customer,
CustomerId = customer.Id,
Date = DateTime.UtcNow
};

order.AddItems(items);

return order;
}

private Order AddItems(List<OrderItem> items)
{
_items.AddRange(items);
return this;
}
}

public class OrderItem  
{
public Guid Id { get; private set; }

public ProductName Product { get; private set; }

public int Quantity { get; private set; }

public Guid OrderId { get; private set; }

public Order Order { get; private set; } = null!;

private OrderItem() { }

public OrderItem(ProductName productName, int quantity)
{
Id = Guid.NewGuid();
Product = productName;
Quantity = quantity;
}
}

如您所见,这两个实体都使用 Value Objects 作为其属性。如果您想了解有关 Value Objects 的更多信息,请务必查看我相应的博客文章。

现在,让我们探讨如何将实体标识符的这些基元类型替换为强类型 ID。

在 .NET 中创建强类型 ID

我可以列出以下用于创建强类型 ID 的最常用选项:

  • 使用 Andrew Lock 编写的 StronglyTypedId 包

  • 使用 C# 记录

  • 使用 C# 记录结构

让我们更深入地探讨每个选项。

使用 StronglyTypedId 包创建强类型 ID

通过使用该包,您可以为实体生成强类型 ID 结构。StronglyTypedId

首先,您需要安装软件包:

dotnet add package StronglyTypedId

您需要定义 a 并添加 attribute:partial structStronglyTypedId

[StronglyTypedId]  
public readonly partial struct OrderId { }

[StronglyTypedId]
public readonly partial struct OrderItemId { }

StronglyTypedIdpackage 将使用源生成器来实现 AND 结构体。默认情况下,下划线类型为 .OrderIdOrderItemIdGuid

以下是创建 OrderId 的方法:

var orderId = new OrderId(Guid.NewGuid());  
var orderItemId = new OrderItemId(Guid.NewGuid());

您可以使用属性来检索隐藏在强类型 ID 中的值:Value

var guid = orderId.Value;  
var guid2 = orderItemId.Value;

如果需要更改类型,请在属性中指定 a:Template

[StronglyTypedId(Template.Int)]  
public readonly partial struct OrderId { }

当前支持的内置支持类型包括:

  • Guid (默认)

  • int

  • long

  • string

使用记录创建强类型 ID

您不必使用外部包,因为 C# 记录已经具有强类型 ID 所需的一切。

记录是在 .NET 中创建强类型 ID 的一种非常现代的方法。

记录是不可变的引用类型,它们支持开箱即用的相等比较。它们是根据其属性而不是引用进行比较的。

以下是使用记录定义相同的强类型 ID 的方法:

public record OrderId(Guid Value);  

public record OrderItemId(Guid Value);

使用记录结构创建强类型 ID

记录是强类型 ID 的绝佳选择,但它们是引用类型。如果您关心内存分配,则可以用于强类型 ID。它们的行为与 None 相同,但它们是值类型,而不是在堆上分配。readonly record structsrecords

这是我个人创建强类型 ID 的选择。

以下是定义强类型 ID 的方法:record structs

public readonly record struct OrderId(Guid Value);  

public readonly record struct OrderItemId(Guid Value);

以下是 and 实体使用强类型 ID 时的样子:OrderOrderItem

public class Order  
{
public OrderId Id { get; private set; }

public OrderNumber OrderNumber { get; private set; }

public CustomerId CustomerId { get; private set; }

public Customer Customer { get; private set; }

public DateTime Date { get; private set; }

public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

private readonly List<OrderItem> _items = new();

private Order() { }

public static Order Create(OrderNumber orderNumber, Customer customer, List\<OrderItem> items)
{
var order = new Order
{
Id = new OrderId(Guid.NewGuid()),
OrderNumber = orderNumber,
Customer = customer,
CustomerId = customer.Id,
Date = DateTime.UtcNow
};

order.AddItems(items);

return order;
}

private Order AddItems(List<OrderItem> items)
{
_items.AddRange(items);
return this;
}
}

public class OrderItem
{
public OrderItemId Id { get; private set; }

public ProductName Product { get; private set; }

public int Quantity { get; private set; }

public OrderId OrderId { get; private set; }

public Order Order { get; private set; } = null!;

private OrderItem() { }

public OrderItem(ProductName productName, int quantity)
{
Id = new OrderItemId(Guid.NewGuid());
Product = productName;
Quantity = quantity;
}
}

如果您尝试滥用实体标识符,您将收到编译错误:

在 EF Core 中映射强类型 ID

在实体模型中引入强类型 ID 后,需要修改 EF Core 映射。

需要使用 conversion 来告知 EF Core 如何将强类型 ID 映射到数据库,以及如何将数据库值映射到 ID。

例如,对于 entity:Order

builder.Property(x => x.Id)  
.HasConversion(
id => id.Value,
value => new OrderId(value)
)
.IsRequired();

强类型 ID 和请求/响应/DTO 模型

强类型 ID 是特定于域的模型,外界不应知道它们。此外,您的公共请求/响应/DTO 模型应尽可能简单。

最好在请求/响应/DTO 模型中使用普通基元类型,并将它们映射到域实体,反之亦然。

例如,我在 “Create Order” 用例中使用 type 映射到:CustomerIdGuidCustomerId

public sealed record CreateOrderRequest(  
Guid CustomerId,
List<OrderItemRequest> Items,
Address ShippingAddress,
string Carrier,
string ReceiverEmail);

public static CreateOrderCommand MapToCommand(this CreateOrderRequest request)
{
return new CreateOrderCommand(
CustomerId: new CustomerId(request.CustomerId),
Items: request.Items,
ShippingAddress: request.ShippingAddress,
Carrier: request.Carrier,
ReceiverEmail: request.ReceiverEmail
);
}

以及从 Strong Typed 到 的反向映射:CustomerResponseOrderId

public static OrderResponse MapToResponse(this Order order)  
{
return new OrderResponse(
OrderId: order.Id.Value,
OrderNumber: order.OrderNumber.Value,
OrderDate: order.Date,
Items: order.Items.Select(x => new OrderItemResponse(
ProductName: x.Product.Value,
Quantity: x.Quantity)).ToList()
);
}

强类型 ID 是提高 .NET 应用程序的类型安全性、清晰度和可维护性的强大工具。通过将 ID 封装在专用记录或结构中,可以防止常见错误并使代码更具表现力。

C# 记录和 readonly 记录结构为您提供了一种优雅、简单、快速的方式来实现强类型 ID,而无需样板代码。

如果你喜欢我的文章,请给我一个赞!谢谢

架构师老卢
资深软件架构师, 分享编程、软件设计经验, 教授前沿技术, 分享技术资源(每天发布电子书),每天进步一点点...
 最新文章