ABP IO 軟體框架教學 Part 2 - 從頭開始建立新模組 - 應用層與遠端服務層 ABP IO 軟體框架教學 Part 2 - 從頭開始建立新模組 - 應用層與遠端服務層

Published on Friday, June 16, 2023

Application Layer

今天要來處理剩下的應用層與遠端服務層,這兩個層級概念跟我們常用的 MVC 的模式有點類似,背後需要建立 Controller 與 ViewModel 來處理 CRUD 的操作,不過在 DDD 戰術設計中建立了兩個概念分別為 Application ServicesData Transfer Objects (DTOs)

在第一篇的文章中提到應用層為表現層與領域層中間的一層媒介,通常會建立一個任務來操作多個商業邏輯,以書店為例我們可以建立一個 Book Application Service 對書本進行增刪查改等商業邏輯,這裡 ABP 額外建立了一個層級叫做遠端服務層,這個層並不是必要的因為他的主要用途是用來定義回傳的 HTTP 請求, 這裡分開的用意是將商業邏輯全部包在應用層,MVC 的邏輯全部包遠端服務層,你也可以直接將遠端服務層刪除改寫到應用層也是同樣的道理, DTO 的意思就跟 ViewModel 一樣了,都是用來降低專案之間的耦合,如果直接使用昨天建立的 Book 實體(Entity), 雖然也是可行但以後只要修改 Entity 時會影響到表現層的邏輯。

所以我們目前需要的流程為建立一個 Application Services 與多個 DTO 來代表增刪查改的請求參數,並透過 AutoMapperDTO 轉換成我們實際需要的 Entity 最後呼叫昨天建立的 Repository 將資料保存到資料庫內。

接下來我們看一下 Volo.Abp.Ddd.ApplicationVolo.Abp.Ddd.Application.Contracts,從名稱可以看出這兩個 Package 跟 昨天提到的 Volo.Abp.Ddd.Domain 都是屬於 ABP 定義 DDD 相關戰術設計會用到的基礎 Package。

Application Services 常用的有以下幾種:

  • ApplicationService 為基礎的 Class 以下兩種都是繼承後添加額外的功能
  • CrudAppService 會自動幫忙建立 CRUD 的方法如果需求比較簡單可以直接繼承這個 Class。
  • ReadOnlyAppService 只會自動建立讀取的方法,可以用在一些報表展示的功能上。

DTO 就有非常多種類型可以讓我們直接繼承例如以下幾個:

  • EntityDto 為最基礎的 DTO 如果不需要其他功能可以選擇直接繼承
  • PagedResultDto 可以幫你的 DTO 添加 TotalCount 屬性讓前端能做到分頁的功能
  • AuditedEntityDto 能夠把你的 DTO 的操作紀錄到 ABP 審計系統裡面

至於為什麼應用層需要額外分出一個 Contracts 呢?這是根據 SOLID 的 DIP 依賴反轉原則所建立出的一個抽象層, 我們需要將抽象的界面定義在 Contracts 讓高階模組依賴這個應用層合約來避免未來低階模組變動時對高階模組產生影響。

了解過後我們先把 CRUD 的 DTO 建立出來, 前往 BookStoreScratch.Application.Contracts 建立一個新的資料夾 Books

// BookDto.cs

using System;
using Volo.Abp.Application.Dtos;

namespace BookStoreScratch.Books;

public class BookDto : AuditedEntityDto<Guid>
{
    public string Name { get; set; }

    public BookType Type { get; set; }

    public DateTime PublishDate { get; set; }

    public float Price { get; set; }
}
CreateUpdateBookDto.cs
using System;
using System.ComponentModel.DataAnnotations;

namespace BookStoreScratch.Books;

public class CreateUpdateBookDto
{
    [Required]
    [StringLength(128)]
    public string Name { get; set; }

    [Required]
    public BookType Type { get; set; } = BookType.Undefined;

    [Required]
    [DataType(DataType.Date)]
    public DateTime PublishDate { get; set; } = DateTime.Now;

    [Required]
    public float Price { get; set; }
}

這裡建立一個界面 IBookAppService 並繼承 ICrudAppService 界面,依序帶入需要的 DTO 與Entity 主鍵

// IBookAppService.cs

using System;
using BookStoreScratch.Books;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;

namespace BookStoreScratch;

public interface IBookAppService
    : ICrudAppService<
        BookDto,
        Guid,
        PagedAndSortedResultRequestDto,
        CreateUpdateBookDto>
{
}

