原文地址: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
回想一下,在你以往編程的過程中,是否經常遇到以下場景:當你從一個服務(Web Api/Database/通用服務)中請求一個實體時,服務響應404, 但是你確信這個實體是存在的。這種問題我已經見過很多次了,有時候它的原因是請求實體時使用了錯誤的ID。 在本篇博文中,我將描述一種避免此類錯誤( 原始類型困擾)的方法,並使用C#的類型系統來幫助我們捕獲錯誤。
其實,許多比我厲害的程序員已經討論過C#中原始類型困擾的問題了。特別是Jimmy Bogard, Mark Seemann, Steve Smith和Vladimir Khorikov編寫的一些文章, 以及Martin Fowler的代碼重構書籍。最近我正在研究F#, 據我所知,這被認為是一個已解決的問題!
原始類型困擾的一個例子
為了給出一個問題說明,我將使用一個非常基本的例子。假設你有一個電子商務的網站,在這個網站中用戶可以下訂單。
其中訂單擁有以下的簡單屬性。
public class Order
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public decimal Total { get; set; }
}
你可以通過OrderService
來創建和讀取訂單。
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方法:
Post()
: 用來創建新訂單。新的訂單信息會放在響應體內返回。Get()
: 根據一個指定的ID獲取訂單信息。如果訂單存在,就將該訂單信息放在響應體內返回。
這兩個方法都需要知道當前登錄用戶的UserId
, 所以這里需要從用戶Claims里面獲取ClaimTypes.NameIdentifier
,並將其轉換成Guid
類型。
不幸的是,以上API控制器的代碼是有Bug的。
你能找到它么?
如果找不到也沒有關系,但是我覺着我能找到。
Bug - 所有的GUID參數都是可以互換的。
代碼編譯之后,你可以成功的添加一個新訂單,但是調用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
類型來表示郵編的概念。
使用強類型ID
所以現在回到我們之前的問題,我們該如何避免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編寫的一些測試。
測試強類型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應用中更容易使用。