3

开源.NET8.0小项目伪微服务框架(分布式、EFCore、Redis、RabbitMQ、Mysql等)

 6 months ago
source link: https://www.cnblogs.com/aehyok/p/18058032
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

开源.NET8.0小项目伪微服务框架(分布式、EFCore、Redis、RabbitMQ、Mysql等)

为什么说是伪微服务框架,常见微服务框架可能还包括服务容错、服务间的通信、服务追踪和监控、服务注册和发现等等,而我这里为了在使用中的更简单,将很多东西进行了简化或者省略了。

年前到现在在开发一个新的小项目,刚好项目最初的很多功能是比较通用的,所以就想着将这些功能抽离出来,然后做成一个通用的基础服务,然后其他项目可以直接引用这个基础服务,这样就可以减少很多重复的工作了。我在做的过程中也是参考了公司原有的一个项目,目标是尽量的简单,但是项目搞着搞着就越来越大了,所以我也是在不断的进行简化和优化。当然我的思考和架构能力还存在很大的问题,另外还由于时间比较仓促,很多东西还没有经过我的深思熟虑,而且现在项目还在初期的开发阶段,问题肯定是有很多的,这里也是希望自己通过整理出来,加深对项目的理解,也希望如果大家能够给我一点指导和建议那就更好了。
总之,后期会慢慢优化和完善这个项目,也会在这里记录下来。后端如果差不多了,就会进行前端项目的开发,然后再进行整合。

直接上github链接:https://github.com/aehyok/NET8.0

现阶段部署的一个单节点的服务:http://101.200.243.192:8080/docs/index.html

2、全文思维导航图

401119-20240307052226539-1442949925.png

其中列举了我觉得比较重点的一些知识点吧,当然其实还有很多知识点,可能我忽略掉了,后期有时间看到了还会加进来。

3、简单整体框架

401119-20240307052226484-940215682.png
  • Libraries
    里面包含了各种外部类库,对其深加工使用在项目中
    • EFCore
    • Excel
    • RabbitMQ
    • Redis
    • Serilog
    • Swagger
    • Skywalking(暂未接入)
  • Services/Basic
    微服务:基础支撑子系统
  • Services/NCDP
    微服务:业务子系统
  • Services/SystemService
    微服务:系统服务(包括数据库的更新、定时任务、数据初始化、Swagger承载、RabbitMQ队列事件处理器等)
  • sun.Core
401119-20240307052226528-2115182044.png

首先我将sun.Core作为了中转,其他外部或者自己封装的类库,在引用的时候都是在sun.Core中进行的引用,
算是间接引用,来简化项目中的依赖关系。同时在sun.Core也封装了一些核心组件和服务。

  • sun.Infrastructure
    其中主要封装一些通用的方法,以及基础设施组件,供外部使用。

4、已实现业务功能

401119-20240307052226452-1158865850.png

目前基本实现的功能有

  • 查看日志(登录日志和操作日志)
  • 基本的登录、登出、权限控制都已实现
  • 系统管理:其中包含很多包括方便开发运维的功能想到就做进去

5、依赖注入和控制反转

针对依赖注入和控制反转概念进行讲解的文章已经非常多了这里我就不进行说明了,找到一篇不错的讲解,有兴趣的可以看看 https://www.cnblogs.com/laozhang-is-phi/p/9541414.html

依赖注入主要有三种方式

  • 构造函数注入
  • 方法参数注入

通过属性方式注入容易和类的实例属性混淆,不建议使用。

通过方法参数注入有时候经常会与其他参数混合,当在原模块中添加新的依赖的时候,通常会带来一些麻烦。

这里通常建议使用构造函数注入的方式,而且在.NET8.0中新增加了主构造函数的语法糖,使声明构造函数的参数更加简洁

