目錄


ABP Building Blocks

ABP 根據 DDD Tactical Design 定義出幾個關鍵的類別,也就是 Building Blocks,其中細分成 Domain Layer Building Blocks 與 Application Layer Building Blocks,上一篇提到的 Domain Objects 其實就是 Domain Layer Building Blocks

Domain Layer Building Blocks 總共提供以下幾個基礎類別:

  1. Entity
  2. Value Object
  3. Aggregate & Aggregate Root
  4. Repository
  5. Domain Service
  6. Specification
  7. Domain Event

Application Layer Building Blocks 總共提供以下幾個基礎類別:

  1. Application Service
  2. Data Transfer Object (DTO)
  3. Unit of Work (UOW)

詳細的定義可以參考市面上的 DDD 書籍,或者是參考 ABP 官方文檔 提供的定義。

整理一下目前的流程,一個請求會從 Application Service (應用服務)開始,它負責操作或協調 Domain Objects 來實現具體的應用程式 Use Cases, 也就代表我們要在應用服務操作或協調上面的 Domain Building Blocks 清單中的類別,並且完成某項任務或者達成使用者預計的結果。

例如一間書店,使用者想透過我們的系統搜尋特定的書籍,並回傳它的相關資料,那麼可以整理成以下流程:

  1. 使用者點擊書店搜尋功能頁面,並輸入書籍相關資料。
  2. 呼叫書籍應用服務的搜尋功能並帶入書籍資料引數。
  3. 從某個資料儲存地點讀取出特定的書籍資料物件。
  4. 將結果回傳給書籍應用服務的搜尋功能。
  5. 書籍應用服務的搜尋功能將結果轉換成 DTO 並回傳給書店搜尋功能頁面。
  6. 使用者查看回傳結果。

要達成上面的流程那麼至少要建立以下幾個新功能:

  1. 搜尋書籍 UI。
  2. BookAppService: 書籍應用服務。
  3. Book: 書籍物件。
  4. 持久化資料讀寫功能。
  5. BookDto: 書籍 DTO。

接下來按照層級來區分每個功能: Domain Layer(領域層): Book Application Layer(應用層): BookAppServiceBookDto Infrastructure Layer(基礎設施層): 持久化資料讀寫功能 Presentation Layer(表現層): 搜尋書籍 UI

這篇文章我們先把 Domain Layer 的功能先完成。




Domain Layer 設計

首先要設計 Book 內部要包含什麼樣的成員。

  1. Name(string): 書本名稱。
  2. Type(BookType): 書本類型,需要額外建立 BookType Enum。
  3. PublishDate(DateTime): 書本出版日期。
  4. Price(decimal): 書本價格。

接下來就跟一般的 .NET 開發流程不太一樣了,在 ABP 我們需要先 Building Blocks 中選擇要繼承的類型,跟物件相關的 Building Blocks 有以下幾種:

  1. Entity
  2. Value Object
  3. Aggregate & Aggregate Root

對於我們的 Book 來說 Entity 是比較恰當的選擇,這個 Entity 還是屬於 DDD 的範圍,實際上開發須要繼承 ABP 提供的相關實做, 這些實做就放在上一篇文章提到的 Volo.Abp.Ddd.Domain Package 內部,對於 Entity 來說總共有以下幾種實做和它們的泛型版本:

  1. Entity | Entity<TKey>
  2. AuditedEntity | AuditedEntity<TKey>
  3. AuditedEntityWithUser<TUser> | AuditedEntityWithUser<<TKey, TUser>>
  4. CreationAuditedEntity | CreationAuditedEntity<TKey>
  5. CreationAuditedEntityWithUser<TUser> | CreationAuditedEntityWithUser<TKey, TUser>
  6. FullAuditedEntity | FullAuditedEntity<TKey>
  7. FullAuditedEntityWithUser<TUser> | FullAuditedEntityWithUser<TKey, TUser>

注意到泛型參數 TKey 非常重要,在 DDD 的世界中是使用 id 來區分兩個 Entity,也就是說兩個 Entity 就算內部的屬性完全一樣, 但只要 id 不相同就應該把這兩個物件認定為不相等,在 ABP 中建議是從 Guidintlong 挑選一種來當作 id

每個 Entity 都有適合的使用場合,最乾淨的就是 Entity 類型其它的都是從 Entity 類型衍生並添加額外的功能創造出來的,所以這裡我們使用 Entity 就足夠了。

下一個是 BookType Enum,我們也需要先想清楚總共有多少書本類型:

  1. Undefined
  2. Adventure
  3. Biography
  4. Dystopia
  5. Fantastic
  6. Horror
  7. Science
  8. ScienceFiction
  9. Poetry

Enum 在 ABP 就沒有特殊的處理流程了,只需要按照平常的開發習慣即可,只是要注意這個 BookType Enum 的放置層級,如果你有 Domain.Shared 層 那就應該把這種類型資料放在這個層級,如果沒有額外建立層級也可以放置在 Domain 層。

