原文地址:https://andrewlock.net/using-strongly-typed-entity-ids-to-avoid-primitive-obsession-part-1/
做者:Andrew Lock
譯者:Lamond Lu
譯文地址:http://www.javashuo.com/article/p-vbryyust-ct.htmlhtml
回想一下,在你以往編程的過程當中,是否常常遇到如下場景:當你從一個服務(Web Api/Database/通用服務)中請求一個實體時,服務響應404, 可是你確信這個實體是存在的。這種問題我已經見過不少次了,有時候它的緣由是請求實體時使用了錯誤的ID。 在本篇博文中,我將描述一種避免此類錯誤( 原始類型困擾)的方法,並使用C#的類型系統來幫助咱們捕獲錯誤。程序員
其實,許多比我厲害的程序員已經討論過C#中原始類型困擾的問題了。特別是Jimmy Bogard, Mark Seemann, Steve Smith和Vladimir Khorikov編寫的一些文章, 以及Martin Fowler的代碼重構書籍。最近我正在研究F#, 據我所知,這被認爲是一個已解決的問題!編程
爲了給出一個問題說明,我將使用一個很是基本的例子。假設你有一個電子商務的網站,在這個網站中用戶能夠下訂單。api
其中訂單擁有如下的簡單屬性。session
public class Order { public Guid Id { get; set; } public Guid UserId { get; set; } public decimal Total { get; set; } }
你能夠經過OrderService
來建立和讀取訂單。ide
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); } }
爲了簡化代碼,這裏咱們將訂單對象保存在內存中,而且只提供了兩個方法。函數
AddOrder()
: 在訂單集合中添加訂單GetOrderForUser()
: 根據訂單Id和用戶Id獲取訂單信息最後,咱們建立一個API控制器,調用這個控制器咱們能夠建立新訂單或者獲取一個訂單信息。測試
[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; } }
這個API控制器被一個[Authorize]
特性所保護,用戶只有登陸以後才能調用它。網站
這裏控制器提供了2個action方法:ui
Post()
: 用來建立新訂單。新的訂單信息會放在響應體內返回。Get()
: 根據一個指定的ID獲取訂單信息。若是訂單存在,就將該訂單信息放在響應體內返回。這兩個方法都須要知道當前登陸用戶的UserId
, 因此這裏須要從用戶Claims裏面獲取ClaimTypes.NameIdentifier
,並將其轉換成Guid
類型。
不幸的是,以上API控制器的代碼是有Bug的。
你能找到它麼?
若是找不到也沒有關係,可是我覺着我能找到。
代碼編譯以後,你能夠成功的添加一個新訂單,可是調用GET()
方法時卻老是返回404。
這裏問題出在OrderController.Get()
方法中,使用OrderService
獲取訂單的部分。
var order = _service.GetOrderForUser(userId, orderId);
這個方法的方法簽名以下
public Order GetOrderForUser(Guid orderId, Guid userId);
UserId
和OrderId
在方法調用時,寫反了!!
這個例子看起來彷佛有點像人爲錯誤(要求提供UserId
感受有點多餘),可是這種模式多是你在實踐中常常看到的。這裏的問題是,咱們使用了原始類型System.GUID
來表示了兩個不一樣的概念:用戶的惟一標識符和訂單的惟一標識符。使用原始類型值來表示領域概念的問題,咱們稱之爲原始類型困擾(Primitive Obsession)。
在這裏,原始類型指的是C#中的內置類型,bool
, int
, Guid
, string
等。原始類型困擾是指過分使用這些內置類型來表示領域概念,其實這並不適合。這裏一個常見的例子是使用string
類型表示郵編或者電話號碼(使用int
類型更糟糕)。
乍看之下,使用string
類型多是有意義的,畢竟你可使用一串字符表示郵編,可是這裏會有幾個問題。
首先,若是使用內置類型 string
, 全部和郵編相關的邏輯都只能存儲在類型以外的其餘地方。例如,不是全部的字符串都是合法的郵編,因此你須要在你的應用中針對郵編添加驗證。若是你有一個ZipCode
類型,你能夠將驗證邏輯封裝在裏面。相反的,若是使用string
類型,你將不得不把這些邏輯放在程序的其餘地方。這意味着數據(郵政編碼的值)和針對數據的操做方法被分離了,這打破了封裝。
第二點,使用原始類型表示領域概念,你將失去一些從類型系統中獲取的好處。
例如,C#的編譯器不會容許你作如下的事情。
int total = 1000; string name = "Jim"; name = total; // compiler error
可是當你將一個電話號碼值賦給一個郵政編碼變量就沒有問題,即便從邏輯上看,這就是個Bug。
string phoneNumber = "+1-555-229-1234"; string zipCode = "1000 AP" zipCode = phoneNumber; // no problem!
你可能會覺着這種「錯誤分配」類型的錯誤不多見,可是它常常出如今將多個原始類型對象做爲參數的方法。這就是以前咱們在GetOrderForUser()
方法中出現問題的緣由。
那麼,咱們該如何避免原始類型困擾呢?
答案是使用封裝。咱們能夠針對每個領域概念建立一個自定義類型,而不是用使用原始類型來表示它們。例如,咱們能夠建立一個ZipCode
類來封裝概念,放棄使用string
類型來表示郵編,並在整個領域模型和整個應用中使用ZipCode
類型來表示郵編的概念。
因此如今回到咱們以前的問題,咱們該如何避免GetOrderForUser
方法調用錯誤的ID呢?
var order = _service.GetOrderForUser(userId, orderId);
咱們可使用封裝!咱們能夠爲訂單ID和用戶ID建立對應的強類型ID。
原始的方法簽名:
public Order GetOrderForUser(Guid orderId, Guid userId);
使用強類型ID的方法簽名:
public Order GetOrderForUser(OrderId orderId, UserId userId);
一個OrderId
是不能指派給一個UserId
的,反之亦然。因此這裏沒有辦法使用錯誤的參數順序來調用GetOrderForUser
方法 - 編譯器會報錯。
那麼, OrderId
和UserId
類型的代碼應該怎麼寫呢?這取決與你本身,可是在下一部分中,我將展現一個實現的示例。
OrderId
類型的實現。如下是OrderId
類型的實現代碼。
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
定義成了一個struct
- 它只是一個封裝了一個Guid類型數據的簡單類型,因此使用class
可能有點小題大作了。可是,也就是說,若是你使用了像EF 6這種ORM, 使用struct
可能會出現問題,因此使用class
可能更容易。這也爲提供了建立基於強類型ID類的選項,以免一些問題。
使用
struct
還會有一些其餘的潛在問題,例如C#中struct
是沒有無參構造函數的。
該類型中惟一的數據保存在屬性Value
中,它包含了咱們以前傳遞的原始Guid
值。 這裏咱們定義了一個構造函數,要求你傳入Guid
值。
OrderId
中大部分功能都是來自複寫標準object
類型對象的方法,以及IEquatable<T>
和IComparable<T>
的接口定義方法。這裏咱們也複寫了相等判斷操做符。
接下來,我將展現一下我針對這個強類型ID編寫的一些測試。
如下的xUnit測試演示了強類型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); } }
經過使用像這樣的強類型ID,咱們能夠充分利用C#的類型系統,以確保不會意外地傳錯ID。 在領域業務核心中使用這些類型將有助於防止一些簡單的錯誤,例如不正確的參數順序問題。這很容易作到,而且很難發現!
可是高興地太早,這裏還有待解決問題。 確實,你能夠很容易地在領域業務核心中使用這些類型,但不可避免地,你最終仍是要與外部進行交互。 目前,最經常使用的是在MVC和ASP.NET Core中經過一些JSON API來傳遞數據。 在下一篇文章中,我將展現如何建立一些簡單的轉換器,以便更加簡單地處理強類型ID。
C#擁有一個很棒的類型系統,因此咱們應該儘可能利用它。原始類型困擾是一個很是常見的場景,可是你須要儘可能去客服它。在本篇博文中,我展現了使用強類型ID來避免傳遞錯誤ID的問題。在下一篇我將擴展這些類型,以便讓他們在ASP.NET Core應用中更容易使用。