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


原文地址: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 SmithVladimir 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);

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM