文章目录
- 项目地址
- 一、项目主体
- 1. CQRS
- 1.1 Repository数据库接口
- 1.2 GetEventDetail 完整的Query流程
- 1.3 创建CreateEventCommand并使用validation
- 2. EFcore层
- 2.1 BaseRepository
- 2.2 CategoryRepository
- 2.3 OrderRepository
- 3. Email/Excel导出
- 3.1 Email
- 1. IEmail接口层
- 2. Email的Model层
- 3. 具体Email的实现层
- 4. 配置settings
- 3.2 Excel导出
- 1. 导出excel接口层
- 2. Controller层
- 3. Query层
- 4. 实现IExcelService
- 4. 定义response/全局错误处理中间件
- 4.1 统一response
- 1. 定义统一的返回类
- 2. 使用
- 4.2 全局错误处理中间件
- 5. 用户权限相关
- 5.1 用户权限相关的接口层
- 5.2 登录/注册/jwt 实体类定义
- 5.2 用户实体
- 5.3 用户认证所有接口实现的地方
- 5.4 用户服务注册
- 6. 添加日志
- 7. 版本控制
- 8. 分页
- 9. 配置中间件和服务注册
- 二、测试
- 1. Unitest
- 2. Integration Tests
项目地址
-
教程作者:ASP.NET Core Clean Architecture 2022-12
-
教程地址:
https://www.bilibili.com/video/BV1YZ421M7UA?spm_id_from=333.788.player.switch&vd_source=d14620e2c9f01dee5d2a104075027ad1&p=16
- 代码仓库地址:
- 所用到的框架和插件:
一、项目主体
- 整个项目4层结构
- Application层
1. CQRS
1.1 Repository数据库接口
- Application层的Contracts里的Persistence,存放数据库的接口
IAsyncRepository
:基类主要功能,规定 增删改查/单一查询/分页
namespace GloboTicket.TicketManagement.Application.Contracts.Persistence
{
public interface IAsyncRepository<T> where T : class
{
Task<T?> GetByIdAsync(Guid id);
Task<IReadOnlyList<T>> ListAllAsync();
Task<T> AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
Task<IReadOnlyList<T>> GetPagedReponseAsync(int page, int size);
}
}
ICategoryRepository.cs
:添加自己独特的GetCategoriesWithEvents 方法
namespace GloboTicket.TicketManagement.Application.Contracts.Persistence
{
public interface ICategoryRepository : IAsyncRepository<Category>
{
Task<List<Category>> GetCategoriesWithEvents(bool includePassedEvents);
}
}
IEventRepository.cs
:添加Event自己的方法
namespace GloboTicket.TicketManagement.Application.Contracts.Persistence
{
public interface IEventRepository : IAsyncRepository<Event>
{
Task<bool> IsEventNameAndDateUnique(string name, DateTime eventDate);
}
}
IOrderRepository.cs
: 没有自己的方法,直接继承使用
namespace GloboTicket.TicketManagement.Application.Contracts.Persistence
{
public interface IOrderRepository: IAsyncRepository<Order>
{
}
}
1.2 GetEventDetail 完整的Query流程
-
项目层级
-
EventDetailVm.cs
:用于返回给接口的数据
CategoryDto.cs
:表示在GetEventDetail里需要用到的Dto
GetEventDetailQuery.cs
:传入ID的值,以及返回EventDetailVm
GetEventDetailQueryHandler.cs
:返回查询
- 返回API的结构类似于
{
"eventId": "123e4567-e89b-12d3-a456-426614174000",
"name": "Rock Concert",
"price": 100,
"artist": "The Rock Band",
"date": "2023-12-25T20:00:00",
"description": "An amazing rock concert to end the year!",
"imageUrl": "https://example.com/images/rock-concert.jpg",
"categoryId": "456e7890-f12g-34h5-i678-901234567890",
"category": {
"id": "456e7890-f12g-34h5-i678-901234567890",
"name": "Music"
}
}
1.3 创建CreateEventCommand并使用validation
- 设置验证类
CreateEventCommandValidator.cs
using FluentValidation;
using GloboTicket.TicketManagement.Application.Contracts.Persistence;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace GloboTicket.TicketManagement.Application.Features.Events.Commands.CreateEvent
{
public class CreateEventCommandValidator : AbstractValidator<CreateEventCommand>
{
private readonly IEventRepository _eventRepository;
public CreateEventCommandValidator(IEventRepository eventRepository)
{
_eventRepository = eventRepository;
RuleFor(p => p.Name)
.NotEmpty().WithMessage("{PropertyName} is required.")
.NotNull()
.MaximumLength(50).WithMessage("{PropertyName} must not exceed 50 characters.");
RuleFor(p => p.Date)
.NotEmpty().WithMessage("{PropertyName} is required.")
.NotNull()
.GreaterThan(DateTime.Now);
RuleFor(e => e)
.MustAsync(EventNameAndDateUnique)
.WithMessage("An event with the same name and date already exists.");
RuleFor(p => p.Price)
.NotEmpty().WithMessage("{PropertyName} is required.")
.GreaterThan(0);
}
private async Task<bool> EventNameAndDateUnique(CreateEventCommand e, CancellationToken token)
{
return !(await _eventRepository.IsEventNameAndDateUnique(e.Name, e.Date));
}
}
}
- Command类:
CreateEventCommand.cs
using MediatR;
namespace GloboTicket.TicketManagement.Application.Features.Events.Commands.CreateEvent
{
public class CreateEventCommand: IRequest<Guid>
{
public string Name { get; set; } = string.Empty;
public int Price { get; set; }
public string? Artist { get; set; }
public DateTime Date { get; set; }
public string? Description { get; set; }
public string? ImageUrl { get; set; }
public Guid CategoryId { get; set; }
public override string ToString()
{
return $"Event name: {Name}; Price: {Price}; By: {Artist}; On: {Date.ToShortDateString()}; Description: {Description}";
}
}
}
CreateEventCommandHandler.cs
:处理Command,并且使用validator
- 自定义验证逻辑:查询在
IEventRepository
接口里
2. EFcore层
-
数据库接口层:Core层的Contracts里的Persistence
-
实现层:Infrastructure层的Persistence
2.1 BaseRepository
BaseRepository.cs
:定义
2.2 CategoryRepository
CategoryRepository.cs
:继承BaseRepository,以及实现接口
2.3 OrderRepository
OrderRepository.cs
使用分页
3. Email/Excel导出
3.1 Email
1. IEmail接口层
- 接口
namespace GloboTicket.TicketManagement.Application.Contracts.Infrastructure
{
public interface IEmailService
{
Task<bool> SendEmail(Email email);
}
}
2. Email的Model层
- Model实体:定义Email发送的内容和设置
3. 具体Email的实现层
- 在Infrastructure层里的infrastructure里实现
4. 配置settings
appsettings.json
3.2 Excel导出
1. 导出excel接口层
- Core文件夹/Application类库/Contracts文件夹/infrastructure文件夹/
IEmailService.cs
namespace GloboTicket.TicketManagement.Application.Contracts.Infrastructure
{
public interface ICsvExporter
{
byte[] ExportEventsToCsv(List<EventExportDto> eventExportDtos);
}
}
2. Controller层
- API文件夹/GloboTicket.TicketManagement.Api类库/Controllers文件夹/
EventsController.cs
3. Query层
GetEventsExportQuery.cs
:返回值EventExportFileVm
类,无参数
using MediatR;
namespace GloboTicket.TicketManagement.Application.Features.Events.Queries.GetEventsExport
{
public class GetEventsExportQuery: IRequest<EventExportFileVm>
{
}
}
EventExportFileVm.cs
:定义返回的文件类
public class EventExportFileVm
{
public string EventExportFileName { get; set; } = string.Empty;
public string ContentType { get; set; } = string.Empty;
public byte[]? Data { get; set; }
}
EventExportDto.cs
:
namespace GloboTicket.TicketManagement.Application.Features.Events.Queries.GetEventsExport
{
public class EventExportDto
{
public Guid EventId { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime Date { get; set; }
}
}
- handler
4. 实现IExcelService
- Infrastructure文件夹/Infrastructure类库/FileExport文件夹/
CsvExporter.cs
using CsvHelper;
using GloboTicket.TicketManagement.Application.Contracts.Infrastructure;
using GloboTicket.TicketManagement.Application.Features.Events.Queries.GetEventsExport;
namespace GloboTicket.TicketManagement.Infrastructure.FileExport
{
public class CsvExporter : ICsvExporter
{
public byte[] ExportEventsToCsv(List<EventExportDto> eventExportDtos)
{
using var memoryStream = new MemoryStream();
using (var streamWriter = new StreamWriter(memoryStream))
{
using var csvWriter = new CsvWriter(streamWriter);
csvWriter.WriteRecords(eventExportDtos);
}
return memoryStream.ToArray();
}
}
}
4. 定义response/全局错误处理中间件
4.1 统一response
- 除了使用.net直接返回状态码之外,还可以统一响应的格式
{
"success": true, //是否成功
"message": "操作成功", //操作结果
"data": {}, //返回数据内容
"errorCode": null //错误类型或错误码
}
1. 定义统一的返回类
ApiResponse.cs
类:处理所有返回的格式
public class ApiResponse<T>
{
public bool Success { get; set; }
public string Message { get; set; }
public T? Data { get; set; }
public string? ErrorCode { get; set; }
public List<string>? ValidationErrors { get; set; }
public ApiResponse(bool success, string message, T? data = default, string? errorCode = null)
{
Success = success;
Message = message;
Data = data;
ErrorCode = errorCode;
}
public static ApiResponse<T> SuccessResponse(T data, string message = "操作成功")
{
return new ApiResponse<T>(true, message, data);
}
public static ApiResponse<T> ErrorResponse(string message, string errorCode, List<string>? validationErrors = null)
{
return new ApiResponse<T>(false, message, default, errorCode)
{
ValidationErrors = validationErrors
};
}
}
2. 使用
- 在Handler里使用
public async Task<ApiResponse<CreateCategoryDto>> Handle(CreateCategoryCommand request, CancellationToken cancellationToken)
{
// 1. 初始化响应
var validator = new CreateCategoryCommandValidator();
var validationResult = await validator.ValidateAsync(request);
// 2. 验证失败,返回错误响应
if (validationResult.Errors.Count > 0)
{
var validationErrors = validationResult.Errors.Select(e => e.ErrorMessage).ToList();
return ApiResponse<CreateCategoryDto>.ErrorResponse(
"请求验证失败",
"VALIDATION_ERROR",
validationErrors
);
}
// 3. 验证成功,继续处理业务逻辑
var category = new Category() { Name = request.Name };
category = await _categoryRepository.AddAsync(category);
var categoryDto = _mapper.Map<CreateCategoryDto>(category);
// 4. 返回成功响应
return ApiResponse<CreateCategoryDto>.SuccessResponse(categoryDto, "分类创建成功");
}
- 成功返回:
{
"success": true,
"message": "分类创建成功",
"data": {
"id": 1,
"name": "Sport"
}
}
- 验证失败
{
"success": false,
"message": "请求验证失败",
"errorCode": "VALIDATION_ERROR",
"validationErrors": [
"分类名称不能为空",
"分类名称长度不能超过50个字符"
]
}
4.2 全局错误处理中间件
5. 用户权限相关
5.1 用户权限相关的接口层
- Core文件夹/Application类库/Contracts文件夹/Identity文件夹
5.2 登录/注册/jwt 实体类定义
- Core文件夹/Application类库/Contracts文件夹/Models文件夹/Authentication文件夹
5.2 用户实体
- Infrastructure文件夹/GloboTicket.TicketManagement.Identity类库/Models文件夹
ApplicationUser.cs
:用户实体
//用户实体
namespace Demo.Domain.Entities
{
public class User
{
public Guid Id { get; set; } = Guid.NewGuid();
public string FirstName { get; set; } = null!;
public string LastName { get; set; } = null!;
public string Email { get; set; } = null!;
public string Password { get; set; } = null!;
public string Role { get; set; } = null!;
}
}
5.3 用户认证所有接口实现的地方
- 用户登录注册以及jwt所有接口实现的地方
5.4 用户服务注册
- 所有Jwt和用户相关的服务注册
namespace GloboTicket.TicketManagement.Identity
{
public static class IdentityServiceExtensions
{
public static void AddIdentityServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<JwtSettings>(configuration.GetSection("JwtSettings"));
services.AddDbContext<GloboTicketIdentityDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("GloboTicketIdentityConnectionString"),
b => b.MigrationsAssembly(typeof(GloboTicketIdentityDbContext).Assembly.FullName)));
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<GloboTicketIdentityDbContext>().AddDefaultTokenProviders();
services.AddTransient<IAuthenticationService, AuthenticationService>();
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(o =>
{
o.RequireHttpsMetadata = false;
o.SaveToken = false;
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
ValidIssuer = configuration["JwtSettings:Issuer"],
ValidAudience = configuration["JwtSettings:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JwtSettings:Key"]))
};
o.Events = new JwtBearerEvents()
{
OnAuthenticationFailed = c =>
{
c.NoResult();
c.Response.StatusCode = 500;
c.Response.ContentType = "text/plain";
return c.Response.WriteAsync(c.Exception.ToString());
},
OnChallenge = context =>
{
context.HandleResponse();
context.Response.StatusCode = 401;
context.Response.ContentType = "application/json";
var result = JsonSerializer.Serialize("401 Not authorized");
return context.Response.WriteAsync(result);
},
OnForbidden = context =>
{
context.Response.StatusCode = 403;
context.Response.ContentType = "application/json";
var result = JsonSerializer.Serialize("403 Not authorized");
return context.Response.WriteAsync(result);
}
};
});
}
}
}
6. 添加日志
7. 版本控制
8. 分页
9. 配置中间件和服务注册
- 模仿.ne5,将
Program.cs
里注册分离
- 创建
StartupExtensions.cs
用来将program.cs
里的代码分离
- 在
program.cs
里配置
二、测试
- 使用框架
Moq用来模拟数据
Shouldly 用来断言
xunit 测试框架
1. Unitest
- Automatically 代码片段测试,快速
- 测试的是Public API
- 独立运行 run in isolation
- 结果断言
2. Integration Tests
- end to end test between different layers
- more work to set up
- often linked with database