Spiga

云原生电商微服务实战2:用户微服务

2024-09-14 11:51:22

上一篇我们介绍了本项目的基础架构,实际上共享项目里面还有很多内容并没有介绍。这是因为在没有具体业务前,光谈抽象类并不是很好理解,因此除了领域模型的规范和通用EFCore的实现外,其他的通用共享类都放到具体的业务中讲。

接下来,本次的出题就开始直接进入业务实现了,首先我们来谈第一个微服务,用户微服务。

一、用户服务领域层

因为我们整个项目采用的是基于洋葱模型的整洁架构,结合DDD的经典分层,我们的微服务基本上都分层四层。

ps:因为是培训项目,实际业务并不一定每个微服务都要采用一样的分层,实际开发中如果是小的,业务相对稳定微服务哪怕直接用文件夹来区分层次也是可以了。

  1. 在services文件夹中创建user文件夹,再创建DDM.DHT.UserService.Core类库项目,可以讲项目默认命名空间改成DDM.DHT.UserService。

    接着创建Entities文件夹,再创建User.cs类,代码如下:

    public class User : BaseAuditEntity, IAggregateRoot
    {
        protected User() { }
    
        public User(string loginId, string phone)
        {
            LoginId = loginId;
            Phone = phone;
    
            Random random = new Random();
            Name = Phone.Substring(0, Phone.Length - 4) + "_" + random.Next(100, 999);
            UseAble = true;
            Salt = loginId.MD5EncodingOnly();
            PasswordHash = "123456".MD5EncodingWithSalt(Salt); // default password is 123456
        }
    
        public string LoginId { get; private set; } = null!;
    
        public string Name { get; private set; } = null!;
    
        public string Phone { get; private set; } = null!;
    
        public string? Email { get; private set; }
    
        public string PasswordHash { get; private set; } = null!;
    
        public string Salt { get; private set; } = null!;
    
        public bool UseAble { get; private set; }
    
        public bool IsDeleted { get; private set; }
    
    
        public void Update(string name, string? email)
        { 
            Name = name;
            Email = email;
        }
    
        public void ChangePhone(string phone)
        { 
            Phone = phone;
        }
    
        public void ChangePwd(string password)
        {
            PasswordHash = password.MD5EncodingWithSalt(Salt);
        }
    
        public void Disable()
        {
            UseAble = false;
        }
    
        public void Enable()
        {
            UseAble = true;
        }
    
        public void Delete()
        {
            IsDeleted = true;
        }
    }
    
    • 该类继承IAggregateRoot接口,因此它是一个聚合根

    • 该类继承BaseAuditEntity抽象类,说明它是一个需要审计修改时间的实体

    • protected User() { }是给EFCore Code First提供的构造方法,该项目中所有的聚合跟都需要提供一个受保护的默认构造方法

    • 我们看到所有的属性的set方法都是私有的,实际业务中只需要私有化核心的业务属性即可,一些非业务属性,比如IsDeleted属性是软删除属性,并非业务属性。还有该类中的LoginId、PasswordHash和Salt,它们是验证属性,也非业务属性。在具体的开发中,每个人对业务属性的理解都不一样,会存在一些模拟两可的属性,比如该类我们也可以认为LoginId和PasswordHash就是业务属性,因为登录也可以认为是具体的业务。为了不在业务和非业务属性上有过多的讨论,本来直接把所有的属性都设置了私有set方法

    • 因为所有属性都是私有的,那属性的赋值就只能是在构造方法,或者实体类自身的方法来完成。于是我们可以看到该类是有一系列方法的,如ChangePhone、Enable等,这就叫充血模型。相对的只有属性,没有方法的实体类被称为贫血模型。充血模型更重要的是,它的每个方法实际上对应这模型的具体业务。比如:

      • 构造方法--->创建实体
      • Update--->修改实体
      • ChangePhone--->修改手机号
      • ChangePwd--->修改密码
      • Enable--->启用用户
      • Disable--->禁用用户
    • 在构造方法中我们还可以看到下面这样的逻辑,默认用户名和默认密码。这种默认规则本身就是模型自己的规格,在贫血模型中,这种规则代码可能需要写上层逻辑中,再把值传递给实体对象,这样规则和模型就分离了,对于开发人员来增加了模型理解难度。而在充血模型中,规则直接写在模型中,开发人员能直接解释业务规则。

      Name = Phone.Substring(-4) + "_" + random.Next(100, 999);
      PasswordHash = "123456".MD5EncodingWithSalt(Salt); // default password is 123456
      
    • 对于复杂的业务规则,比如不是实体自身就能完成的业务规则。如用户编码规则是前缀+(当前用户总数+1),这个业务中当前用户数在单个用户实体中是不知道的,要完成这个规则就需要使用到领域服务,接着我们来完成这个。

  2. 我们在User类中添加Code属性,注意看它的set方法是internal,这是因为Code属性的赋值需要在领域服务中实现。

    public string Code { get; internal set; } = null!;
    
  3. 创建UserManager.cs,这是类就是用户实体的领域服务类。

    public class UserManager(IRepository<User> repository)
    {
        public async Task<User> SetCode(User user)
        { 
            var count = await repository.CountAsync(null);
            user.Code = "DDM" + (count + 1).ToString("000000"); //真实案例中这里需要考虑并发问题
            return user;
        }
    }
    