建立完 Book 實體後就要想該如何處理新增跟查詢書籍的功能,我們在上面的流程表中有一個步驟是呼叫 BookAppService 並取得書籍, 也就是要在 BookAppService 建立一個新增與查詢的方法來建立與搜尋書籍,由於 BookAppService 主要的工作是操作或協調, 那麼新增書籍時檢查書籍名稱或長度這些商業邏輯的程式碼就不適合放在 BookAppService 裡面了。

這種處理額外的商業邏輯就適合放在 Domain Service 這也是 Building Blocks 其中之一,所以需要在設計插入一個流程就是 建立一個管理書籍 Domain Service 叫做 BookManager,這樣使用者在處理 CRUD 的請求時就可以直接重複使用 BookManager 裡面的商業邏輯。

跟 Entity 相同,ABP 也有建立 Domain Service 的具體實做 DomainService,所以我們在建立 BookManager 的時候要確定類別要 繼承 DomainService 類別,才能讓 ABP 系統知道你建立的是一個 Domain Service。




Domain Layer 實做

準備完設計後就可以開始寫程式了,由於 Book 內部會使用到 BookType 所以先把 Enum 準備好。

touch src/BookStoreScratch.Domain.Shared/BookStoreScratch/BookType.cs
// BookType.cs
namespace BookStoreScratch;

public enum BookType
{
    Undefined,
    Adventure,
    Biography,
    Dystopia,
    Fantastic,
    Horror,
    Science,
    ScienceFiction,
    Poetry
}

接下來建立 Book 類別並繼承 Entity<TKey>,這裡的選擇使用 Guid 來做為實體 id,這裡將建構函式設定成 internal 避免 其他層級私自建立 Book 物件,所有 Book 物件都必須透過 BookManager 產生。

touch src/BookStoreScratch.Domain/BookStoreScratch/Book.cs
// Book.cs
public class Book : Entity<Guid>
{
    public string Name { get; set; }

    public BookType BookType { get; set; }

    public DateTime PublishDate { get; set; }

    public decimal Price { get; set; }

    [JsonConstructor]
    private Book() {}

    internal Book(
        Guid id,
        string name,
        BookType bookType,
        DateTime publishDate,
        decimal price)
        : base(id)
    {
        Name = name;
        BookType = bookType;
        PublishDate = publishDate;
        Price = price;
    }
}

建立完實體 Book 之後添加 BookManager Domain Service 確保在新增書本的時候能遵守制定的商業邏輯,同時這裡也是唯一能建立 Book 實體的方法,因為建構函式使用 internal 進行限制,這樣就能避免使用者略過商業邏輯直接在 Application Service 中建立實體。

touch src/BookStoreScratch.Domain/BookStoreScratch/BookManager.cs
using System;
using Volo.Abp;
using Volo.Abp.Domain.Services;

namespace BookStoreScratch;

public class BookManager : DomainService
{
    public Book CreateBook(string name, BookType bookType, DateTime publishDate, decimal price)
    {
        Check.NotNullOrWhiteSpace(name, nameof(name));
        Check.NotDefaultOrNull<BookType>(bookType, nameof(bookType));
        Check.NotDefaultOrNull<DateTime>(publishDate, nameof(publishDate));
	
        if (price < 0)
        {
            throw new ArgumentException("Price must be greater than or equal to 0.");
        }

        return new Book(
            new Guid(),
            name,
            bookType,
            publishDate,
            price
        );
    }
}




Domain Layer 測試專案

由於目前只有 Domain 層的程式碼,要查看效果比較不方便,所以這裡選擇先建立 Domain 層級的測試專案,ABP 建議測試專案安裝以下幾個 Package:

  1. Microsoft.NET.Test.Sdk
  2. NSubstitute
  3. Shouldly
  4. xunit
  5. xunit.extensibility.execution
  6. xunit.runner.visualstudio

上面這些 Package 都是我們平常測試會使用到的,另外 ABP 還有提供 Volo.Abp.TestBase,安裝這個 Package 可以幫助我們建立整合測試(IntegratedTest)。

這裡由於每個層級都需要同樣的 Package 所以我們選擇建立一個獨立的專案 BookStoreScratch.TestBase 將共用的 Package 安裝在這裡面。

mkdir test
dotnet new classlib -o test/BookStoreScratch.TestBase
dotnet new classlib -o test/BookStoreScratch.Domain.Tests

dotnet sln add test/BookStoreScratch.TestBase
dotnet sln add test/BookStoreScratch.Domain.Tests

rm test/BookStoreScratch.TestBase/Class1.cs
rm test/BookStoreScratch.Domain.Tests/Class1.cs

調整所有專案的 RootNamespace 與刪除 ImplicitUsings

<PropertyGroup>
  <TargetFramework>net9.0</TargetFramework>
  <Nullable>enable</Nullable>
  <RootNamespace/>
