系列導航
需求
需求很簡單:實現GET
請求獲取業務數據。在這個階段我們經常使用的類庫是AutoMapper。
目標
合理組織並使用AutoMapper,完成GET
請求。
原理與思路
首先來簡單地介紹一下這這個類庫。
關於AutoMapper
在業務側代碼和數據庫實體打交道的過程中,一個必不可少的部分就是返回的數據類型轉換。對於不同的請求來說,希望得到的返回值是數據庫實體的一部分/組合/計算等情形。我們就經常需要手寫用於數據對象轉換的代碼,但是轉換前后可能大部分情況下有着相同名稱的字段或屬性。這部分工作能避免手寫冗長的代碼嗎?可以。
我們希望接受的請求和返回的值(統一稱為model)具有以下兩點需要遵循的原則:
- 每個model被且只被一個API消費;
- 每個model里僅僅包含API發起方希望包含的必要字段或屬性。
AutoMapper庫就是為了實現這個需求而存在的,它的具體用法請參考官方文檔,尤其是關於Convention
的部分,避免重復勞動。
實現
所有需要使用AutoMapper
的地方都集中在Application
項目中。
引入AutoMapper
$ dotnet add src/TodoList.Application/TodoList.Application.csproj package AutoMapper.Extensions.Microsoft.DependencyInjection
然后在Application/Common/Mappings
下添加配置,提供接口的原因是我們后面就可以在DTO
里實現各自對應的Mapping規則,方便查找。
IMapFrom.cs
using AutoMapper;
namespace TodoList.Application.Common.Mappings;
public interface IMapFrom<T>
{
void Mapping(Profile profile) => profile.CreateMap(typeof(T), GetType());
}
MappingProfile.cs
using System.Reflection;
using AutoMapper;
namespace TodoList.Application.Common.Mappings;
public class MappingProfile : Profile
{
public MappingProfile() => ApplyMappingsFromAssembly(Assembly.GetExecutingAssembly());
private void ApplyMappingsFromAssembly(Assembly assembly)
{
var types = assembly.GetExportedTypes()
.Where(t => t.GetInterfaces().Any(i =>
i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IMapFrom<>)))
.ToList();
foreach (var type in types)
{
var instance = Activator.CreateInstance(type);
var methodInfo = type.GetMethod("Mapping")
?? type.GetInterface("IMapFrom`1")!.GetMethod("Mapping");
methodInfo?.Invoke(instance, new object[] { this });
}
}
}
在DependencyInjection.cs
進行依賴注入:
DependencyInjection.cs
// 省略其他...
services.AddAutoMapper(Assembly.GetExecutingAssembly());
services.AddMediatR(Assembly.GetExecutingAssembly());
return services;
實現GET請求
在本章中我們只實現TodoList
的Query
接口(GET),並且在結果中包含TodoItem
集合,剩下的接口后面的文章中逐步涉及。
GET All TodoLists
在Application/TodoLists/Queries/
下新建一個目錄GetTodos
用於存放創建一個TodoList
相關的所有邏輯:
定義TodoListBriefDto
對象:
TodoListBriefDto.cs
using TodoList.Application.Common.Mappings;
namespace TodoList.Application.TodoLists.Queries.GetTodos;
// 實現IMapFrom<T>接口,因為此Dto不涉及特殊字段的Mapping規則
// 並且屬性名稱與領域實體保持一致,根據Convention規則默認可以完成Mapping,不需要額外實現
public class TodoListBriefDto : IMapFrom<Domain.Entities.TodoList>
{
public Guid Id { get; set; }
public string? Title { get; set; }
public string? Colour { get; set; }
}
GetTodosQuery.cs
using AutoMapper;
using AutoMapper.QueryableExtensions;
using MediatR;
using Microsoft.EntityFrameworkCore;
using TodoList.Application.Common.Interfaces;
namespace TodoList.Application.TodoLists.Queries.GetTodos;
public class GetTodosQuery : IRequest<List<TodoListBriefDto>>
{
}
public class GetTodosQueryHandler : IRequestHandler<GetTodosQuery, List<TodoListBriefDto>>
{
private readonly IRepository<Domain.Entities.TodoList> _repository;
private readonly IMapper _mapper;
public GetTodosQueryHandler(IRepository<Domain.Entities.TodoList> repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<List<TodoListBriefDto>> Handle(GetTodosQuery request, CancellationToken cancellationToken)
{
return await _repository
.GetAsQueryable()
.AsNoTracking()
.ProjectTo<TodoListBriefDto>(_mapper.ConfigurationProvider)
.OrderBy(t => t.Title)
.ToListAsync(cancellationToken);
}
}
最后實現Controller
層的邏輯:
TodoListController.cs
// 省略其他...
[HttpGet]
public async Task<ActionResult<List<TodoListBriefDto>>> Get()
{
return await _mediator.Send(new GetTodosQuery());
}
GET Single TodoList
首先在Application/TodoItems/Queries/
下新建目錄GetTodoItems
用於存放獲取TodoItem
相關的所有邏輯:
定義TodoItemDto
和TodoListDto
對象:
TodoItemDto.cs
using AutoMapper;
using TodoList.Application.Common.Mappings;
using TodoList.Domain.Entities;
namespace TodoList.Application.TodoItems.Queries.GetTodoItems;
// 實現IMapFrom<T>接口
public class TodoItemDto : IMapFrom<TodoItem>
{
public Guid Id { get; set; }
public Guid ListId { get; set; }
public string? Title { get; set; }
public bool Done { get; set; }
public int Priority { get; set; }
// 實現接口定義的Mapping方法,並提供除了Convention之外的特殊字段的轉換規則
public void Mapping(Profile profile)
{
profile.CreateMap<TodoItem, TodoItemDto>()
.ForMember(d => d.Priority, opt => opt.MapFrom(s => (int)s.Priority));
}
}
TodoListDto.cs
using TodoList.Application.Common.Mappings;
using TodoList.Application.TodoItems.Queries.GetTodoItems;
namespace TodoList.Application.TodoLists.Queries.GetSingleTodo;
// 實現IMapFrom<T>接口,因為此Dto不涉及特殊字段的Mapping規則
// 並且屬性名稱與領域實體保持一致,根據Convention規則默認可以完成Mapping,不需要額外實現
public class TodoListDto : IMapFrom<Domain.Entities.TodoList>
{
public Guid Id { get; set; }
public string? Title { get; set; }
public string? Colour { get; set; }
public IList<TodoItemDto> Items { get; set; } = new List<TodoItemDto>();
}
創建一個根據ListId
來獲取包含TodoItems
子項的spec:
TodoListSpec.cs
using Microsoft.EntityFrameworkCore;
using TodoList.Application.Common;
namespace TodoList.Application.TodoLists.Specs;
public sealed class TodoListSpec : SpecificationBase<Domain.Entities.TodoList>
{
public TodoListSpec(Guid id, bool includeItems = false) : base(t => t.Id == id)
{
if (includeItems)
{
AddInclude(t => t.Include(i => i.Items));
}
}
}
我們仍然為這個查詢新建一個GetSingleTodo
目錄,並實現GetSIngleTodoQuery
:
GetSingleTodoQuery.cs
using AutoMapper;
using AutoMapper.QueryableExtensions;
using MediatR;
using Microsoft.EntityFrameworkCore;
using TodoList.Application.Common.Interfaces;
using TodoList.Application.TodoLists.Specs;
namespace TodoList.Application.TodoLists.Queries.GetSingleTodo;
public class GetSingleTodoQuery : IRequest<TodoListDto?>
{
public Guid ListId { get; set; }
}
public class ExportTodosQueryHandler : IRequestHandler<GetSingleTodoQuery, TodoListDto?>
{
private readonly IRepository<Domain.Entities.TodoList> _repository;
private readonly IMapper _mapper;
public ExportTodosQueryHandler(IRepository<Domain.Entities.TodoList> repository, IMapper mapper)
{
_repository = repository;
_mapper = mapper;
}
public async Task<TodoListDto?> Handle(GetSingleTodoQuery request, CancellationToken cancellationToken)
{
var spec = new TodoListSpec(request.ListId, true);
return await _repository
.GetAsQueryable(spec)
.AsNoTracking()
.ProjectTo<TodoListDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync(cancellationToken);
}
}
添加Controller邏輯,這里的Name
是為了完成之前遺留的201返回的問題,后文會有使用。
TodoListController.cs
// 省略其他...
[HttpGet("{id:Guid}", Name = "TodListById")]
public async Task<ActionResult<TodoListDto>> GetSingleTodoList(Guid id)
{
return await _mediator.Send(new GetSingleTodoQuery
{
ListId = id
}) ?? throw new InvalidOperationException();
}
驗證
運行Api
項目
獲取所有TodoList列表
-
請求
-
響應
獲取單個TodoList詳情
-
請求
-
響應
填一個POST文章里的坑
在使用.NET 6開發TodoList應用(6)——使用MediatR實現POST請求中我們留了一個問題,即創建TodoList
后的返回值當時我們是臨時使用Id
返回的,推薦的做法是下面這樣:
需要修改CreateTodoListCommand
的定義,現在我們需要返回實體對象而不是原先的Id:
CreateTodoListCommand.cs
using MediatR;
using TodoList.Application.Common.Interfaces;
using TodoList.Domain.ValueObjects;
namespace TodoList.Application.TodoLists.Commands.CreateTodoList;
public class CreateTodoListCommand : IRequest<Domain.Entities.TodoList>
{
public string? Title { get; set; }
public string? Colour { get; set; }
}
public class CreateTodoListCommandHandler : IRequestHandler<CreateTodoListCommand, Domain.Entities.TodoList>
{
private readonly IRepository<Domain.Entities.TodoList> _repository;
public CreateTodoListCommandHandler(IRepository<Domain.Entities.TodoList> repository)
{
_repository = repository;
}
public async Task<Domain.Entities.TodoList> Handle(CreateTodoListCommand request, CancellationToken cancellationToken)
{
var entity = new Domain.Entities.TodoList
{
Title = request.Title,
Colour = Colour.From(request.Colour ?? string.Empty)
};
await _repository.AddAsync(entity, cancellationToken);
return entity;
}
}
// 省略其他...
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateTodoListCommand command)
{
var createdTodoList = await _mediator.Send(command);
// 創建成功返回201
return CreatedAtRoute("TodListById", new { id = createdTodoList.Id }, createdTodoList);
}
-
請求
-
返回
Content部分
以及Header部分
我們主要觀察返回的HTTPStatusCode
是201,並且在Header
中location
字段表明了創建資源的位置。
總結
其他和查詢請求相關的例子我就不多舉了,通過兩個簡單的例子想說明如何組織CQRS模式下的代碼邏輯。我們可以直觀地看出,CQRS操作是通過IRequest
和IRequestHandler
實現的,其中IRequest
部分直接和API接口的請求參數直接或間接相關聯,將請求參數通過注入的_mediator
對象進行處理。
同時我們在實現兩個查詢接口的過程中也可以發現,查詢語句中的Select
部分現在已經被AutoMapper
的相關功能替代掉了,所以在調用Repository
時,可能並不經常用到SelectXXXX
相關的具有數據類型轉換的接口,更多的還是使用返回IQueryable
對象的接口。這和我在使用.NET 6開發TodoList應用(5.1)——實現Repository模式中實踐的有一點出入,在那篇文章中,我之所以把Repository
的抽象層次做的很高的原因是,我希望順便把類似的類庫實現思路也梳理一下。就像評論中有朋友提出的那樣,其實更多的場合下,因為會配合系統里其他組件的使用,比如這里的AutoMapper
,那么對於Repository
的實際需求就變成了只需要給我一個IQueryable
對象即可。這也是我在那篇文章中試圖強調的那樣:關於Repository
,每個人的理解和實現都有差別,因為取決於抽象程度和應用場合。
這一篇文章處理了關於GET
的請求,有一個小的知識點沒有講到:后台分頁返回,這部分內容會在后面專門再回到查詢的場景里來說。然后又留了一個小坑下一篇文章來說:全局異常處理和統一返回類型。