二、用户服务基础设施层

  1. 我们继续,创建DDM.DHT.UserService.Infrastructure项目。创建Data文件夹,再新建UserDbContext.cs类

    public class UserDbContext(DbContextOptions<UserDbContext> options) : DbContext(options)
    {
        public DbSet<User> Users => Set<User>();
    
        protected override void OnModelCreating(ModelBuilder builder)
        {
            base.OnModelCreating(builder);
            builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
        }
    }
    

    这个类就是EF的DbContext上下文对象,实体类就只有User类。

  2. 再创建一个UserReadDbContext.cs,实现数据库的读写分离,这类先把读写分离的结构提供出来。

    public class UserReadDbContext(DbContextOptions<UserDbContext> options) : UserDbContext(options);
    
  3. 新建Repositories文件夹,创建UserRepository.cs,实现User实体的仓储

    public class UserRepository(UserDbContext dbContext) : EfRepository<User>(dbContext);
    
  4. 创建DependencyInjection.cs,定义依赖注入关系

    public static class DependencyInjection
    {
        public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration)
        {
            ConfigureEfCore(services, configuration);
    
            return services;
        }
    
        private static void ConfigureEfCore(IServiceCollection services, IConfiguration configuration)
        {
            services.AddInfrastructureEfCore<UserDbContext, UserReadDbContext>(configuration);
    
            services.AddScoped(typeof(IUserRepository), typeof(UserRepository));
        }
    }
    