</PropertyGroup>
dotnet add test/BookStoreScratch.Domain.Tests package Microsoft.NET.Test.Sdk
dotnet add test/BookStoreScratch.TestBase package Microsoft.NET.Test.Sdk
dotnet add test/BookStoreScratch.TestBase package FluentAssertions
dotnet add test/BookStoreScratch.TestBase package NSubstitute
dotnet add test/BookStoreScratch.TestBase package Shouldly
dotnet add test/BookStoreScratch.TestBase package xunit
dotnet add test/BookStoreScratch.TestBase package xunit.extensibility.execution
dotnet add test/BookStoreScratch.TestBase package xunit.runner.visualstudio
dotnet add test/BookStoreScratch.TestBase package Volo.Abp.TestBase

添加 BookStoreScratch.TestBase 專案參考,以及被測試的專案 BookStoreScratch.Domain

dotnet add test/BookStoreScratch.Domain.Tests reference test/BookStoreScratch.TestBase
dotnet add test/BookStoreScratch.Domain.Tests reference src/BookStoreScratch.Domain

同樣也需要添加 AbpModule,並且在 BookStoreScratchTestBaseModule 添加 AbpTestBaseModule 依賴。

mkdir -p test/BookStoreScratch.Domain.Tests/BookStoreScratch && touch "$_"/BookStoreScratchDomainTestModule.cs
mkdir -p test/BookStoreScratch.TestBase/BookStoreScratch && touch "$_"/BookStoreScratchTestBaseModule.cs
// BookStoreScratchTestBaseModule.cs
using Volo.Abp;
using Volo.Abp.Modularity;

namespace BookStoreScratch;

[DependsOn(
    typeof(AbpTestBaseModule)
)]
public class BookStoreScratchTestBaseModule : AbpModule
{
}
// BookStoreScratchDomainTestModule.cs
using Volo.Abp.Modularity;

namespace BookStoreScratch;

[DependsOn(
    typeof(BookStoreScratchTestBaseModule)
)]
public class BookStoreScratchDomainTestModule : AbpModule
{
}

完成底層後就可以撰寫測試了,首先我們建立 BookManager_Tests 專門用來測試 BookManager。

touch test/BookStoreScratch.Domain.Tests/BookStoreScratch/BookManager_Tests.cs
// BookManager_Tests.cs
using System;
using FluentAssertions;
using Xunit;

namespace BookStoreScratch;

public class BookManager_Tests
{
    private readonly BookManager _bookManager;

    public BookManager_Tests()
    {
        _bookManager = new BookManager();
    }

    [Fact]
    public void CreateBook_Should_Create_Book_With_Valid_Inputs()
    {
        // Arrange
        var name = "Test Book";
        var bookType = BookType.Adventure;
        var publishDate = new DateTime(2024, 1, 1);
        var price = 19.99m;
    
        // Act
        var book = _bookManager.CreateBook(name, bookType, publishDate, price);
    
        // Assert
        book.Should().NotBeNull();
        book.Name.Should().Be(name);
        book.BookType.Should().Be(bookType);
        book.PublishDate.Should().Be(publishDate);
        book.Price.Should().Be(price);
    }

    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData(" ")]
    public void CreateBook_Should_Throw_ArgumentException_For_Invalid_Name(string invalidName)
    {
        // Arrange
        var bookType = BookType.Adventure;
        var publishDate = new DateTime(2024, 1, 1);
        var price = 19.99m;

        // Act
        Action act = () => _bookManager.CreateBook(invalidName, bookType, publishDate, price);

        // Assert
        act.Should().Throw<ArgumentException>().WithMessage("*name*");
    }

    [Fact]
    public void CreateBook_Should_Throw_ArgumentException_For_Default_BookType()
    {
        // Arrange
        var name = "Test Book";
        var bookType = default(BookType);
        var publishDate = new DateTime(2024, 1, 1);
        var price = 19.99m;

        // Act
        Action act = () => _bookManager.CreateBook(name, bookType, publishDate, price);

        // Assert
        act.Should().Throw<ArgumentException>().WithMessage("*bookType*");
    }

    [Fact]
    public void CreateBook_Should_Throw_ArgumentException_For_Default_PublishDate()
    {
        // Arrange
        var name = "Test Book";
        var bookType = BookType.Adventure;
        var publishDate = default(DateTime);
        var price = 19.99m;

        // Act
        Action act = () => _bookManager.CreateBook(name, bookType, publishDate, price);

        // Assert
        act.Should().Throw<ArgumentException>().WithMessage("*publishDate*");
    }

    [Fact]
    public void CreateBook_Should_Throw_ArgumentException_For_Negative_Price()
    {
        // Arrange
        var name = "Test Book";
        var bookType = BookType.Adventure;
        var publishDate = new DateTime(2024, 1, 1);
        var price = -5m;

        // Act
        Action act = () => _bookManager.CreateBook(name, bookType, publishDate, price);

        // Assert
        act.Should().Throw<ArgumentException>().WithMessage("Price must be greater than or equal to 0.");
    }
}

運行測試後全部成功就代表 Domain 層可以先告一段落,接下來可以開始準備基礎設施層,以及補安裝一些 ABP 常用框架模組。

本次文章進度 Github