没有使用主构造函数的方式

    public class DictController : BasicControllerBase
    {
        private readonly IDictionaryGroupService dictionaryGroupService;
        private readonly IDictionaryItemService dictionaryItemService;

        public DictController(IDictionaryGroupService dictionaryGroupService, IDictionaryItemService dictionaryItemService)
        {
            this.dictionaryGroupService = dictionaryGroupService;
            this.dictionaryItemService = dictionaryItemService;
        }

使用主构造函数之后的方法,看上去代码就简洁了很多

    public class DictionaryController(
        IDictionaryGroupService dictionaryGroupService,
        IDictionaryItemService dictionaryItemService) : BasicControllerBase
    {
    
    }

6、双token实现登录,并实现无感刷新前端token

通过输入用户名和密码以及验证码之后,调用接口进行返回结果如下

image.png

expirationDate超时时间对应的是token的,而refreshToken的超时时间是在后端进行设置的通常要比token的超时时间要长的长

            var token = new UserToken()
            {
                ExpirationDate = DateTime.Now.AddHours(10),
                IpAddress = ipAddress.ToString(),
                PlatformType = platform,
                UserAgent = userAgent,
                UserId = user.Id,
                LoginType = LoginType.Login,
                RefreshTokenIsAvailable = true
            };

            token.Token = StringExtensions.GenerateToken(user.Id.ToString(), token.ExpirationDate);
            token.TokenHash = StringExtensions.EncodeMD5(token.Token);
            token.RefreshToken = StringExtensions.GenerateToken(token.Token, token.ExpirationDate.AddMonths(1));

我这里后端的代码token设置的有效时间为10个小时,而refreshToken设置的过期时间则为一个月

当前端请求接口时间超过10个小时之后,后端则会现在redis中进行查找

 await redisService.SetAsync(CoreRedisConstants.UserToken.Format(token.TokenHash), cacheData, TimeSpan.FromHours(10));

但是redis中已经设置了过期时间,在接口访问校验token时如果超过了设置的过期时间,则返回为空值。后端则直接报错给前端,此时前端便可以通过RefreshToken进行重新获取token。

通过前端进行调用

if (code === ResultEnum.NOT_LOGIN && !res.config.url?.includes("/basic/Token/Refresh")) {
    if (!isRefreshing) {
      isRefreshing = true;
      try {
        const { code, data } = await refreshTokenApi({
          userId: storage.get(UserEnum.ACCESS_TOKEN_INFO).userId,
          refreshToken: storage.get(UserEnum.ACCESS_TOKEN_INFO).refreshToken
        });
        if (code === ResultEnum.SUCCESS) {
          storage.set(UserEnum.ACCESS_TOKEN_INFO, data);
          res.config.headers.Authorization = `${data?.token}`;
          res.config.url = res.config.url?.replace("/api", "");

          // token 刷新后将数组的方法重新执行
          requests.forEach((cb) => cb(data?.token));
          requests = []; // 重新请求完清空
          // @ts-ignore
          return http.request(res.config, res.config.requestOptions);
        }
      } catch (err) {
        return Promise.reject(err);
      } finally {
        isRefreshing = false;
      }
    }

后端方法的实现则是通过RefreshToken进行确认身份,然后重新生成登录的token和refreshToken,以及重新设置token的过期时间,跟登录时的逻辑是一样的。

7、实现Authentication安全授权

首先在初始化应用程序的时候注册授权认证的中间件

builder.Services.AddAuthentication("Authorization-Token").AddScheme<RequestAuthenticationSchemeOptions, RequestAuthenticationHandler>("Authorization-Token", options => { });

然后来看一下我的RequestAuthenticationHandler具体实现如下

    /// <summary>
    /// 请求认证处理器(Token校验)
    /// </summary>
    public class RequestAuthenticationHandler(IOptionsMonitor<RequestAuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IUserTokenService userTokenService) : AuthenticationHandler<RequestAuthenticationSchemeOptions>(options, logger, encoder, clock)
    {
        protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            var token = Request.Headers.Authorization.ToString();

            if(!string.IsNullOrEmpty(token))
            {
                token = token.Trim();

                // 验证 Token 是否有效,并获取用户信息
                var userToken = await userTokenService.ValidateTokenAsync(token);
                if (userToken == null)
                {
                    return AuthenticateResult.Fail("Invalid Token!");
                }

                var claims = new List<Claim>
                {
                    new(DvsClaimTypes.RegionId, userToken.RegionId.ToString()),
                    new(DvsClaimTypes.UserId, userToken.UserId.ToString()),
                    new(DvsClaimTypes.Token, token),
                    new(DvsClaimTypes.RoleId, userToken.RoleId.ToString()),
                    new(DvsClaimTypes.PopulationId, userToken.PopulationId.ToString()),
                    new(ClaimTypes.NameIdentifier, userToken.UserId.ToString()),
                    new(DvsClaimTypes.TokenId, userToken.Id.ToString()),
                    new(DvsClaimTypes.PlatFormType, userToken.PlatformType.ToString()),
                };

                // 将当前用户的所有角色添加到 Claims 中
                userToken.Roles.ForEach(a =>
                {
                    claims.Add(new Claim(ClaimTypes.Role, a));
                });

                var claimsIdentity = new ClaimsIdentity(claims, nameof(RequestAuthenticationHandler));

                var ticket = new AuthenticationTicket(new ClaimsPrincipal(claimsIdentity), this.Scheme.Name);
                return AuthenticateResult.Success(ticket);
            }
            return AuthenticateResult.NoResult();
        }
    }

处理认证流程中的一个核心方法,这个方法返回 AuthenticateResult来标记是否认证成功以及返回认证过后的票据(AuthenticationTicket)。

这样后续便可以通过context.HttpContext.User.Identity.IsAuthenticated 来判断是否已经认证

 // 其他需要登录验证的,则通过AuthenticationHandler进行用户认证
 if (!context.HttpContext.User.Identity.IsAuthenticated)
 {
     context.Result = new RequestJsonResult(new RequestResultModel(StatusCodes.Status401Unauthorized, "请先登录", null));
     return;
 }

8、引入Swagger 生成REST APIs文档工具

最终的效果如下图所示

401119-20240307052226503-999087531.png
  • 包含可以承载多个微服务项目,通过右上角进行切换,便可以查看当前微服务项目的接口文档,并可以进行测试
  • 测试接口直接可在swagger ui上进行
  • 统一添加接口中的Header参数

通过对swagger ui进行部分的自定义,使的更好的适配自己的项目,比如添加登录,这样接口便直接可以在swagger ui上面进行。

401119-20240307052226546-945310096.png

同时通过配置文件的方式,添加多个微服务项目进行切换测试

401119-20240307052226530-1002085541.png

直接通过以下代码

        public void Apply(OpenApiOperation operation, OperationFilterContext context)
        {
            if (operation.Parameters == null)
                operation.Parameters = new List<OpenApiParameter>();

            operation.Parameters.Add(new OpenApiParameter
            {
                Name = "Menu-Code",
                Description = "当前操作的menuCode",
                In = ParameterLocation.Header,
                Required = false,
                Schema = new OpenApiSchema
                {
                    Type = "string"
                }
            });
        }

统一在Header中添加一个Menu-Code的参数

401119-20240307052226539-130433813.png

这里主要是为了写入操作日志时使用的,后面会专门提到。

9、初始化加载appsettings.json配置信息

开发环境,我的配置文件是单独放在src/etc下面的

401119-20240307052226544-1389300406.png

通过代码,这样一方面配置文件可以统一位置方便修改,以及编译的时候配置文件不在编译目录中,不用改来改去

            builder.ConfigureAppConfiguration((context, options) =>
            {
                // 正式环境配置文件路径
                options.AddJsonFile(Path.Combine(AppContext.BaseDirectory, $"../etc/appsettings.json"), true, true);
                options.AddJsonFile(Path.Combine(AppContext.BaseDirectory, $"../etc/{moduleKey}-appsettings.json"), true, true);

                // 本地开发环境配置文件路径
                options.AddJsonFile(Path.Combine(AppContext.BaseDirectory, $"../../../../../../etc/appsettings.json"), true, true);
                options.AddJsonFile(Path.Combine(AppContext.BaseDirectory, $"../../../../../../etc/{moduleKey}-appsettings.json"), true, true);
            });

10、引入Serilog实现过滤器IAsyncExceptionFilter进行记录错误日志,并部署docker进行可视化快速定位问题

401119-20240307052226514-1576663311.png

这个通过安装一个docker容器遍可以跑起来了,非常简单
安装地址为:https://docs.datalust.co/docs/getting-started-with-docker

安装成功后,访问地址,然后在上面配置一下api-key
https://docs.datalust.co/docs/api-keys

然后便可以在程序调用中进行配置

401119-20240307052226554-723772401.png

代码的位置

401119-20240307052226525-1563205069.png

其中还可以对日志封装一些特殊字段,方便查看日志,定位问题的字段。例如下面我封装了三个特殊字段

  • IpAddressEnricher 在日志中记录请求的 IP 地址
  • TokenEnricher 将TokenId写入日志
  • WorkerEnricher 将配置文件中的WorkId写入日志

然后遍可以在seq可视化平台进行查看定位问题

401119-20240307052226524-1978130034.png

实现IAsyncExceptionFilter接口,统一记录错误日志,以及统一返回前端错误

    /// <summary>
    /// 错误异常处理过滤器(控制器构造函数、执行Action接口方法、执行ResultFilter结果过滤器)
    /// </summary>
    public class ApiAsyncExceptionFilter : IAsyncExceptionFilter
    {
        private readonly ILogger<ApiAsyncExceptionFilter> logger;

        public ApiAsyncExceptionFilter(ILogger<ApiAsyncExceptionFilter> logger)
        {
            this.logger = logger;
        }

        public async Task OnExceptionAsync(ExceptionContext context)
        {
            var exception = context.Exception;

            //设置错误返回结果
            var resultModel = new RequestResultModel();
            if(exception is ErrorCodeException errorCodeException)
            {
                resultModel.Code = errorCodeException.ErrorCode;
            }
            else
            {
                resultModel.Code = (int)HttpStatusCode.InternalServerError;
            }

            resultModel.Message = exception.Message;

            // 读取配置文件中是否配置了显示堆栈信息
            if(App.Options<CommonOptions>().ShowStackTrace)
            {
                resultModel.Data = exception.StackTrace;
            }

            context.Result = new RequestJsonResult(resultModel);

            //用来指示错误异常已处理
            context.ExceptionHandled = true;

            //所有接口如果包含异常,都返回500
            context.HttpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

            var message = exception.Message;

            logger.LogError(exception, message);

            await Task.CompletedTask;
        }
    }

11、通过实现过滤器IAsyncActionFilter结合反射来记录操作日志,并通过请求头中的Menu-Code来辨别具体接口

直接看一下对过滤器IAsyncActionFilter的实现

/// <summary>
/// 操作日志记录过滤器
/// </summary>
public class OperationLogActionFilter(IOperationLogService operationLogService, IEventPublisher publisher, ICurrentUser currentUser) : IAsyncActionFilter
{
    /// <summary>
    /// 执行时机可通过代码中的的位置(await next();)来分辨
    /// </summary>
    /// <param name="context"></param>
    /// <exception cref="NotImplementedException"></exception>

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var actionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;

        if (context.HttpContext.Request.Headers.ContainsKey("Menu-Code") && !string.IsNullOrEmpty(context.HttpContext.Request.Headers["Menu-Code"]))
        {
            var menuCode = context.HttpContext.Request.Headers["Menu-Code"].ToString();
            if (actionDescriptor != null)
            {
                var json = JsonConvert.SerializeObject(context.ActionArguments);

                var logAttribute = actionDescriptor.MethodInfo.GetCustomAttribute<OperationLogActionAttribute>();
                string logMessage = null;
                if (logAttribute != null)
                {
                    logMessage = logAttribute.MessageTemplate;
                    if(logMessage is not null)
                    {
                        CreateOperationLogContent(json, ref logMessage);
                    } 
                }
                else
                {
                    // 获取 Action 注释
                    var commentsInfo = DocsHelper.GetMethodComments(actionDescriptor.ControllerTypeInfo.Assembly.GetName().Name, actionDescriptor.MethodInfo);
                    logMessage = commentsInfo;
                }
                // 待处理发布事件

                publisher.Publish(new OperationLogEventData()
                {
                    Code = menuCode,
                    Content = logMessage,
                    Json = json,
                    UserId = currentUser.UserId,
                    IpAddress = context.HttpContext.Request.GetRemoteIpAddress(),
                    UserAgent = context.HttpContext.Request.Headers.UserAgent
                }) ;
                //await operationLogService.LogAsync(menuCode, logMessage, json);
            }
        }
        await next();
    }

比较重要的便是这个Menu-Code,前端会在Header中进行传递,同时我上面也说了Swagger UI中也可以传递Menu-Code进行测试写入操作日志。

那么这个Menu-Code到底是哪里来的呢

这个MenuCode就是菜单的Code而已,每个菜单下的所有按钮也会保存在数据库中

401119-20240307052226775-1586748135.png

然后根据接口的action 先找有没有对action接口方法进行标记

401119-20240307052226582-1026232972.png

有进行标记,则将参数进行转换即可,如果没有标记,则通过反射进行读取action接口方法上的注释作为操作日志的内容,每个接口上我都会进行注释。

准备好操作内容之后,接下来就是写入数据库,这里操作日志可能会有很多很多,因为这里我的想法是尽可能多的写入操作日志,其实内容也没多少吧。但是可能写入是非常的频繁,于是这里引入了RabbitMQ的队列慢慢排队写入到数据库就可以了。

                    // 待处理发布事件

                    publisher.Publish(new OperationLogEventData()
                    {
                        Code = menuCode,
                        Content = logMessage,
                        Json = json,
                        UserId = currentUser.UserId,
                        IpAddress = context.HttpContext.Request.GetRemoteIpAddress(),
                        UserAgent = context.HttpContext.Request.Headers.UserAgent
                    }) ;

姑且有关RabbitMQ的内容我下面会继续记录,这里暂时就点到为止。

12、通过实现IAsyncAuthorizationFilter来验证用户身份,并判断接口访问的权限

先看一下对IAsyncAuthorizationFilter接口的实现

    /// <summary>
    /// 请求接口权限过滤器而AuthenticationHandler则是用户认证,token认证
    /// </summary>
    public class RequestAuthorizeFilter(IPermissionService permissionService) : IAsyncAuthorizationFilter
    {
        public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
        {
            // 接口标记了[AllowAnonymous],则不需要进行权限验证
            if (context.ActionDescriptor.EndpointMetadata.Any(a => a.GetType() == typeof(AllowAnonymousAttribute)))
            {
                return;
            }

            // 其他需要登录验证的,则通过AuthenticationHandler进行用户认证
            if (!context.HttpContext.User.Identity.IsAuthenticated)
            {
                context.Result = new RequestJsonResult(new RequestResultModel(StatusCodes.Status401Unauthorized, "请先登录", null));
                return;
            }

            if (context.ActionDescriptor is not null && context.ActionDescriptor is ControllerActionDescriptor descriptor)
            {
                var namespaceStr = descriptor.ControllerTypeInfo.Namespace;
                var controllerName = descriptor.ControllerName;
                var actionName = descriptor.ActionName;

                var code = $"{namespaceStr}.{controllerName}.{actionName}";

                var menuCode = string.Empty;
                if (context.HttpContext.Request.Headers.ContainsKey("Menu-Code") && !string.IsNullOrEmpty(context.HttpContext.Request.Headers["Menu-Code"]))
                {
                    menuCode = context.HttpContext.Request.Headers["Menu-Code"].ToString();
                }

                // 通过menuCode找到菜单Id,通过code找到接口Id
                var hasPermission = false;

                //有些操作是不在菜单下面的,则默认有访问接口的权限
                if (string.IsNullOrEmpty(menuCode))
                {
                    hasPermission = true;
                }

                hasPermission = await permissionService.JudgeHasPermissionAsync(code, menuCode);
                if (hasPermission)
                {
                    return;
                }

                context.Result = new RequestJsonResult(new RequestResultModel(StatusCodes.Status403Forbidden, "暂无权限", null));
                await Task.CompletedTask;
            }
        }
    }   

通过最上面的代码可以看到如果接口上标注了[AllowAnonymous] 则访问接口不需要进行校验token。例如下面这个接口

        /// <summary>
        /// 使用 Refresh Token 获取新的 Token
        /// </summary>
        /// <param name="model"></param>
        /// <returns></returns>
        [HttpPost("Refresh")]
        [AllowAnonymous]
        public Task<UserTokenDto> RefreshAsync(RefreshTokenDto model)
        {
            return userTokenService.RefreshTokenAsync(model.UserId, model.RefreshToken);
        }

下面则进行判断token是否已经校验。然后再根据接口的命名空间名称、控制器名称、接口名称的拼接 来判断当前操作是否有勾选对应的接口(当前操作则是通过传递的Menu-Code进行的)。

401119-20240307052226539-1352255783.png

目前设计是一个操作对应一个接口,也就是只勾选一个接口即可。这里其实勾选多个接口应该也没什么问题。操作日志相当于一个Menu-Code下有两个访问接口的日志而已。

同时,这里的接口列表也是通过反射进行完成映射并写入数据库的。这个在初始化在后面会详细说明。

13、通过实现IAsyncResultFilter来统一返回前端数据

直接来看代码实现

/// <summary>
/// 异步请求结果过滤器
/// </summary>
public class RequestAsyncResultFilter : IAsyncResultFilter
{
    /// <summary>
    /// 在返回结果之前调用,用于统一返回数据格式
    /// </summary>
    /// <param name="context"></param>
    /// <param name="next"></param>
    /// <returns></returns>
    /// <exception cref="NotImplementedException"></exception>
    public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
    {
        if (Activity.Current is not null)
        {
            context.HttpContext.Response.Headers.Append("X-TraceId", Activity.Current?.TraceId.ToString());
        }

        if(context.Result is BadRequestObjectResult badRequestObjectResult)
        {
            var resultModel = new RequestResultModel
            {
                Code = badRequestObjectResult.StatusCode ?? StatusCodes.Status400BadRequest,
                Message = "请求参数验证错误",
                Data = badRequestObjectResult.Value
            };

            context.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
            context.Result = new RequestJsonResult(resultModel);
        }
        // 比如直接return Ok();
        else if(context.Result is StatusCodeResult statusCodeResult)
        {
            var resultModel = new RequestResultModel
            {
                Code = statusCodeResult.StatusCode,
                Message = statusCodeResult.StatusCode == 200 ? "Success" : "请求发生错误",
                Data = statusCodeResult.StatusCode == 200
            };

            context.Result = new RequestJsonResult(resultModel);
        }
        else if(context.Result is ObjectResult result)
        {
            if(result.Value is null)
            {
                var resultModel = new RequestResultModel
                {
                    Code = result.StatusCode ?? context.HttpContext.Response.StatusCode,
                    Message = "未请求到数据"
                };
                context.Result = new RequestJsonResult(resultModel);
            }
            else if(result.Value is not RequestJsonResult)
            {
                if (result.Value is IPagedList pagedList)
                {
                    var resultModel = new RequestPagedResultModel
                    {
                        Message = "Success",
                        Data = result.Value,
                        Total = pagedList.TotalItemCount,
                        Page = pagedList.PageNumber,
                        TotalPage = pagedList.PageCount,
                        Limit = pagedList.PageSize,
                        Code = result.StatusCode ?? context.HttpContext.Response.StatusCode
                    };

                    context.Result = new RequestJsonResult(resultModel);
                }
                else
                {
                    var resultModel = new RequestResultModel
                    {
                        Code = result.StatusCode ?? context.HttpContext.Response.StatusCode,
                        Message = "Success",
                        Data = result.Value
                    };

                    context.Result = new RequestJsonResult(resultModel);
                }
            }
        }

        await next();
    }
}

主要就是三种情况

  • 请求参数验证错误的返回提示
  • 正常返回例如详情的结果数据
  • 单独针对分页数据的返回

这样前端也可以更好的根据情况进行封装统一,便于维护的代码

14、初始化EFCore,并实现Repository仓储模式

401119-20240307052226582-1689256912.png

这部分包含的代码和知识点还是比较多的,这里暂时通过一个截图来看看。

  • DvsContext 中则是简单封装了基础的数据库上下文
  • Entities 业务实体基类和基础接口
  • Mapping 实现针对每个业务实体的映射基类,方便针对属性字段进行定制化的设置
  • Repository 仓储模式
    • AutoMapper自动化映射的封装
    • Base DbContext基础操作的封装 新增 修改 删除 事物等
    • Query 主要是查询的封装 以及对查询分页的封装
  • DvsSaveChangeInterceptor 针对通用查询、新增、修改的统一封装逻辑处理

15、引入Snowflake,实现分布式雪花Id生成器

所使用的开源类库:https://github.com/stulzq/snowflake-net

    /// <summary>
    /// 分布式雪花Id生成器
    /// </summary>
    public class SnowFlake
    {
        /// <summary>
        /// 通过静态类只实例化一次IdWorker 否则生成的Id会有重复
        /// </summary>
        private static readonly Lazy<IdWorker> _instance = new(() =>
        {
            var commonOptions = App.Options<CommonOptions>();

            return new IdWorker(commonOptions.WorkerId, commonOptions.DatacenterId);
        });

        public static IdWorker Instance = _instance.Value;
    }

其中 WorkerId和DatacenterId保持不同的话,例如两个微服务WorkerId一个为1一个为2,那么在同一毫秒数生成的Id肯定是不同的。

同一个IdWorker在一个毫秒中可以生成4096个序列号 足够大型系统使用了,不怕重复的问题

16、引入Redis统一封装实现分布式缓存和分布式锁

所使用的开源类库:https://github.com/2881099/csredis

目前主要封装了几个常用的接口方法

    public interface IRedisService
    {
        /// <summary>
        /// 查看服务是否运行
        /// </summary>
        /// <returns></returns>
        bool PingAsync();

        /// <summary>
        /// 根据key获取缓存
        /// </summary>
        /// <param name="key"></param>
        /// <returns></returns>
        Task<T> GetAsync<T>(string key);



        /// <summary>
        /// 设置指定key的缓存值(不过期)
        /// </summary>
        /// <param name="key"></param>
        /// <param name="value"></param>
        /// <returns></returns>
        Task<bool> SetAsync(string key, object value);

        /// <summary>
        /// 设置指定key的缓存值(可设置过期时间和Nx、Xx)
        /// </summary>
        /// <param name="key"></param>
        /// <param name="value"></param>
        /// <param name="expire"></param>
        /// <param name="exists"></param>
        /// <returns></returns>
        Task<bool> SetAsync(string key, object value, TimeSpan expire, RedisExistence? exists = null);

        /// <summary>
        /// 设置指定key的缓存值(可设置过期秒数和Nx、Xx)
        /// </summary>
        /// <param name="key"></param>
        /// <param name="value"></param>
        /// <param name="expireSeconds">过期时间单位为秒</param>
        /// <param name="exists"></param>
        /// <returns></returns>
        Task<bool> SetAsync(string key, object value, int expireSeconds = -1, RedisExistence? exists = null);

        /// <summary>
        /// 删除Key
        /// </summary>
        /// <param name="key"></param>
        /// <returns></returns>
        Task<long> DeleteAsync(string key);


        Task<Dictionary<string,string>> ScanAsync();
    }

主要是为了保持与redis cli中的方法一致,选了这个类库,当然你也可以选择其他的类库 还是蛮多的。
同时还封装了一个接口用于前端监测所有的key和value。

        public async Task<dynamic> ScanAsync(PagedQueryModelBase model)
        {
            List<string> list = new List<string>();

            //根沐model.Keyword进行模糊匹配
            var scanResult = await RedisHelper.ScanAsync(model.Page, $"*{model.Keyword}*", model.Limit);
            list.AddRange(scanResult.Items);

            var values = await RedisHelper.MGetAsync(list.ToArray());

            var resultDictionary = list.Zip(values, (key, value) => new { key, value })
                                            .ToDictionary(item => item.key, item => item.value);
            dynamic result = new ExpandoObject();
            result.Items = resultDictionary;
            result.Cursor = scanResult.Cursor;  // 下一次要通过这个Cursor获取下一页的keys
           return result;
        }

https://www.redis.net.cn/order/3552.html

17、引入RabbitMQ统一封装实现异步任务,例如上传和下载文件等

暂时只使用了direct模式,根据routingKey和exchange决定的那个唯一的queue可以接收消息。

401119-20240307052226699-834311492.png

我这里封装了一个统一的消息队列处理器,具体的订阅逻辑都在EventSubscriber。
调用的时候参考如下代码

401119-20240307052226776-329579865.png

定义好要传输的消息实体,发布消息,然后RabbitMQ通用方法收到消息后会进行处理,然后交给指定的处理器

401119-20240307052226537-351935029.png

直接实现IEventHandler,这个T便是AsyncTaskEventData,根据需要进行定义就好了。

// 发布任务
publisher.Publish(new AsyncTaskEventData(task));    

这里其实可以通过RabbitMQ后台管理查看,这里我的Queues队列名中直接也包含了对应的事件处理器,方便查看。
这里我也可以将事件处理器批量写入到数据库,再写个接口,方便在系统中直接查看,后面有时间了加进去。

401119-20240307052226608-97110641.png

18、引入Cronos并结合自带BackgroundService后台任务实现秒级定时任务处理

所使用的开源类库:https://github.com/HangfireIO/Cronos
表达式具体的使用规则可以直接打开上面的链接进行学习查看,也可以查看在线的表达式进行对比查看https://cron.qqe2.com/ 。

使用.net内置 BackgroundService后台异步执行任务程序运行后,定时任务便会一直运行着,封装统一处理定时任务基类CronScheduleService,会在sun.SystemService系统服务开启后将服务本身同步到Mysql和Redis(ScheduleTask)

401119-20240307052226543-1736541020.png

会对定时任务的执行过程进行记录,记录到数据库中(ScheduleTaskRecord) 记录开始执行时间,结束执行时间,执行是否成功,以及表达式的转换时间等。

来看一个定时任务的例子

    /// <summary>
    /// 测试调查问卷的功能
    /// </summary>
    public class QuestionSchedule2(IServiceScopeFactory serviceFactory) : CronScheduleService(serviceFactory)
    {
        protected override string Expression { get; set; } = "0/2 * * * * ?";

        protected override bool Singleton => true;

        protected override Task ProcessAsync(CancellationToken cancellationToken)
        {
            Console.WriteLine("实现调查问卷的功能");
            return Task.CompletedTask;
        }
    }

相当于只需实现ProcessAsync 定时任务中的业务逻辑,然后指定Expression 该什么时候执行即可。

401119-20240307052226618-1574865859.png

后面搞前端的时候顺便加上定时任务的是否启用,以及可以在线修改表达式,也就是修改定时任务的执行时间。

19、通过BackgroundService实现数据的初始化服务,例如字典数据等

上面是通用的定时任务执行。这里主要就是根据BackgroundService来初始化或更新一些数据,例如 字典项、初始化区域、初始化角色等等

401119-20240307052226533-379121283.png

这是一个通用的初始化数据的执行器,然后可以单独进行实现每个想要初始化的数据执行器

401119-20240307052226592-1561013729.png

可以对执行进行设置顺序,因为有些数据是有依赖的。

这里可以看到上面的定时任务列表,我就是通过这里实现的初始化数据

401119-20240307052226532-929543120.png

其中里面用到了反射来读取类的信息。

20、通过BackgroundService和反射实现所有接口的写入数据库

401119-20240307052226607-1390007286.png

程序中所有的接口列表,我也是在这里进行单独初始化的,通过类似反射来读取项目中的所有接口,来初始化到数据库中,然后在程序中进行使用的。

21、引入EPPlus实现Excel的导入和导出

所使用的开源类库:https://github.com/EPPlusSoftware/EPPlus

401119-20240307052226533-25488047.png

统一封装关于Excel导入导出中的通用方法。

22、goploy一键部署前后端项目

所使用的开源类库:https://github.com/zhenorzz/goploy
部署其实也非常简单的,能通过脚本使用的,便可以在工具上进行设置,然后点一下就可以进行一键部署,当然了还需要服务器的支持了。

同时我也将.net8的后端部署为本地宿主的服务也是没问题的

401119-20240307052226790-1258663249.png

这是部署后进行查看服务状态的,通过一个命令便可以查看三个服务的状态

systemctl status sun-*,同样也可以一起重启和关闭服务

401119-20240307052226552-364426143.png
401119-20240307052226632-737432613.png

23、我还通过google/zx使用nodejs开发了一个脚本,用于自动化部署

可以参考我的github的地址:https://github.com/aehyok/zx-deploy

主要是用于开发环境,通过

pnpm sun-baisc
pnpm sun-ncdp
pnpm sun-systemserivce

当然你还可以通过组合命令进行部署,例如想一起部署三个服务

pnpm sun-all 其实就是  "pnpm sun-ncdp && pnpm sun-basic && pnpm sun-systemservice"

这里我用的&&相当于上面三个命令串行执行,先执行sun-ncdp,再执行sun-basic,最后执行sun-systemservice。如果你的电脑或者服务器性能足够好,可以使用&符号,这样就是并行执行,三个服务同时启动,这样可以节省时间。

24、docker一键部署后端项目

401119-20240307052226529-1019285695.png

写了个脚本和Dockerfile文件,可单独更新某个服务,也可以三个服务一起更新。

401119-20240307052226615-1920389413.png

同样我现在开发使用的Mysql、Redis、RabbitMQ、Seq、等等也可以通过docker进行运行,很湿方便啊。

401119-20240307052226543-949787550.png

25、总结

经过这段时间的项目实践,也学到了非常多的知识,同时也发现了一些自身的问题。同时也发现现有项目中方方面面如果再有一个月的时间,很多代码可以做一波新的优化和重写。后面有时间我还会整理一套简易的微前端框架,同时要将后端的大部分接口进行实现, pnpm + vue3 + vite5 + wujie 微前端。

项目中的一些问题:

  • 针对复杂业务的处理 EFCore事物的处理
  • RabbitMQ 更深入的使用
  • 微服务框架的有些地方设计的不够合理吧
  • 缓存中到底要存储那些数据还可以进行调整
  • EFCore中的批量操作还可以进行优化调整
  • Linq多表查询还可以进一步的学习使用
  • Excel导入和导出还可以进一步的通用化
  • 考虑处理sso单点登录和多端登录的问题
  • zabbix监控还可以进一步的学习使用
  • opentelemetry 可考虑接入
  • agileconfig分布式配置中心和服务治理
  • https://github.com/hashicorp/consul 当然服务治理也可以考虑使用

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK