当前位置: 欣欣网 > 码农

.NET 8.0 开源项目伪微服务框架

2024-03-08码农

1、前言

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

年前到现在在开发一个新的小项目,刚好项目最初的很多功能是比较通用的,所以就想着将这些功能抽离出来,然后做成一个通用的基础服务,然后其他项目可以直接引用这个基础服务,这样就可以减少很多重复的工作了。

我在做的过程中也是参考了公司原有的一个项目,目标是尽量的简单,但是项目搞着搞着就越来越大了,所以我也是在不断的进行简化和优化。

当然我的思考和架构能力还存在很大的问题,另外还由于时间比较仓促,很多东西还没有经过我的深思熟虑,而且现在项目还在初期的开发阶段,问题肯定是有很多的,这里也是希望自己通过整理出来,加深对项目的理解,也希望如果大家能够给我一点指导和建议那就更好了。

总之,后期会慢慢优化和完善这个项目,也会在这里记录下来。后端如果差不多了,就会进行前端项目的开发,然后再进行整合。

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

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

2、全文思维导航图

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

3、简单整体框架

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

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

  • 4、已实现业务功能

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

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

    public classDictController : BasicControllerBase
    {
    privatereadonly IDictionaryGroupService dictionaryGroupService;
    privatereadonly IDictionaryItemService dictionaryItemService;
    publicDictController(IDictionaryGroupService dictionaryGroupService, IDictionaryItemService dictionaryItemService)
    {
    this.dictionaryGroupService = dictionaryGroupService;
    this.dictionaryItemService = dictionaryItemService;
    }

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

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

    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));

    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具体实现如下

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

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

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

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

    直接通过以下代码

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

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

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

  • IpAddressEnricher 在日志中记录请求的 IP 地址

  • TokenEnricher 将TokenId写入日志

  • WorkerEnricher 将配置文件中的WorkId写入日志

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

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

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

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

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

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

    先看一下对IAsyncAuthorizationFilter接口的实现

    ///<summary>
    /// 请求接口权限过滤器而AuthenticationHandler则是用户认证,token认证
    ///</summary>
    public class RequestAuthorizeFilter(IPermissionService permissionService) : IAsyncAuthorizationFilter
    {
    publicasync 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;
    }
    }
    }






    }
    // 比如直接return Ok();
    elseif(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);
    }
    elseif(context.Result is ObjectResult result)
    {
    if(result.Value isnull)
    {
    var resultModel = new RequestResultModel
    {
    Code = result.StatusCode ?? context.HttpContext.Response.StatusCode,
    Message = "未请求到数据"
    };
    context.Result = new RequestJsonResult(resultModel);
    }
    elseif(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仓储模式

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    24、docker一键部署后端项目

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

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

    25、总结

    经过这段时间的项目实践,也学到了非常多的知识,同时也发现了一些自身的问题。同时也发现现有项目中方方面面如果再有一个月的时间,很多代码可以做一波新的优化和重写。

    后面有时间我还会整理一套简易的微前端框架,同时要将后端的大部分接口进行实现, pnpm + vue3 + vite5 + wujie 微前端。

    转自:aehyok

    链接:cnblogs.com/aehyok/p/18058032

    - EOF -

    推荐阅读 点击标题可跳转

    看完本文有收获?请转发分享给更多人

    推荐关注「DotNet」,提升.Net技能

    点赞和在看就是最大的支持❤️