完成後到 BookStoreScratch.Application 建立資料夾 Books 並實做 IBookAppService,這裡繼承 CrudAppService 主要是為了要它內部預設的 CRUD 方法。

// BookAppService.cs

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)
    {
    }
}

我們順便看一下 CrudAppService 的原始碼,可以看到並沒有使用特別的設計就是從 DI 取得 Repository 後直接呼叫內部的方法來操作資料, 因此你也可以直接實做 IBookAppService 需要的方法也可以達到同樣的效果。

CrudAppService & AbstractKeyCrudAppService Detail
public abstract class CrudAppService<TEntity, TGetOutputDto, TGetListOutputDto, TKey, TGetListInput, TCreateInput, TUpdateInput>
    : AbstractKeyCrudAppService<TEntity, TGetOutputDto, TGetListOutputDto, TKey, TGetListInput, TCreateInput, TUpdateInput>
    where TEntity : class, IEntity<TKey>
    where TGetOutputDto : IEntityDto<TKey>
    where TGetListOutputDto : IEntityDto<TKey>
{
    protected new IRepository<TEntity, TKey> Repository { get; }

    protected CrudAppService(IRepository<TEntity, TKey> repository)
        : base(repository)
    {
        Repository = repository;
    }

    protected override async Task DeleteByIdAsync(TKey id)
    {
        await Repository.DeleteAsync(id);
    }

    protected override async Task<TEntity> GetEntityByIdAsync(TKey id)
    {
        return await Repository.GetAsync(id);
    }

    protected override void MapToEntity(TUpdateInput updateInput, TEntity entity)
    {
        if (updateInput is IEntityDto<TKey> entityDto)
        {
            entityDto.Id = entity.Id;
        }

        base.MapToEntity(updateInput, entity);
    }

    protected override IQueryable<TEntity> ApplyDefaultSorting(IQueryable<TEntity> query)
    {
        if (typeof(TEntity).IsAssignableTo<IHasCreationTime>())
        {
            return query.OrderByDescending(e => ((IHasCreationTime)e).CreationTime);
        }
        else
        {
            return query.OrderByDescending(e => e.Id);
        }
    }
}
public abstract class AbstractKeyCrudAppService<TEntity, TGetOutputDto, TGetListOutputDto, TKey, TGetListInput, TCreateInput, TUpdateInput>
    : AbstractKeyReadOnlyAppService<TEntity, TGetOutputDto, TGetListOutputDto, TKey, TGetListInput>,
        ICrudAppService<TGetOutputDto, TGetListOutputDto, TKey, TGetListInput, TCreateInput, TUpdateInput>
    where TEntity : class, IEntity
{
    protected IRepository<TEntity> Repository { get; }

    protected virtual string CreatePolicyName { get; set; }

    protected virtual string UpdatePolicyName { get; set; }

    protected virtual string DeletePolicyName { get; set; }

    protected AbstractKeyCrudAppService(IRepository<TEntity> repository)
        : base(repository)
    {
        Repository = repository;
    }

    public virtual async Task<TGetOutputDto> CreateAsync(TCreateInput input)
    {
        await CheckCreatePolicyAsync();

        var entity = await MapToEntityAsync(input);

        TryToSetTenantId(entity);

        await Repository.InsertAsync(entity, autoSave: true);

        return await MapToGetOutputDtoAsync(entity);
    }

    public virtual async Task<TGetOutputDto> UpdateAsync(TKey id, TUpdateInput input)
    {
        await CheckUpdatePolicyAsync();

        var entity = await GetEntityByIdAsync(id);
        //TODO: Check if input has id different than given id and normalize if it's default value, throw ex otherwise
        await MapToEntityAsync(input, entity);
        await Repository.UpdateAsync(entity, autoSave: true);

        return await MapToGetOutputDtoAsync(entity);
    }

    public virtual async Task DeleteAsync(TKey id)
    {
        await CheckDeletePolicyAsync();

        await DeleteByIdAsync(id);
    }

    protected abstract Task DeleteByIdAsync(TKey id);

    protected virtual async Task CheckCreatePolicyAsync()
    {
        await CheckPolicyAsync(CreatePolicyName);
    }

    protected virtual async Task CheckUpdatePolicyAsync()
    {
        await CheckPolicyAsync(UpdatePolicyName);
    }

    protected virtual async Task CheckDeletePolicyAsync()
    {
        await CheckPolicyAsync(DeletePolicyName);
    }

    /// <summary>
    /// Maps <typeparamref name="TCreateInput"/> to <typeparamref name="TEntity"/> to create a new entity.
    /// It uses <see cref="MapToEntity(TCreateInput)"/> by default.
    /// It can be overriden for custom mapping.
    /// Overriding this has higher priority than overriding the <see cref="MapToEntity(TCreateInput)"/>
    /// </summary>
    protected virtual Task<TEntity> MapToEntityAsync(TCreateInput createInput)
    {
        return Task.FromResult(MapToEntity(createInput));
    }

    /// <summary>
    /// Maps <typeparamref name="TCreateInput"/> to <typeparamref name="TEntity"/> to create a new entity.
    /// It uses <see cref="IObjectMapper"/> by default.
    /// It can be overriden for custom mapping.
    /// </summary>
    protected virtual TEntity MapToEntity(TCreateInput createInput)
    {
        var entity = ObjectMapper.Map<TCreateInput, TEntity>(createInput);
        SetIdForGuids(entity);
        return entity;
    }

    /// <summary>
    /// Sets Id value for the entity if <typeparamref name="TKey"/> is <see cref="Guid"/>.
    /// It's used while creating a new entity.
    /// </summary>
    protected virtual void SetIdForGuids(TEntity entity)
    {
        if (entity is IEntity<Guid> entityWithGuidId && entityWithGuidId.Id == Guid.Empty)
        {
            EntityHelper.TrySetId(
                entityWithGuidId,
                () => GuidGenerator.Create(),
                true
            );
        }
    }

    /// <summary>
    /// Maps <typeparamref name="TUpdateInput"/> to <typeparamref name="TEntity"/> to update the entity.
    /// It uses <see cref="MapToEntity(TUpdateInput, TEntity)"/> by default.
    /// It can be overriden for custom mapping.
    /// Overriding this has higher priority than overriding the <see cref="MapToEntity(TUpdateInput, TEntity)"/>
    /// </summary>
    protected virtual Task MapToEntityAsync(TUpdateInput updateInput, TEntity entity)
    {
        MapToEntity(updateInput, entity);
        return Task.CompletedTask;
    }

    /// <summary>
    /// Maps <typeparamref name="TUpdateInput"/> to <typeparamref name="TEntity"/> to update the entity.
    /// It uses <see cref="IObjectMapper"/> by default.
    /// It can be overriden for custom mapping.
    /// </summary>
    protected virtual void MapToEntity(TUpdateInput updateInput, TEntity entity)
    {
        ObjectMapper.Map(updateInput, entity);
    }

    protected virtual void TryToSetTenantId(TEntity entity)
    {
        if (entity is IMultiTenant && HasTenantIdProperty(entity))
        {
            var tenantId = CurrentTenant.Id;

            if (!tenantId.HasValue)
            {
                return;
            }

            var propertyInfo = entity.GetType().GetProperty(nameof(IMultiTenant.TenantId));

            if (propertyInfo == null || propertyInfo.GetSetMethod(true) == null)
            {
                return;
            }

            propertyInfo.SetValue(entity, tenantId);
        }
    }

    protected virtual bool HasTenantIdProperty(TEntity entity)
    {
        return entity.GetType().GetProperty(nameof(IMultiTenant.TenantId)) != null;
    }
}

