使用強類型實體Id來避免原始類型困擾(一)

原文地址: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 SmithVladimir 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的。

你能找到它麼?

若是找不到也沒有關係,可是我覺着我能找到。

Bug - 全部的GUID參數都是能夠互換的。

代碼編譯以後,你能夠成功的添加一個新訂單,可是調用GET()方法時卻老是返回404。

這裏問題出在OrderController.Get()方法中,使用OrderService獲取訂單的部分。

var order = _service.GetOrderForUser(userId, orderId);

這個方法的方法簽名以下

public Order GetOrderForUser(Guid orderId, Guid userId);

UserIdOrderId在方法調用時,寫反了!!

這個例子看起來彷佛有點像人爲錯誤(要求提供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方法 - 編譯器會報錯。

那麼, OrderIdUserId類型的代碼應該怎麼寫呢?這取決與你本身,可是在下一部分中,我將展現一個實現的示例。

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應用中更容易使用。

相關文章
相關標籤/搜索