原文地址:https://andrewlock.net/using-strongly-typed-entity-ids-to-avoid-primitive-obsession-part-1/
作者:Andrew Lock
譯者:Lamond Lu
譯文地址:https://www.cnblogs.com/lwqlun/p/10693763.html

回想一下,在你以往編程的過程中,是否經(jīng)常遇到以下場(chǎng)景:當(dāng)你從一個(gè)服務(wù)(Web Api/Database/通用服務(wù))中請(qǐng)求一個(gè)實(shí)體時(shí),服務(wù)響應(yīng)404, 但是你確信這個(gè)實(shí)體是存在的。這種問題我已經(jīng)見過很多次了,有時(shí)候它的原因是請(qǐng)求實(shí)體時(shí)使用了錯(cuò)誤的ID。 在本篇博文中,我將描述一種避免此類錯(cuò)誤( 原始類型困擾)的方法,并使用C#的類型系統(tǒng)來幫助我們捕獲錯(cuò)誤。
其實(shí),許多比我厲害的程序員已經(jīng)討論過C#中原始類型困擾的問題了。特別是Jimmy Bogard, Mark Seemann, Steve Smith和Vladimir Khorikov編寫的一些文章, 以及Martin Fowler的代碼重構(gòu)書籍。最近我正在研究F#, 據(jù)我所知,這被認(rèn)為是一個(gè)已解決的問題!
原始類型困擾的一個(gè)例子
為了給出一個(gè)問題說明,我將使用一個(gè)非常基本的例子。假設(shè)你有一個(gè)電子商務(wù)的網(wǎng)站,在這個(gè)網(wǎng)站中用戶可以下訂單。
其中訂單擁有以下的簡(jiǎn)單屬性。
public class Order
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public decimal Total { get; set; }
}
你可以通過OrderService來創(chuàng)建和讀取訂單。
public class OrderService
{
private readonly List<Order> _orders = new List<Order>();
public void AddOrder(Order order)
{
_orders.Add(order);
}
public Order GetOrderForUser(Guid orderId, Guid userId)
{
return _orders.FirstOrDefault(
order => order.Id == orderId && order.UserId == userId);
}
}
為了簡(jiǎn)化代碼,這里我們將訂單對(duì)象保存在內(nèi)存中,并且只提供了兩個(gè)方法。
-
AddOrder(): 在訂單集合中添加訂單 -
GetOrderForUser(): 根據(jù)訂單Id和用戶Id獲取訂單信息
最后,我們創(chuàng)建一個(gè)API控制器,調(diào)用這個(gè)控制器我們可以創(chuàng)建新訂單或者獲取一個(gè)訂單信息。
[Route("api/[controller]")]
[ApiController, Authorize]
public class OrderController : ControllerBase
{
private readonly OrderService _service;
public OrderController(OrderService service)
{
_service = service;
}
[HttpPost]
public ActionResult<Order> Post()
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier));
var order = new Order { Id = Guid.NewGuid(), UserId = userId };
_service.AddOrder(order);
return Ok(order);
}
[HttpGet("{orderId}")]
public ActionResult<Order> Get(Guid orderId)
{
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier));
var order = _service.GetOrderForUser(userId, orderId);
if (order == null)
{
return NotFound();
}
return order;
}
}
這個(gè)API控制器被一個(gè)[Authorize]特性所保護(hù),用戶只有登錄之后才能調(diào)用它。
這里控制器提供了2個(gè)action方法:
-
Post(): 用來創(chuàng)建新訂單。新的訂單信息會(huì)放在響應(yīng)體內(nèi)返回。 -
Get(): 根據(jù)一個(gè)指定的ID獲取訂單信息。如果訂單存在,就將該訂單信息放在響應(yīng)體內(nèi)返回。
這兩個(gè)方法都需要知道當(dāng)前登錄用戶的UserId, 所以這里需要從用戶Claims里面獲取ClaimTypes.NameIdentifier,并將其轉(zhuǎn)換成Guid類型。
不幸的是,以上API控制器的代碼是有Bug的。
你能找到它么?
如果找不到也沒有關(guān)系,但是我覺著我能找到。
Bug - 所有的GUID參數(shù)都是可以互換的。
代碼編譯之后,你可以成功的添加一個(gè)新訂單,但是調(diào)用GET()方法時(shí)卻總是返回404。
這里問題出在OrderController.Get()方法中,使用OrderService獲取訂單的部分。
var order = _service.GetOrderForUser(userId, orderId);
這個(gè)方法的方法簽名如下
public Order GetOrderForUser(Guid orderId, Guid userId);
UserId和OrderId在方法調(diào)用時(shí),寫反了?。?/strong>
這個(gè)例子看起來似乎有點(diǎn)像人為錯(cuò)誤(要求提供UserId感覺有點(diǎn)多余),但是這種模式可能是你在實(shí)踐中經(jīng)??吹降?。這里的問題是,我們使用了原始類型System.GUID來表示了兩個(gè)不同的概念:用戶的唯一標(biāo)識(shí)符和訂單的唯一標(biāo)識(shí)符。使用原始類型值來表示領(lǐng)域概念的問題,我們稱之為原始類型困擾(Primitive Obsession)。
原始類型困擾
在這里,原始類型指的是C#中的內(nèi)置類型,bool, int, Guid, string等。原始類型困擾是指過度使用這些內(nèi)置類型來表示領(lǐng)域概念,其實(shí)這并不適合。這里一個(gè)常見的例子是使用string類型表示郵編或者電話號(hào)碼(使用int類型更糟糕)。
乍看之下,使用string類型可能是有意義的,畢竟你可以使用一串字符表示郵編,但是這里會(huì)有幾個(gè)問題。
首先,如果使用內(nèi)置類型 string, 所有和郵編相關(guān)的邏輯都只能存儲(chǔ)在類型之外的其他地方。例如,不是所有的字符串都是合法的郵編,所以你需要在你的應(yīng)用中針對(duì)郵編添加驗(yàn)證。如果你有一個(gè)ZipCode類型,你可以將驗(yàn)證邏輯封裝在里面。相反的,如果使用string類型,你將不得不把這些邏輯放在程序的其他地方。這意味著數(shù)據(jù)(郵政編碼的值)和針對(duì)數(shù)據(jù)的操作方法被分離了,這打破了封裝。
第二點(diǎn),使用原始類型表示領(lǐng)域概念,你將失去一些從類型系統(tǒng)中獲取的好處。
例如,C#的編譯器不會(huì)允許你做以下的事情。
int total = 1000;
string name = "Jim";
name = total; // compiler error
但是當(dāng)你將一個(gè)電話號(hào)碼值賦給一個(gè)郵政編碼變量就沒有問題,即使從邏輯上看,這就是個(gè)Bug。
string phoneNumber = "+1-555-229-1234";
string zipCode = "1000 AP"
zipCode = phoneNumber; // no problem!
你可能會(huì)覺著這種“錯(cuò)誤分配”類型的錯(cuò)誤很少見,但是它經(jīng)常出現(xiàn)在將多個(gè)原始類型對(duì)象作為參數(shù)的方法。這就是之前我們?cè)?code>GetOrderForUser()方法中出現(xiàn)問題的原因。
那么,我們?cè)撊绾伪苊庠碱愋屠_呢?
答案是使用封裝。我們可以針對(duì)每一個(gè)領(lǐng)域概念創(chuàng)建一個(gè)自定義類型,而不是用使用原始類型來表示它們。例如,我們可以創(chuàng)建一個(gè)ZipCode類來封裝概念,放棄使用string類型來表示郵編,并在整個(gè)領(lǐng)域模型和整個(gè)應(yīng)用中使用ZipCode類型來表示郵編的概念。
使用強(qiáng)類型ID
所以現(xiàn)在回到我們之前的問題,我們?cè)撊绾伪苊?code>GetOrderForUser方法調(diào)用錯(cuò)誤的ID呢?
var order = _service.GetOrderForUser(userId, orderId);
我們可以使用封裝!我們可以為訂單ID和用戶ID創(chuàng)建對(duì)應(yīng)的強(qiáng)類型ID。
原始的方法簽名:
public Order GetOrderForUser(Guid orderId, Guid userId);
使用強(qiáng)類型ID的方法簽名:
public Order GetOrderForUser(OrderId orderId, UserId userId);
一個(gè)OrderId是不能指派給一個(gè)UserId的,反之亦然。所以這里沒有辦法使用錯(cuò)誤的參數(shù)順序來調(diào)用GetOrderForUser方法 - 編譯器會(huì)報(bào)錯(cuò)。
那么, OrderId和UserId類型的代碼應(yīng)該怎么寫呢?這取決與你自己,但是在下一部分中,我將展示一個(gè)實(shí)現(xiàn)的示例。
OrderId類型的實(shí)現(xiàn)。
以下是OrderId類型的實(shí)現(xiàn)代碼。
public readonly struct OrderId : IComparable<OrderId>, IEquatable<OrderId>
{
public Guid Value { get; }
public OrderId(Guid value)
{
Value = value;
}
public static OrderId New() => new OrderId(Guid.NewGuid());
public bool Equals(OrderId other) => this.Value.Equals(other.Value);
public int CompareTo(OrderId other) => Value.CompareTo(other.Value);
public override bool Equals(object obj)
{
if (ReferenceEquals(null, obj)) return false;
return obj is OrderId other && Equals(other);
}
public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString();
public static bool operator ==(OrderId a, OrderId b) => a.CompareTo(b) == 0;
public static bool operator !=(OrderId a, OrderId b) => !(a == b);
}
這里我將OrderId定義成了一個(gè)struct - 它只是一個(gè)封裝了一個(gè)Guid類型數(shù)據(jù)的簡(jiǎn)單類型,所以使用class可能有點(diǎn)小題大做了。但是,也就是說,如果你使用了像EF 6這種ORM, 使用struct可能會(huì)出現(xiàn)問題,所以使用class可能更容易。這也為提供了創(chuàng)建基于強(qiáng)類型ID類的選項(xiàng),以避免一些問題。
使用
struct還會(huì)有一些其他的潛在問題,例如C#中struct是沒有無參構(gòu)造函數(shù)的。
該類型中唯一的數(shù)據(jù)保存在屬性Value中,它包含了我們之前傳遞的原始Guid值。 這里我們定義了一個(gè)構(gòu)造函數(shù),要求你傳入Guid值。
OrderId 中大部分功能都是來自復(fù)寫標(biāo)準(zhǔn)object類型對(duì)象的方法,以及IEquatable<T>和IComparable<T>的接口定義方法。這里我們也復(fù)寫了相等判斷操作符。
接下來,我將展示一下我針對(duì)這個(gè)強(qiáng)類型ID編寫的一些測(cè)試。
測(cè)試強(qiáng)類型ID的行為
以下的xUnit測(cè)試演示了強(qiáng)類型ID - OrderId的一些特性。 這里我們還使用了(類似定義的)UserId來證明它們是不同的類型。
public class StronglyTypedIdTests
{
[Fact]
public void SameValuesAreEqual()
{
var id = Guid.NewGuid();
var order1 = new OrderId(id);
var order2 = new OrderId(id);
Assert.Equal(order1, order2);
}
[Fact]
public void DifferentValuesAreUnequal()
{
var order1 = OrderId.New();
var order2 = OrderId.New();
Assert.NotEqual(order1, order2);
}
[Fact]
public void DifferentTypesAreUnequal()
{
var userId = UserId.New();
var orderId = OrderId.New();
//Assert.NotEqual(userId, orderId); // 編譯不通過
Assert.NotEqual((object) bar, (object) foo);
}
[Fact]
public void OperatorsWorkCorrectly()
{
var id = Guid.NewGuid();
var same1 = new OrderId(id);
var same2 = new OrderId(id);
var different = OrderId.New();
Assert.True(same1 == same2);
Assert.True(same1 != different);
Assert.False(same1 == different);
Assert.False(same1 != same2);
}
}
通過使用像這樣的強(qiáng)類型ID,我們可以充分利用C#的類型系統(tǒng),以確保不會(huì)意外地傳錯(cuò)ID。 在領(lǐng)域業(yè)務(wù)核心中使用這些類型將有助于防止一些簡(jiǎn)單的錯(cuò)誤,例如不正確的參數(shù)順序問題。這很容易做到,并且很難發(fā)現(xiàn)!
但是高興地太早,這里還有待解決問題。 確實(shí),你可以很容易地在領(lǐng)域業(yè)務(wù)核心中使用這些類型,但不可避免地,你最終還是要與外部進(jìn)行交互。 目前,最常用的是在MVC和ASP.NET Core中通過一些JSON API來傳遞數(shù)據(jù)。 在下一篇文章中,我將展示如何創(chuàng)建一些簡(jiǎn)單的轉(zhuǎn)換器,以便更加簡(jiǎn)單地處理強(qiáng)類型ID。
總結(jié)
C#擁有一個(gè)很棒的類型系統(tǒng),所以我們應(yīng)該盡量利用它。原始類型困擾是一個(gè)非常常見的場(chǎng)景,但是你需要盡量去客服它。在本篇博文中,我展示了使用強(qiáng)類型ID來避免傳遞錯(cuò)誤ID的問題。在下一篇我將擴(kuò)展這些類型,以便讓他們?cè)贏SP.NET Core應(yīng)用中更容易使用。