三、用户服务用例层

  1. 创建DDM.DHT.UserService.UseCases项目,新建Users文件夹,再创建Commands和Queries文件夹,分别用来存放命令和查询的具体用例

  2. 创建UserDto类和MappingProfile类,用于定义User实体传输对象,以及自动属性赋值的支持。目前User类的属性并不多,直接写赋值也很容易,如果遇到属性很多的类,自动Mapping能减少大量代码。

    public class UserDto
    {
        public long Id { get; set; }
    
        public string Name { get; set; } = null!; 
    
        public string Code { get; set; } = null!;
    
        public string Phone { get; set; } = null!;
    
        public string Email { get; set; } = null!;
    
        public string UseAble { get; set; } = null!;
    }
    
    public class MappingProfile : Profile
    {
        public MappingProfile()
        {
            CreateMap<User, UserDto>();
        }
    }
    
  3. 实现用户命令:根据用户实体类的定义,我们可以分析出用户实体的命令大致包括:Create、Update、ChangePhone、ChangePwd、Enable、Disable、Delete等方法。

    public record CreateUserCommand(string LoginId, string Phone) : ICommand<Result<long>>;
    
    public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
    {
        public CreateUserCommandValidator()
        {
            RuleFor(command => command.LoginId)
                .NotEmpty()
                .MaximumLength(DataSchemaConstants.DefaultUserLoginIdMaxLength);
    
            RuleFor(command => command.Phone)
                .NotEmpty()
                .MinimumLength(DataSchemaConstants.DefaultUserPhoneMinLength)
                .MaximumLength(DataSchemaConstants.DefaultUserPhoneMaxLength);
        }
    }
    
    public class CreateUserCommandHandler(IRepository<User> repository, UserManager userManager) : ICommandHandler<CreateUserCommand, Result<long>>
    {
        public async Task<Result<long>> Handle(CreateUserCommand request, CancellationToken cancellationToken)
        {
            var entity = new User(request.LoginId, request.Phone);
    
            repository.Add(entity);
    
            await userManager.SetCode(entity);
    
            await repository.SaveChangesAsync(cancellationToken);
    
            return Result.Success(entity.Id);
        }
    }
    

    上面列出Create命令的实现,我们分析一下:

    • CreateUserCommand继承ICommand,并且定义的传入参数是LoginId和Phone,返回结果为Result。这个类用了关键字record,说明它的属性在创建后就不可变了。用record定义的类特别适合做数据传输,而且优化了代码。
    • CreateUserCommandHandler继承ICommandHandler<,>,泛型参数是CreateUserCommand和Result,也就是说通过MediatR发出的CreateUserCommand命令,最终会被CreateUserCommandHandler执行。
    • CreateUserCommandHandler注入了IRepository和UserManager,实现具体的创建用户业务逻辑。
    • CreateUserCommandValidator类定义了CreateUserCommand参数的数据验证,这里LoginId不能为空,也不能超过默认长度。Phone也同样不能为空,同时定义最小和最大长度。这个数据模型的验证具体是怎么实现的呢?会在下一个主题中介绍。
  4. 实现用户查询:查询服务可以有很多,未来各种需要的查询逻辑都可以扩展。该项目默认实现GetById,GetList和Login的查询,下面是GetList查询的代码:

    [Authorize]
    public record GetUsersQuery : IQuery<Result<List<UserDto>>>;
    
    public class GetUsersQueryHandler(UserReadDbContext dbContext, ICacheService<List<UserDto>> cacheService)
        : IQueryHandler<GetUsersQuery, Result<List<UserDto>>>
    {
        public async Task<Result<List<UserDto>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
        {
            var list = await cacheService.GetOrSetByKeyAsync(async _ =>
            {
                var query = dbContext.Users.AsNoTracking()
                    .Where(m => m.IsDeleted == false)
                    .Select(m => new UserDto
                    {
                        Id = m.Id,
                        Name = m.Name,
                    });
                return await query.ToListAsync(cancellationToken);
            });
    
            return Result.Success(list!);
        }
    }
    

    我们在来分析查询的实现:

    • GetUsersQuery继承IQuery,没有输入参数,输出结果是Result<List>
    • GetUsersQueryHandler继承IQueryHandler<,>,同理它可以订阅通过MediatR发出的GetUsersQuery,实现具体查询方法。
    • 注入了ICacheService<List> 对象,用来实现缓存,缓存的实现在后面第二个主题再介绍。
    • GetUsersQuery还有一个[Authorize],很明显这是一个认证特性,这个也在下一个主题中介绍。

四、通用用例层封装

