Newbe.Claptrap 框架入門,第一步 —— 建立專案,實現簡易購物車

Newbe36524發表於2020-07-10

讓我們來實現一個簡單的 “電商購物車” 需求來了解一下如何使用 Newbe.Claptrap 進行開發。

業務需求

實現一個簡單的 “電商購物車” 需求,這裡實現幾個簡單的業務:

  • 獲取當前購物車中的商品和數量
  • 向購物車中新增商品
  • 從購物車中移除特定的商品

安裝專案模板

首先,需要確保已經安裝了 .NetCore SDK 3.1 。可以點選此處來獲取最新的版本進行安裝

SDK 安裝完畢後,開啟控制檯執行以下命令來安裝最新的專案模板:

 
dotnet new --install Newbe.Claptrap.Template

安裝完畢後,可以在安裝結果中檢視到已經安裝的專案模板。

newbe.claptrap.template安裝完畢

建立專案

選擇一個位置,建立一個資料夾,本示例選擇在 D:\Repo 下建立一個名為 HelloClaptrap 的資料夾。該資料夾將會作為新專案的程式碼資料夾。

開啟控制檯,並且將工作目錄切換到 D:\Repo\HelloClaptrap。然後執行以下命令便可以建立出專案:

dotnet new newbe.claptrap --name HelloClaptrap
 

通常來說,我們建議將 D:\Repo\HelloClaptrap 建立為 Git 倉庫資料夾。通過版本控制來管理您的原始碼。

編譯與啟動

專案建立完成之後,您可以會用您偏愛的 IDE 開啟解決方案進行編譯。

編譯完成後,通過 IDE 上 “啟動” 功能,同時啟動 Web 和 BackendServer 兩個專案。(VS 需要以控制檯方式啟動服務,如果使用 IIS Express,需要開發者看一下對應的埠號來訪問 Web 頁面)

啟動完成後,便可以通過 http://localhost:36525/swagger 地址來檢視樣例專案的 API 描述。其中包括了三個主要的 API:

  • GET /api/Cart/{id} 獲取特定 id 購物車中的商品和數量
  • POST /api/Cart/{id} 新增新的商品到指定 id 的購商品
  • DELETE /api/Cart/{id} 從指定 id 的購物車中移除特定的商品

您可以通過介面上的 Try It Out 按鈕來嘗試對 API 進行幾次呼叫。

第一次新增商品,沒有效果?

是的,您說的沒錯。專案模板中的業務實現是存在 BUG 的。

接下來我們來開啟專案,通過新增一些斷點來排查並解決這些 BUG。

並且通過對 BUG 的定位,您可以瞭解框架的程式碼流轉過程。

新增斷點

以下根據不同的 IDE 說明需要增加斷點的位置,您可以選擇您習慣的 IDE 進行操作。

如果您當前手頭沒有 IDE,也可以跳過本節,直接閱讀後面的內容。

Visual Studio

按照上文提到的啟動方式,同時啟動兩個專案。

匯入斷點:開啟 “斷點” 視窗,點選按鈕,從專案下選擇 breakpoints.xml 檔案。可以通過以下兩張截圖找到對應的操作位置。

Open Breakpoints Window

Import Breakpoints

Rider

按照上文提到的啟動方式,同時啟動兩個專案。

Rider 目前沒有斷點匯入功能。因此需要手動的在以下位置建立斷點:

檔案行號
CartController 30
CartController 34
CartGrain 24
CartGrain 32
AddItemToCartEventHandler 14
AddItemToCartEventHandler 28

通過 Go To File 可以助您快速定位檔案所在

開始除錯

接下來,我們通過一個請求來了解一下整個程式碼執行的過程。

首先,我們先通過 swagger 介面來傳送一個 POST 請求,嘗試為購物車新增商品。

CartController Start

首先命中斷點是 Web API 層的 Controller 程式碼:

[HttpPost("{id}")]
public async Task<IActionResult> AddItemAsync(int id, [FromBody] AddItemInput input)
{
    var cartGrain = _grainFactory.GetGrain<ICartGrain>(id.ToString());
    var items = await cartGrain.AddItemAsync(input.SkuId, input.Count);
    return Json(items);
}

 

 

在這段程式碼中,我們通過_grainFactory 來建立一個 ICartGrain 例項。

這例項本質是一個代理,這個代理將指向 Backend Server 中的一個具體 Grain。

傳入的 id 可以認為是定位例項使用唯一識別符號。在這個業務上下文中,可以理解為 “購物車 id” 或者 “使用者 id”(如果每個使用者只有一個購物車的話)。

繼續除錯,進入下一步,讓我們來看看 ICartGrain 內部是如何工作的。

CartGrain Start

接下來命中斷點的是 CartGrain 程式碼:

public async Task<Dictionary<string, int>> AddItemAsync(string skuId, int count)
{
    var evt = this.CreateEvent(new AddItemToCartEvent
    {
        Count = count,
        SkuId = skuId,
    });
    await Claptrap.HandleEventAsync(evt);
    return StateData.Items;
}
此時,程式碼已經執行到了一個具體的購物車物件。

可以通過偵錯程式看到傳入的 skuId 和 count 都是從 Controller 傳遞過來的引數。

在這裡您可以完成以下這些操作:

  • 通過事件對 Claptrap 中的資料進行修改
  • 讀取 Claptrap 中儲存的資料

這段程式碼中,我們建立了一個 AddItemToCartEvent 物件來表示一次對購物車的變更。

然後將它傳遞給 Claptrap 進行處理了。

Claptrap 接受了事件之後就會更新自身的 State 資料。

最後我們將 StateData.Items 返回給呼叫方。(實際上 StateData.Items 是 Claptrap.State.Data.Items 的一個快捷屬性。因此實際上還是從 Claptrap 中讀取。)

通過偵錯程式,可以看到 StateData 的資料型別如下所示:

public class CartState : IStateData
{
    public Dictionary<string, int> Items { get; set; }
}
 

這就是樣例中設計的購物車狀態。我們使用一個 Dictionary 來表示當前購物車中的 SkuId 及其對應的數量。

繼續除錯,進入下一步,讓我們看看 Claptrap 是如何處理傳入的事件的。

AddItemToCartEventHandler Start

再次命中斷點的是下面這段程式碼:

public class AddItemToCartEventHandler
    : NormalEventHandler<CartState, AddItemToCartEvent>
{
    public override ValueTask HandleEvent(CartState stateData, AddItemToCartEvent eventData,
        IEventContext eventContext)
    {
        var items = stateData.Items ?? new Dictionary<string, int>();
        if (items.TryGetValue(eventData.SkuId, out var itemCount))
        {
            itemCount += eventData.Count;
        }
        // else
        // {
        //     itemCount = eventData.Count;
        // }

        items[eventData.SkuId] = itemCount;
        stateData.Items = items;
        return new ValueTask();
    }
}

這段程式碼中,包含有兩個重要引數,分別是表示當前購物車狀態的 CartState 和需要處理的事件 AddItemToCartEvent

我們按照業務需求,判斷狀態中的字典是否包含 SkuId,並對其數量進行更新。

繼續除錯,程式碼將會執行到這段程式碼的結尾。

此時,通過偵錯程式,可以發現,stateData.Items 這個字典雖然增加了一項,但是數量卻是 0 。原因其實就是因為上面被註釋的 else 程式碼段,這就是第一次新增購物車總是失敗的 BUG 成因。

在這裡,不要立即中斷除錯。我們繼續除錯,讓程式碼走完,來了解整個過程如何結束。

實際上,繼續除錯,斷點將會依次命中 CartGrain 和 CartController 對應方法的方法結尾。

這其實就是三層架構!