我們擷取一段 Create 方法來看看內部做了什麼事情

// AbstractKeyCrudAppService.cs

public virtual async Task<TGetOutputDto> CreateAsync(TCreateInput input)
{
    await CheckCreatePolicyAsync();

    var entity = await MapToEntityAsync(input);

    TryToSetTenantId(entity);

    await Repository.InsertAsync(entity, autoSave: true);

    return await MapToGetOutputDtoAsync(entity);
}

protected virtual TEntity MapToEntity(TCreateInput createInput)
{
    var entity = ObjectMapper.Map<TCreateInput, TEntity>(createInput);
    SetIdForGuids(entity);
    return entity;
}

protected virtual void SetIdForGuids(TEntity entity)
{
    if (entity is IEntity<Guid> entityWithGuidId && entityWithGuidId.Id == Guid.Empty)
    {
        EntityHelper.TrySetId(
            entityWithGuidId,
            () => GuidGenerator.Create(),
            true
        );
    }
}
// EfCoreRepository.cs

public override async Task<TEntity> InsertAsync(TEntity entity, bool autoSave = false, CancellationToken cancellationToken = default)
{
    CheckAndSetId(entity);

    var dbContext = await GetDbContextAsync();

    var savedEntity = (await dbContext.Set<TEntity>().AddAsync(entity, GetCancellationToken(cancellationToken))).Entity;

    if (autoSave)
    {
        await dbContext.SaveChangesAsync(GetCancellationToken(cancellationToken));
    }

    return savedEntity;
}