前面用户用例层的命令和查询实现中我们遇到了数据验证和认证特性,这些并不是核心业务,不需要在每个微服务中单独实现。于是我们需要封装一个通用的用例层,用来提供通用用例基础方法的支持。

  1. 在shared中创建DDM.DHT.UseCases.Common项目

  2. 创建Attributes文件夹和AuthorizeAttribute.cs类,定义一个特性

    [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
    public class AuthorizeAttribute : Attribute;
    
  3. 创建Behaviors文件夹,创建AuthorizationBehavior.cs类

    public class AuthorizationBehavior<TRequest, TResponse>(IUser user) : IPipelineBehavior<TRequest, TResponse>
        where TRequest : IRequest<TResponse>
    {
        public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
            CancellationToken cancellationToken)
        {
            var authorizeAttributes = request.GetType().GetCustomAttributes<AuthorizeAttribute>();
    
            if (authorizeAttributes.Any())
                if (user.Id is null)
                    throw new ForbiddenException();
    
            // 其它授权逻辑
    
            return await next();
        }
    }
    

    本实例只是判断了用户是否登录,如果需要做其他验证,可以编写具体的逻辑。

  4. 我们在创建ValidationBehavior.cs类

    public class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
        : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
    {
        public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next,
            CancellationToken cancellationToken)
        {
            if (validators.Any())
            {
                var context = new ValidationContext<TRequest>(request);
    
                var validationResults = await Task.WhenAll(
                    validators.Select(validator => validator
                        .ValidateAsync(context, cancellationToken)));
    
                var failures = validationResults
                    .Where(result => result.Errors.Count != 0)
                    .SelectMany(result => result.Errors)
                    .ToList();
    
                if (failures.Count != 0)
                    throw new ValidationException(failures);
            }
    
            return await next();
        }
    }
    

    ValidationBehavior类和AuthorizationBehavior类都是实现IPipelineBehavior接口的方法,也就是他们都是使用MediatR的管道来实现的,只要是通过MediatR发送的消息,都会被MediatR的管道拦截,从而添加新的逻辑。这种拦截器实现也就是通常上大家说的AOP。

  5. 创建DependencyInjection.cs,配置依赖注入。

    public static class DependencyInjection
    {
        public static IServiceCollection AddUseCaseCommon(this IServiceCollection services, Assembly assembly)
        {
            services.AddAutoMapper(assembly);
    
            services.AddValidatorsFromAssembly(assembly);
    
            services.AddMediatR(cfg =>
            {
                cfg.RegisterServicesFromAssembly(assembly);
                cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehavior<,>));
                cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
            });
    
            return services;
        }
    }
    

五、封装基础设施层Cache的支持

在用户服务GetList查询实现的时候,我们可以看到注入了一个通用的Cache泛型对象,本主题来谈谈通用Cache的封装。

  1. 在shared创建DDM.DHT.Infrastructure.Cache项目,引入nuget包:ZiggyCreatures.FusionCache、ZiggyCreatures.FusionCache.Serialization.SystemTextJson和Microsoft.Extensions.Caching.StackExchangeRedis

  2. 创建ICacheService.cs接口和CacheService.cs实现

    public interface ICacheService<TValue> where TValue : class
    {
        string Key { get; set; }
    
        ValueTask<TValue?> GetOrSetByIdAsync(int id, Func<CancellationToken, Task<TValue?>> factory);
    
        ValueTask<TValue?> GetOrSetByIdAsync(int fid, int sid, Func<CancellationToken, Task<TValue?>> factory);
    
        ValueTask<PagedList<TValue>?> GetOrSetListByPageAsync(int id, Pagination pagination,
            Func<CancellationToken, Task<PagedList<TValue>?>> factory);
    
        ValueTask<List<TValue>?> GetOrSetListAsync(Func<CancellationToken, Task<List<TValue>?>> factory);
        
        ValueTask<TValue?> GetOrSetByKeyAsync(Func<CancellationToken, Task<TValue?>> factory);
    }
    

    这个接口扩展了一些方法,除了这些扩展方法外ZiggyCreatures.FusionCache库本身封装的类也很好用,以后我们会用到。

  3. 创建DependencyInjection.cs,配置依赖注入

    public static class DependencyInjection
    {
        public static IServiceCollection AddCache(this IServiceCollection services, string? redisConn)
        {
            ArgumentNullException.ThrowIfNull(redisConn);
            
            services.AddStackExchangeRedisCache(options => options.Configuration = redisConn);
            services.AddFusionCache()
                .WithOptions(options =>
                {
                    options.DefaultEntryOptions = new FusionCacheEntryOptions { Duration = TimeSpan.FromSeconds(10) };
                })
                .WithSystemTextJsonSerializer()
                .WithDistributedCache(provider => provider.GetRequiredService<IDistributedCache>());
    
            services.AddSingleton(typeof(ICacheService<>), typeof(CacheService<>));
            return services;
        }
    }
    