絕大多數的開發者都瞭解三層架構。其實,我們也可以說 Newbe.Claptrap 其實就是一個三層架構。下面我們通過一個表格來對比一下:

傳統三層Newbe.Claptrap說明
Presentation 展示層 Controller 層 用來與外部的系統進行對接,提供對外的互操作能力
Business 業務層 Grain 層 根據業務對傳入的業務引數進行業務處理(樣例中其實沒寫判斷,需要判斷 count > 0)
Persistence 持久化層 EventHandler 層 對業務結果進行更新

當然上面的類似只是一種簡單的描述。具體過程中,不需要太過於糾結,這只是一個輔助理解的說法。

您還有一個待修復的 BUG

接下來我們重新回過頭來修復前面的 “首次加入商品不生效” 的問題。

這是一個考慮單元測試框架

在專案模板中存在一個專案 HelloClaptrap.Actors.Tests,該專案包含了對主要業務程式碼的單元測試。

我們現在已經知道,AddItemToCartEventHandler 中註釋的程式碼是導致 BUG 存在的主要原因。

我們可以使用 dotnet test 執行一下測試專案中的單元測試,可以得到如下兩個錯誤:

A total of 1 test files matched the specified pattern.
  X AddFirstOne [130ms]
  Error Message:
   Expected value to be 10, but found 0.
  Stack Trace:
     at FluentAssertions.Execution.LateBoundTestFramework.Throw(String message)
   at FluentAssertions.Execution.TestFrameworkProvider.Throw(String message)
   at FluentAssertions.Execution.DefaultAssertionStrategy.HandleFailure(String message)
   at FluentAssertions.Execution.AssertionScope.FailWith(Func`1 failReasonFunc)
   at FluentAssertions.Execution.AssertionScope.FailWith(Func`1 failReasonFunc)
   at FluentAssertions.Execution.AssertionScope.FailWith(String message, Object[] args)
   at FluentAssertions.Numeric.NumericAssertions`1.Be(T expected, String because, Object[] becauseArgs)
   at HelloClaptrap.Actors.Tests.Cart.Events.AddItemToCartEventHandlerTest.AddFirstOne() in D:\Repo\HelloClaptrap\HelloClaptrap\HelloClaptrap.Actors.Tests\Cart\Events\AddItemToCartEventHandlerTest.cs:line 32
   at HelloClaptrap.Actors.Tests.Cart.Events.AddItemToCartEventHandlerTest.AddFirstOne() in D:\Repo\HelloClaptrap\HelloClaptrap\HelloClaptrap.Actors.Tests\Cart\Events\AddItemToCartEventHandlerTest.cs:line 32
   at NUnit.Framework.Internal.TaskAwaitAdapter.GenericAdapter`1.GetResult()
   at NUnit.Framework.Internal.AsyncToSyncAdapter.Await(Func`1 invoke)
   at NUnit.Framework.Internal.Commands.TestMethodCommand.RunTestMethod(TestExecutionContext context)
   at NUnit.Framework.Internal.Commands.TestMethodCommand.Execute(TestExecutionContext context)
   at NUnit.Framework.Internal.Execution.SimpleWorkItem.PerformWork()

  X RemoveOne [2ms]
  Error Message:
   Expected value to be 90, but found 100.
  Stack Trace:
     at FluentAssertions.Execution.LateBoundTestFramework.Throw(String message)
   at FluentAssertions.Execution.TestFrameworkProvider.Throw(String message)
   at FluentAssertions.Execution.DefaultAssertionStrategy.HandleFailure(String message)
   at FluentAssertions.Execution.AssertionScope.FailWith(Func`1 failReasonFunc)
   at FluentAssertions.Execution.AssertionScope.FailWith(Func`1 failReasonFunc)
   at FluentAssertions.Execution.AssertionScope.FailWith(String message, Object[] args)
   at FluentAssertions.Numeric.NumericAssertions`1.Be(T expected, String because, Object[] becauseArgs)
   at HelloClaptrap.Actors.Tests.Cart.Events.RemoveItemFromCartEventHandlerTest.RemoveOne() in D:\Repo\HelloClaptrap\HelloClaptrap\HelloClaptrap.Actors.Tests\Cart\Events\RemoveItemFromCartEventHandlerTest.cs:line 40
   at HelloClaptrap.Actors.Tests.Cart.Events.RemoveItemFromCartEventHandlerTest.RemoveOne() in D:\Repo\HelloClaptrap\HelloClaptrap\HelloClaptrap.Actors.Tests\Cart\Events\RemoveItemFromCartEventHandlerTest.cs:line 40
   at NUnit.Framework.Internal.TaskAwaitAdapter.GenericAdapter`1.GetResult()
   at NUnit.Framework.Internal.AsyncToSyncAdapter.Await(Func`1 invoke)
   at NUnit.Framework.Internal.Commands.TestMethodCommand.RunTestMethod(TestExecutionContext context)
   at NUnit.Framework.Internal.Commands.TestMethodCommand.Execute(TestExecutionContext context)
   at NUnit.Framework.Internal.Execution.SimpleWorkItem.PerformWork()