可以整理出以下幾個關鍵步驟:

  1. CheckCreatePolicyAsync: 會檢查使用者是否有權限呼叫新增功能,要搭配 ABP 的權限系統。
  2. MapToEntityAsync: 內部需要搭配 AutoMapper 來將傳入的 DTO 轉換成真正需要的 Entity,並且會幫忙檢查 Entity 使否有帶入主鍵沒有的話會自動建立。
  3. TryToSetTenantId: 自動設定 Entity 的租戶 ID,要搭配 ABP 的租戶系統。
  4. Repository.InsertAsync: 呼叫 Repository 的 Insert 方法,會使用到上篇文章建立的 BookRepository
  5. MapToGetOutputDtoAsync: 將建立完成的 Entity 再次透過 AutoMapper 轉換成顯示專用的 DTO。

首先 ABP 的權限系統與租戶系統是可選的,如果你沒有需要使用的話內部也會自行跳過,所以最關鍵的就是物件映射這一塊也就是 AutoMapper 提供的功能, 換句話說要使用 CrudAppService 就必須要搭配 AutoMapper 否則會沒辦法運行。

所以我們先到 BookStoreScratch.Application 安裝 Volo.Abp.AutoMapper 並建立 Profile。

dotnet add package Volo.Abp.AutoMapper --version 7.2.2
// BookStoreScratchApplicationAutoMapperProfile.cs

using AutoMapper;
using BookStoreScratch.Books;

namespace BookStoreScratch;

public class BookStoreScratchApplicationAutoMapperProfile : Profile
{
    public BookStoreScratchApplicationAutoMapperProfile()
    {
        CreateMap<Book, BookDto>();
        CreateMap<CreateUpdateBookDto, Book>();
    }
}

安裝後將 AbpAutoMapperModule 註冊到我們的 BookStoreScratchApplicationModule 並使用 AddMaps 方法將我們的 Profile 註冊到系統內。

using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Application;
using Volo.Abp.AutoMapper;
using Volo.Abp.Modularity;

namespace BookStoreScratch;

[DependsOn(
    typeof(AbpDddApplicationModule),
    typeof(AbpAutoMapperModule)
)]
[DependsOn(
    typeof(BookStoreScratchApplicationContractsModule),
    typeof(BookStoreScratchDomainModule)
)]
public class BookStoreScratchApplicationModule : AbpModule
{
    public override void ConfigureServices(ServiceConfigurationContext context)
    {
        context.Services.AddAutoMapperObjectMapper<BookStoreScratchApplicationModule>();
        Configure<AbpAutoMapperOptions>(options =>
        {
            options.AddMaps<BookStoreScratchApplicationModule>();
        });
    }
}

HttpApi Layer

應用層準備完成後最後是遠端服務層,我們之前在準備專案時有幫這個專案安裝了一個 Volo.Abp.AspNetCore.Mvc 與參考 BookStoreScratch.Application.Contracts 專案,這個專案的主要目的是建立一個額外的 Controller 層級來處理 HTTP 相關的回應。

我們知道在 Dotnet 中建立 Controller 時需要繼承 ControllerBase,在 ABP 中則是提供了 AbpControllerBase 給我們使用, 底層其實也是繼承了 ControllerBase 不過額外添加了許多 ABP 建立的功能。

接下來前往 BookStoreScratch.HttpApi 建立一個 Controllers 資料夾並新增一個 BookController 並實做 IBookAppService 界面, 這段程式主要就是在定義 HTTP Method 與 API Route,定義完成後透過依賴注入取得 BookAppService 並直接把請求轉送給它,因為大部分的功能都是 CRUD 的操作而已,所以這種轉送的工作其實也可以交由 ABP 幫忙自動產生詳細的流程會在設定 Host 專案時提到。

// BookController.cs

using System;
using System.Threading.Tasks;
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(PagedAndSortedResultRequestDto 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);
    }
}

Summary

今天將應用層與遠端服務層處理完成,基底差不多都已經完成了接下來就準備進入實際運作的部份,也就是 Host 專案。 我們將透過這個專案將把我們寫的模組實際運作起來並且搭配 Swagger 變成一個可以實際操作的工具,也可以試試看將我們的模組 上傳到 Nuget 上面方便之後的專案可以直接引用。

今天的進度 Github