六、用户服务HttpApi层

实现好用户服务用例后,我们到了最上一层,也就是用户服务的HttpApi实现。

  1. 新建一个WebApi项目DDM.DHT.UserService.HttpApi,并创建UserController.cs类

    public record CreateUserRequest(string Name, string Phone);
    
    public record UpdateUserRequest(string Name, string Email);
    
    public record ChangeUserPhoneRequest(string Phone);
    
    public record ChangeUserPwdRequest(string OldPassword, string NewPassword);
    
    [Tags("管理用户")]
    [Route("api/user")]
    public class UserController : ApiControllerBase
    {
        [EndpointSummary("创建用户")]
        [ProducesResponseType(StatusCodes.Status200OK)]
        [HttpPost]
        public async Task<IActionResult> Create(CreateUserRequest request)
        {
            var result = await Sender.Send(new CreateUserCommand(request.Name, request.Phone));
            return ReturnResult(result);
        }
    
        [EndpointSummary("删除用户")]
        [HttpDelete("{id:long}")]
        public async Task<IActionResult> Delete(long id)
        {
            var result = await Sender.Send(new DeleteUserCommand(id));
    
            return ReturnResult(result);
        }
    
        [EndpointSummary("更新指定用户")]
        [HttpPut("{id:long}")]
        public async Task<IActionResult> Update(long id, UpdateUserRequest request)
        {
            var result = await Sender.Send(new UpdateUserCommand(id, request.Name, request.Email));
    
            return ReturnResult(result);
        }
    
        [EndpointSummary("更新指定用户手机号")]
        [HttpPut("change-phone/{id:long}")]
        public async Task<IActionResult> ChangePhone(long id, ChangeUserPhoneRequest request)
        {
            var result = await Sender.Send(new ChangeUserPhoneCommand(id, request.Phone));
    
            return ReturnResult(result);
        }
    
        [EndpointSummary("更新指定用户密码")]
        [HttpPut("change-pwd/{id:long}")]
        public async Task<IActionResult> ChangePwd(long id, ChangeUserPwdRequest request)
        {
            var result = await Sender.Send(new ChangeUserPwdCommand(id, request.OldPassword, request.NewPassword));
    
            return ReturnResult(result);
        }
    
        [EndpointSummary("启用指定用户")]
        [HttpPut("enable/{id:long}")]
        public async Task<IActionResult> Enable(long id)
        {
            var result = await Sender.Send(new EnableUserCommand(id));
    
            return ReturnResult(result);
        }
    
        [EndpointSummary("禁用指定用户")]
        [HttpPut("disable/{id:long}")]
        public async Task<IActionResult> Disable(long id)
        {
            var result = await Sender.Send(new DisableUserCommand(id));
    
            return ReturnResult(result);
        }
    
        [EndpointSummary("获取指定用户")]
        [ProducesResponseType<UserDto>(StatusCodes.Status200OK)]
        [HttpGet("{id:int}")]
        public async Task<IActionResult> Get(long id)
        {
            var result = await Sender.Send(new GetUserByIdQuery(id));
    
            return ReturnResult(result);
        }
    
        [EndpointSummary("获取用户列表")]
        [ProducesResponseType<List<UserDto>>(StatusCodes.Status200OK)]
        [HttpGet("list")]
        public async Task<IActionResult> GetList([FromQuery] Pagination pagination)
        {
            var result = await Sender.Send(new GetUsersQuery());
    
            return ReturnResult(result);
        }
    
        [HttpPost("login")]
        public async Task<IActionResult> Get(LoginQuery query)
        {
            var result = await Sender.Send(query);
    
            return ReturnResult(result);
        }
    }
    

    Controller是一个很薄的一层,它基本上就是定义基于resfull规范,定义接口方法,参数和返回。

    我们也不建议在Controller写过多的代码。

  2. 修改Program.cs代码,改成如下:

    using DDM.DHT.HttpApi.Common;
    using DDM.DHT.UserService.UseCases;
    
    var builder = WebApplication.CreateBuilder(args);
    builder.AddServiceDefaults();
    
    builder.Configuration.AddDaprConfiguration();
    
    builder.Services
        .AddUseCaseService(builder.Configuration)
        .AddHttpApiCommon(builder.Configuration, "user-service");
    
    var app = builder.Build();
    
    app.UseHttpApiCommon();
    
    app.Run();
    
    • 这段代码中builder.AddServiceDefaults()是aspire的封装,以后再介绍。
    • AddUseCaseService是通用用例层配置的依赖注入
    • AddHttpApiCommon和UseHttpApiCommon是统一HttpApi层定义的方法,下一个主题我们来看通用HttpApi层的封装