Test Run Failed.
Total tests: 7
     Passed: 5
     Failed: 2

 

我們看一下其中一個出錯的單元測試的程式碼:

[Test]
public async Task AddFirstOne()
{
    using var mocker = AutoMock.GetStrict();

    await using var handler = mocker.Create<AddItemToCartEventHandler>();
    var state = new CartState();
    var evt = new AddItemToCartEvent
    {
        SkuId = "skuId1",
        Count = 10
    };
    await handler.HandleEvent(state, evt, default);

    state.Items.Count.Should().Be(1);
    var (key, value) = state.Items.Single();
    key.Should().Be(evt.SkuId);
    value.Should().Be(evt.Count);
}

AddItemToCartEventHandler 是該測試主要測試的元件,由於 stateData 和 event 都是通過手動構建的,因此開發者可以很容易就按照需求構建出需要測試的場景。不需要構建什麼特殊的內容。

現在,只要將 AddItemToCartEventHandler 中那段被註釋的程式碼還原,重新執行這個單元測試。單元測試便就通過了。BUG 也就自然的修復了。

當然,上面還有另外一個關於刪除場景的單元測試也是失敗的。開發者可以按照上文中所述的 “斷點”、“單元測試” 的思路,來修復這個問題。

資料已經持久化了

您可以嘗試重新啟動 Backend Server 和 Web, 您將會發現,您之前操作的資料已經被持久化的儲存了。

我們將會在後續的篇章中進一步介紹。

小結

通過本篇,我們初步瞭解了一下,如何建立一個基礎的專案框架來實現一個簡單的購物車場景。

這裡還有很多內容我們沒有詳細的說明:專案結構、部署、持久化等等。您可以進一步閱讀後續的文章來了解。

最後但是最重要!

最近作者正在構建以反應式Actor模式事件溯源為理論基礎的一套服務端開發框架。希望為開發者提供能夠便於開發出 “分散式”、“可水平擴充套件”、“可測試性高” 的應用系統 ——Newbe.Claptrap

本篇文章是該框架的一篇技術選文,屬於技術構成的一部分。如果讀者對該內容感興趣,歡迎轉發、評論、收藏文章以及專案。您的支援是促進專案成功的關鍵。

如果你對該專案感興趣,你可以通過 github issues 提交您的看法。

如果您無法正常訪問 github issue,您也可以傳送郵件到 newbe-claptrap@googlegroups.com 來參與我們的討論。

點選連結 QQ 交流【Newbe.Claptrap】:https://jq.qq.com/?_wv=1027&k=5uJGXf5

您還可以查閱本系列的其他選文:

GitHub 專案地址:https://github.com/newbe36524/Newbe.Claptrap

Gitee 專案地址:https://gitee.com/yks/Newbe.Claptrap

Newbe.Claptrap

相關文章