今天要來處理應用層(Application Layer)與 HTTP 層(HttpAPI Layer)的實作。
這兩個層級在概念上,與傳統 MVC 中的 Controller + ViewModel 有些相似, 但在 DDD 戰術設計中引入了兩個關鍵概念:
- Application Service
- Data Transfer Object(DTO)
應用層
在第一篇文章中已經提過,應用層位於表現層與領域層之間,負責將多個商業行為組合成一個「可被呼叫的應用操作」。
以書店範例來說,我們會建立一個 BookAppService,集中處理書籍的新增、修改、查詢與刪除等操作。
HTTP 層
ABP 額外拆出 HTTP 層的主要目的是避免商業邏輯依賴 MVC 或 Web 技術,因此它主要負責 HTTP Route、HTTP Method 與 HTTP Response 等相關功能, 這個層級屬於輔助層級並非強制要求建立,也可以將 HTTP 層合併到應用層,讓應用層的 Application Service 處理 HTTP。
DTO 的用處
DTO 的概念其實與 MVC 中的 ViewModel 非常接近,主要目的是:
- 避免 Entity 直接暴露給外部
- 降低層與層之間的耦合
- 防止未來 Entity 結構調整時影響 API
雖然省略 DTO 直接使用 Entity 也是可行,但在實務中,這樣會讓表現層過度依賴底層資料結構,導致後續修改與維護成本提高。
整體呼叫流程
- 呼叫者透過 HTTP 層知道有對外公開的 API,並呼叫查詢書籍 API
- HTTP 層透過依賴注入呼叫 Application Service(
BookAppService) BookAppService透過倉庫發送查詢請求給基礎設施層- 基礎設施層將請求發送到資料庫,並把查詢到的
Book實體回傳給BookAppService BookAppService將Book實體轉換成BookDto並由 HTTP 層回傳給呼叫者
應用層設計
根據 DDD 的設計我們會把應用服務的介面與 DTO 放在 Application.Contracts 專案內部,
這樣能將具體的服務放在 Application 專案內,並把相關的服務合約單獨放在 Application.Contracts 專案,
例如有兩個微服務需要溝通,就能透過 Application.Contracts 專案與 ABP 提供的動態代理功能自動幫你在背後做 RPC 非常方便。
由於 Application.Contracts 並沒有參考 Domain 專案,所以等於禁止我們直接回傳領域模型,因此只能乖乖將結果轉換成 DTO 才能回傳,
這樣做的好處能避免將領域模型暴露給前端知道。
首先需要在 Application.Contracts 專案定義 IBookAppService 介面,並且定義好未來需要使用的服務,
需要注意 ABP 的應用服務介面一定要繼承自 IApplicationService 否則 ABP 沒辦處理動態代理或動態 API 功能,
通常來說 ABP 有幾個應用服務介面是比較常用的,它們底層也都是繼承 IApplicationService:
- IApplicationService: 空的介面,甚麼方法都沒有定義。
- IReadOnlyAppService: 只有定義讀取方法。
- ICrudAppService: 定義全部常用的 CRUD 方法。
建議是用在後台相關的應用才使用 ICrudAppService 介面,如果應用服務需要對外公開最好是使用 IApplicationService 介面,
因為如果使用動態 API 功能,背後會自動產生所有服務的 API,因此使用基礎介面可以避免對外顯示不重要的服務,或者也可以透過 HTTP 層進一步控制對外顯示的服務。
應用服務常繼承的類別的有以下幾種:
- ApplicationService 基礎的 Class,只有實做基礎的 ABP 功能。
- ReadOnlyAppService 使用預設的讀取方法,可以用在一些報表展示的功能上。
- CrudAppService 預設建立所有 CRUD 方法,一些簡單的後台功能可以直接繼承這個 Class。
接下來在 Application 專案處理服務實做 BookAppService,實務上可以直接繼承 CrudAppService 類別,概念跟基礎服務層的 EfCoreRepository 差不多,
都已經定義好常用的 CURD 功能,避免我們浪費時間處理基礎存取功能,這裡為了 DEMO 因此繼承最基本的類別 ApplicationService。
DTO 就有非常多內建類型可以直接繼承例如以下幾個:
- EntityDto: 基礎的 DTO ,只包含 Id 屬性,如果使用者只在乎這筆資料是誰那用這個就夠了。
- PagedResultDto: 添加 TotalCount 屬性,方便前端能處理分頁功能
- AuditedEntityDto: 添加額外的新增、修改屬性,用來顯示常用的審計欄位
- FullAuditedEntityDto: 額外添加刪除屬性,在 admin 權限後台比較常用
注意 PagedResultDto 的使用方法是 PagedResultDto<BookDto>,它的內部會保存 IReadOnlyList<BookDto> 與 TotalCount 屬性,
它只是個給前端用的包裝層,並不是給領域實體轉換用的 DTO。
應用層實做
在 BookStoreScratch.Application.Contracts 專案建立一個新的資料夾 Books 並建立 DTO。
using System;
using Volo.Abp.Application.Dtos;
namespace BookStoreScratch.Books;
public class BookDto : EntityDto<Guid>
{
public string Name { get; set; }
public BookType BookType { get; set; }
public DateTime PublishDate { get; set; }
public decimal Price { get; set; }
}
using System;
using System.ComponentModel.DataAnnotations;
using Volo.Abp.Application.Dtos;
namespace BookStoreScratch.Books;
public class CreateUpdateBookDto
{
[Required]
[StringLength(128)]
public string Name { get; set; }
[Required]
public BookType BookType { get; set; } = BookType.Undefined;
[Required]
[DataType(DataType.Date)]
public DateTime PublishDate { get; set; } = DateTime.Now;
[Required]
public decimal Price { get; set; }
}
using Volo.Abp.Application.Dtos;
namespace BookStoreScratch.Books;
public class BookGetListInput : PagedAndSortedResultRequestDto
{
public string? Filter { get; set; }
}
建立 IBookAppService 介面並繼承 ICrudAppService 界面
using System;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
namespace BookStoreScratch.Books;
public interface IBookAppService
: ICrudAppService<
BookDto,
Guid,
BookGetListInput,
CreateUpdateBookDto>
{
}
到 BookStoreScratch.Application 專案建立資料夾 Books 並實做 IBookAppService,這裡繼承 ApplicationService 所以只有注入基礎的 ABP 服務。
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
namespace BookStoreScratch.Books;
public class BookAppService: ApplicationService, IBookAppService
{
private readonly IBookStoreScratchRepository _bookStoreScratchRepository;
private readonly BookManager _bookManager;
public BookAppService(IBookStoreScratchRepository bookStoreScratchRepository, BookManager bookManager)
{
_bookStoreScratchRepository = bookStoreScratchRepository;
_bookManager = bookManager;
}
public async Task<BookDto> GetAsync(Guid id)
{
var book = await _bookStoreScratchRepository.GetAsync(id);
return ObjectMapper.Map<Book, BookDto>(book);
}
public async Task<PagedResultDto<BookDto>> GetListAsync(BookGetListInput input)
{
var books = await _bookStoreScratchRepository.GetPagedAndFilteredListAsync(
input.SkipCount,
input.MaxResultCount,
input.Sorting.IsNullOrEmpty() ? "Id desc" : input.Sorting,
input.Filter,
includeDetails: true);
var count = await _bookStoreScratchRepository.GetFilteredCountAsync(input.Filter);
return new PagedResultDto<BookDto>(count, ObjectMapper.Map<List<Book>, List<BookDto>>(books));
}
public async Task<BookDto> CreateAsync(CreateUpdateBookDto input)
{
var book = _bookManager.CreateBook(input.Name, input.BookType, input.PublishDate, input.Price);
await _bookStoreScratchRepository.InsertAsync(book);
return ObjectMapper.Map<Book, BookDto>(book);
}
public async Task<BookDto> UpdateAsync(Guid id, CreateUpdateBookDto input)
{
var book = await _bookStoreScratchRepository.GetAsync(id);
book.Name = input.Name;
book.BookType = input.BookType;
book.PublishDate = input.PublishDate;
book.Price = input.Price;
await _bookStoreScratchRepository.UpdateAsync(book);
return ObjectMapper.Map<Book, BookDto>(book);
}
public Task DeleteAsync(Guid id)
{
return _bookStoreScratchRepository.DeleteAsync(id);
}
}
回到領域層修改介面,加上新的分頁用方法,由於我們繼承的是 IBasicRepository 因此應用層沒辦法直接拿到 IQueryable,
所以要額外定義方法,確保資料傳出應用設施層時已經查詢完畢。
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.Domain.Repositories;
namespace BookStoreScratch;
public interface IBookStoreScratchRepository : IBasicRepository<Book, Guid>
{
Task<Book?> FindByBookNameAsync(string name);
Task<List<Book>> GetPagedAndFilteredListAsync(
int skipCount,
int maxResultCount,
string sorting,
string filter,
bool includeDetails = false,
CancellationToken cancellationToken = default);
Task<int> GetFilteredCountAsync(string filter, CancellationToken cancellationToken = default);
}
到應用設施層添加實做。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Dynamic.Core;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Volo.Abp.Domain.Repositories.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
namespace BookStoreScratch.EntityFrameworkCore;
public class EfCoreBookStoreScratchRepository: EfCoreRepository<BookStoreScratchDbContext, Book, Guid>, IBookStoreScratchRepository
{
public EfCoreBookStoreScratchRepository(IDbContextProvider<BookStoreScratchDbContext> dbContextProvider) : base(dbContextProvider)
{
}
public async Task<Book?> FindByBookNameAsync(string name)
{
return await (await GetDbSetAsync())
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Name == name);
}
public async Task<List<Book>> GetPagedAndFilteredListAsync(
int skipCount,
int maxResultCount,
string sorting,
string filter,
bool includeDetails = false,
CancellationToken cancellationToken = default)
{
var queryable = (includeDetails ? await WithDetailsAsync() : await GetQueryableAsync());
queryable = CreateFilteredQuery(queryable, filter)
.OrderBy(sorting.IsNullOrEmpty() ? $"{nameof(Book.PublishDate)} desc" : sorting)
.Skip(skipCount)
.Take(maxResultCount);
return await queryable.ToListAsync(GetCancellationToken(cancellationToken));
}
public async Task<int> GetFilteredCountAsync(string filter, CancellationToken cancellationToken = default)
{
var queryable = await GetQueryableAsync();
return await CreateFilteredQuery(queryable, filter)
.CountAsync(GetCancellationToken(cancellationToken));
}
protected virtual IQueryable<Book> CreateFilteredQuery(
IQueryable<Book> queryable,
string filter)
{
var query = queryable
.WhereIf(!filter.IsNullOrEmpty(), x => x.Name.ToLower().Contains(filter.ToLower()));
return query;
}
}
如果想要跳過這部分可以直接繼承 CrudAppService,全部都用預設的實做。
using System;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
namespace BookStoreScratch.Books;
public class BookAppService :
CrudAppService<
Book,
BookDto,
Guid,
PagedAndSortedResultRequestDto,
CreateUpdateBookDto>,
IBookAppService
{
public BookAppService(IRepository<Book, Guid> repository) : base(repository)
{
}
}
Mapper 安裝
之前在流程說明中有提到需要將領域模型轉換成 DTO 避免模型外洩,這部分就是透過 ObjectMapper.Map 方法達成,這部分是由 ApplicationService
在背後使用 Lazy 方式取得依賴注入 IObjectMapper 服務,這裡有幾種選擇:
- 完全手動映射
- 使用 ABP 內建的
DefaultObjectMapper - 安裝第三方 AutoMapper 或 Mapperly
這裡選擇用 ABP DefaultObjectMapper 能夠保持彈性,未來如果想要轉用第三方函式庫也可以直接轉換。
到 BookStoreScratch.Application 專案底下,可以整合在一個類別內方便處理也可以分開到多個類別。
using Volo.Abp.DependencyInjection;
using Volo.Abp.ObjectMapping;
namespace BookStoreScratch.Books;
public class BookMapper : IObjectMapper<Book, BookDto>, ITransientDependency
{
public BookDto Map(Book source)
{
return Map(source, new BookDto());
}
public BookDto Map(Book source, BookDto destination)
{
destination.Id = source.Id;
destination.Name = source.Name;
destination.BookType = source.BookType;
destination.PublishDate = source.PublishDate;
destination.Price = source.Price;
return destination;
}
}
public class CreateUpdateBookMapper : IObjectMapper<CreateUpdateBookDto, Book>, ITransientDependency
{
public Book Map(CreateUpdateBookDto source)
{
return new Book(
source.Id,
source.Name,
source.BookType,
source.PublishDate,
source.Price
);
}
public Book Map(CreateUpdateBookDto source, Book destination)
{
destination.Name = source.Name;
destination.BookType = source.BookType;
destination.PublishDate = source.PublishDate;
destination.Price = source.Price;
return destination;
}
}
目前會報錯,因為之前將建構子設定成 internal 的原因,所以沒被法在應用層直接新增實體,只能透過 BookManager 新增實體,
不過之前只是為了演示才這麼做,現在準備重構將相關的檢查直接搬到建構子內部。
using System;
using Volo.Abp;
using Volo.Abp.Domain.Entities;
namespace BookStoreScratch;
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; }
internal Book() {}
public Book(
Guid id,
string name,
BookType bookType,
DateTime publishDate,
decimal price)
: base(id)
{
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.");
}
Name = name;
BookType = bookType;
PublishDate = publishDate;
Price = price;
}
}
接下來可以選擇重構 BookStoreDataSeedContributor、BookAppService、BookManager_Tests,這裡就不多家贅述。
HTTP 層實做
應用層準備完成後是 HTTP 層,這個專案的主要目的是建立一個對外 Controller 來處理 HTTP 相關的功能。
我們知道在 Dotnet 中建立 Controller 時需要繼承 ControllerBase,在 ABP 中則是提供了 AbpControllerBase 給我們使用, AbpControllerBase 底層其實也是繼承了 ControllerBase 不過額外添加了許多 ABP 建立的功能。
建立新的 Http 層,名稱為 BookStoreScratch.HttpApi
dotnet new classlib -o src/BookStoreScratch.HttpApi
dotnet sln add src/BookStoreScratch.HttpApi
mkdir src/BookStoreScratch.HttpApi/BookStoreScratch
rm src/BookStoreScratch.HttpApi/Class1.cs
修改 csproj,修改 RootNamespace 添加依賴,注意這個層級只需要依賴 Application.Contracts,另外安裝了 Volo.Abp.AspNetCore.Mvc 函式庫,
它使用的是 Microsoft.NET.Sdk.Razor 並且 ABP 對其進行深度改造將整個 ASP.NET Core MVC 融合到 ABP 框架中,例如最常用的動態 Web API 就在這個函式庫實現。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace />
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BookStoreScratch.Application.Contracts\BookStoreScratch.Application.Contracts.csproj" />
<PackageReference Include="Volo.Abp.AspNetCore.Mvc" Version="9.0.1" />
</ItemGroup>
</Project>
前往 BookStoreScratch.HttpApi 建立一個 BookStoreScratch\Controllers 資料夾並新增一個 BookController 並實做 IBookAppService 界面,
這段程式主要就是在定義 HTTP Method 與 API Route,定義完成後透過依賴注入取得 BookAppService 並直接把請求轉送給它。
dotnet new class -n BookController -o src\BookStoreScratch.HttpApi\BookStoreScratch\Controllers --project src\BookStoreScratch.HttpApi\BookStoreScratch.HttpApi.csproj
using BookStoreScratch.Books;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp;
using Volo.Abp.Application.Dtos;
using Volo.Abp.AspNetCore.Mvc;
namespace BookStoreScratch.Controllers;
[Area("book")]
[RemoteService(Name = "book")]
[Route("api/book")]
public class BookController : AbpControllerBase, IBookAppService
{
private readonly IBookAppService _bookAppService;
public BookController(IBookAppService bookAppService)
{
_bookAppService = bookAppService;
}
[HttpGet("{id}")]
public async Task<BookDto> GetAsync(Guid id)
{
return await _bookAppService.GetAsync(id);
}
[HttpGet]
public async Task<PagedResultDto<BookDto>> GetListAsync(BookGetListInput input)
{
return await _bookAppService.GetListAsync(input);
}
[HttpPost]
public async Task<BookDto> CreateAsync(CreateUpdateBookDto input)
{
return await _bookAppService.CreateAsync(input);
}
[HttpPut("{id}")]
public async Task<BookDto> UpdateAsync(Guid id, CreateUpdateBookDto input)
{
return await _bookAppService.UpdateAsync(id, input);
}
[HttpDelete("{id}")]
public async Task DeleteAsync(Guid id)
{
await _bookAppService.DeleteAsync(id);
}
}
並且設定 ABP 模組 BookStoreScratchHttpApiModule,這裡需要在 PreConfigureServices 階段確保 MVC 框架正式啟動前把專案的 Assembly 載入,
避免 Controller 沒有正確讀取。
dotnet new class -n BookStoreScratchHttpApiModule -o src\BookStoreScratch.HttpApi\BookStoreScratch --project src\BookStoreScratch.HttpApi\BookStoreScratch.HttpApi.csproj
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.Modularity;
namespace BookStoreScratch;
[DependsOn(
typeof(BookStoreScratchApplicationContractsModule),
typeof(AbpAspNetCoreMvcModule))]
public class BookStoreScratchHttpApiModule : AbpModule
{
public override void PreConfigureServices(ServiceConfigurationContext context)
{
PreConfigure<IMvcBuilder>(mvcBuilder =>
{
mvcBuilder.AddApplicationPartIfNotExists(typeof(BookStoreScratchHttpApiModule).Assembly);
});
}
}
今天將應用層與 HTTP 層處理完成,基底差不多都已經完成,這個狀態已經可以模組安裝到其他 ABP 專案了,也可以在模組內額外建立 Host 專案, 方便未來能夠直接轉成微服務。
今天的進度 Github