七、通用HttpApi层封装

  1. 在shared文件夹创建DDM.DHT.HttpApi.Common项目

  2. 创建Services文件夹和CurrentUser.cs文件,首先实现IUser接口,提供当前登录用户的支持

    public class CurrentUser(IHttpContextAccessor httpContextAccessor) : IUser
    {
        public readonly ClaimsPrincipal? User = httpContextAccessor.HttpContext?.User;
    
        public string? UserName => User?.FindFirstValue(ClaimTypes.Name);
    
        public long? Id
        {
            get
            {
                var id = User?.FindFirstValue(ClaimTypes.NameIdentifier);
    
                if (id is null) return null;
    
                return Convert.ToInt32(id);
            }
        }
        
        public UserType UserType
        {
            get
            {
                var value = User?.FindFirstValue(nameof(UserType));
    
                return value is null ? UserType.User : Enum.Parse<UserType>(value);
            }
        }
    }
    
  3. 创建Infrastructure文件夹,再创建ApiControllerBase.cs,这个类继承默认的ControllerBase,用来扩展方法

    [Route("api/[controller]")]
    [ApiController]
    public abstract class ApiControllerBase : ControllerBase
    {
        protected ISender Sender => HttpContext.RequestServices.GetRequiredService<ISender>();
    
        [NonAction]
        protected IActionResult ReturnResult(IResult result)
        {
            switch (result.Status)
            {
                case ResultStatus.Ok:
                {
                    var value = result.GetValue();
                    return value is null ? NoContent() : Ok(value);
                }
                case ResultStatus.Error:
                    return result.Errors is null ? BadRequest() : BadRequest(new { errors = result.Errors });
    
                case ResultStatus.NotFound:
                    return result.Errors is null ? NotFound() : NotFound(new { errors = result.Errors });
    
                case ResultStatus.Invalid:
                    return result.Errors is null ? BadRequest() : BadRequest(new { errors = result.Errors });
    
                case ResultStatus.Forbidden:
                    return StatusCode(403);
    
                case ResultStatus.Unauthorized:
                    return Unauthorized();
    
                default:
                    return BadRequest(new { errors = result.Errors });
            }
        }
    }
    

    这里提供了ISender对象和不同状态的通用接口返回方法ReturnResult

  4. 创建UseCaseExceptionHandler.cs,统一处理的用例层异常

    public class UseCaseExceptionHandler : IExceptionHandler
    {
        private readonly Dictionary<Type, Func<HttpContext, Exception, Task>> _exceptionHandlers = new()
        {
            { typeof(ValidationException), HandleValidationException },
            { typeof(ForbiddenException), HandleForbiddenExceptionAsync }
        };
    
        public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception,
            CancellationToken cancellationToken)
        {
            var exceptionType = exception.GetType();
    
            if (!_exceptionHandlers.TryGetValue(exceptionType, out var handler)) return false;
    
            await handler.Invoke(httpContext, exception);
            return true;
        }
    
        private static async Task HandleValidationException(HttpContext httpContext, Exception exception)
        {
            var validationException = (ValidationException)exception;
    
            httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
    
            await httpContext.Response.WriteAsJsonAsync(new ValidationProblemDetails(validationException.Errors)
            {
                Status = StatusCodes.Status400BadRequest,
                Type = "https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/400"
            });
        }
    
        private static async Task HandleForbiddenExceptionAsync(HttpContext httpContext, Exception exception)
        {
            httpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
    
            await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
            {
                Status = StatusCodes.Status403Forbidden,
                Title = "Forbidden",
                Type = "https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/403"
            });
        }
    }
    
  5. 创建AppBuilderExtensions.cs类,配置中间件

    public static class AppBuilderExtensions
    {
        public static IApplicationBuilder UseHttpApiCommon(this WebApplication app)
        {
            if (app.Environment.IsDevelopment())
            {
                app.MapScalarApiReference(options =>
                {
                    options.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
                });
            }
    
            app.UseSerilogRequestLogging();
    
            app.UseMetricServer();
            app.UseHttpMetrics();
    
            app.UseCors("AllowAny");
    
            app.UseAuthentication();
    
            app.UseAuthorization();
    
            app.UseExceptionHandler(_ => { });
            
            app.UseCloudEvents();
            
            app.MapSubscribeHandler();
            
            app.MapOpenApi();
            
            app.MapControllers();
    
            return app;
        }
    }
    

    这些中间件如果有不清楚干什么了,以后用到了再介绍。

  6. 创建DependencyInjection.cs,配置依赖注入

    public static class DependencyInjection
    {
        public static IServiceCollection AddHttpApiCommon(this IServiceCollection services, IConfiguration configuration, string serviceName)
        {
            services.AddDaprClient(builder =>
            {
                builder.UseDaprApiToken(configuration["APP_API_TOKEN"]);
            });
            
            services.AddMultiAuth(configuration);
            
            services.AddOpenApi(options =>
            {
                options.AddDocumentTransformer<BearerSecuritySchemeTransformer>();
            });
    
            //services.AddHealthChecks();
    
            services.AddControllers();
    
            services.AddScoped<IUser, CurrentUser>();
    
            services.AddHttpContextAccessor();
    
            services.AddExceptionHandler<UseCaseExceptionHandler>();
    
            services.AddProblemDetails();
    
            services.AddSerilog(configuration, serviceName);
    
            ConfigureCors(services);
    
            return services;
        }
    
        public static void AddSerilog(this IServiceCollection services, IConfiguration configuration, string serviceName)
        {
            // 注册 Serilog 服务
            services.AddSerilog((sp, lc) =>
            {
                lc.Enrich.FromLogContext();
                lc.WriteTo.Console(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development"
                    ? LogEventLevel.Information
                    : LogEventLevel.Error);
                var lokiUri = configuration["LokiUri"];
                if (lokiUri is not null)
                {
                    lc.WriteTo.GrafanaLoki(
                        uri: lokiUri,
                        labels: new List<LokiLabel>
                        {
                            new() { Key = "App", Value = serviceName },
                            new() { Key = "Host", Value = configuration["Urls"] ?? string.Empty }
                        });
                }
    
                var esUri = configuration["EsUri"];
                if (esUri is not null)
                {
    
                    lc.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri(esUri))
                    {
                        IndexFormat = "Serilog-index-{0:yyyy.MM.dd}",
                        AutoRegisterTemplate = true,
                        AutoRegisterTemplateVersion = AutoRegisterTemplateVersion.ESv8
                    });
                    lc.Enrich.WithProperty("App", serviceName);
    
                    lc.Enrich.WithProperty("Host", configuration["Urls"] ?? string.Empty);
                }
            });
        }
    
        private static void ConfigureCors(IServiceCollection services)
        {
            services.AddCors(options =>
            {
                options.AddPolicy("AllowAny", builder =>
                {
                    builder.AllowAnyOrigin()
                        .AllowAnyMethod()
                        .AllowAnyHeader();
                });
            });
        }
    }
    

​ 这里也注入了很多配置,比如日志、跨域访问、OpenApi等,目前也先不用去关心细节,使用到的时候再具体说明。

八、实现EFCore数据迁移

以上基本已经完成用户微服务,同时也完成了基础的share层代码共享封装,要让用户微服务跑起来,我们还差一个步骤,那就是EFCore的数据迁移。

  1. 这里我们单独创建一个数据迁移项目,创建一个名为DDM.DHT.UserService.MigrationWorker的辅助角色服务项目,添加Microsoft.EntityFrameworkCore.Tools包,以及DDM.DHT.Infrastructure.EFCore和DDM.DHT.UserService.Infrastructure项目引用

  2. appsettings.json配置数据库连接字符串

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.Hosting.Lifetime": "Information"
        }
      },
      "ConnectionStrings": {
        "MasterDb": "Database=dht-user;Data Source=localhost;Port=3306;User Id=root;Password=123456;Charset=utf8;",
        "SlaveDb": "Database=dht-user;Data Source=localhost;Port=3306;User Id=root;Password=123456;Charset=utf8;"
      }
    }
    

    这里读库和写库的连接字符串指向的是同一个数据库。

  3. 创建Worker.cs

    public class Worker(IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider) : BackgroundService
    {
        public const string ActivitySourceName = "UserService Migrations";
        private static readonly ActivitySource ActivitySource = new(ActivitySourceName);
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            using var activity = ActivitySource.StartActivity("Migrating database", ActivityKind.Client);
    
            try
            {
                using var scope = serviceProvider.CreateScope();
                var dbContext = scope.ServiceProvider.GetRequiredService<UserDbContext>();
                await MigrationTool.RunMigrationAsync(dbContext, stoppingToken);
            }
            catch (Exception ex)
            {
                activity?.RecordException(ex);
                throw;
            }
    
            applicationLifetime.StopApplication();
        }
    }
    
  4. 修改Program.cs

    using DDM.DHT.UserService.Infrastructure.Data;
    using DDM.DHT.UserService.MigrationWorker;
    using Microsoft.EntityFrameworkCore;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    
    var builder = Host.CreateApplicationBuilder(args);
    builder.AddServiceDefaults();
    
    builder.Services.AddHostedService<Worker>();
    
    builder.Services.AddOpenTelemetry()
        .WithTracing(tracing => tracing.AddSource(Worker.ActivitySourceName));
    
    var connectionString = builder.Configuration.GetConnectionString("MasterDb");
    builder.Services.AddDbContext<UserDbContext>(options =>
    {
        options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString));
    });
    
    var host = builder.Build();
    host.Run();
    

    这里的代码无需关系细节,这里的代码也集成了Asprise,还有部分封装代码这里也不介绍了,只要知道它能完成mysql数据库的迁移即可。

    最后就是设置DDM.DHT.UserService.MigrationWorker为启动项目,用EFCore命令来生成mysql的迁移代码。

    这里说明一下,因为mysql每个版本生成的迁移代码不一样,所以要生成迁移代码必须要连接真实的mysql来生成,而Asprise创建的mysql组件是运行时生成的,所以迁移代码的时候不能用Asprise的mysql组件。

    我们这里只能本地创建一个mysql一样的版本来生成用于生成迁移代码,我们直接用docker来安装。

    docker run --name mysql9.2 -e MYSQL_ROOT_PASSWORD=123456 -p 3306:3306 -d mysql:9.2
    

    下面是迁移命令,因为我们的项目有mysql读写2个上下文对象,迁移的时候需要指定上下文类。命令如下:

     add-migration init -c UserDbContext
    

    到目前我们已经完成了第一个微服务,用户微服务。下一篇开始集成Aspire