開源.NET8.0小專案偽微服務框架(分散式、EFCore、Redis、RabbitMQ、Mysql等)

aehyok發表於2024-03-07

1、前言

為什麼說是偽微服務框架,常見微服務框架可能還包括服務容錯、服務間的通訊、服務追蹤和監控、服務註冊和發現等等,而我這裡為了在使用中的更簡單,將很多東西進行了簡化或者省略了。

年前到現在在開發一個新的小專案,剛好專案最初的很多功能是比較通用的,所以就想著將這些功能抽離出來,然後做成一個通用的基礎服務,然後其他專案可以直接引用這個基礎服務,這樣就可以減少很多重複的工作了。我在做的過程中也是參考了公司原有的一個專案,目標是儘量的簡單,但是專案搞著搞著就越來越大了,所以我也是在不斷的進行簡化和最佳化。當然我的思考和架構能力還存在很大的問題,另外還由於時間比較倉促,很多東西還沒有經過我的深思熟慮,而且現在專案還在初期的開發階段,問題肯定是有很多的,這裡也是希望自己透過整理出來,加深對專案的理解,也希望如果大家能夠給我一點指導和建議那就更好了。
總之,後期會慢慢最佳化和完善這個專案,也會在這裡記錄下來。後端如果差不多了,就會進行前端專案的開發,然後再進行整合。

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

現階段部署的一個單節點的服務:http://101.200.243.192:8080/docs/index.html

2、全文思維導航圖

其中列舉了我覺得比較重點的一些知識點吧,當然其實還有很多知識點,可能我忽略掉了,後期有時間看到了還會加進來。

3、簡單整體框架

  • Libraries
    裡面包含了各種外部類庫,對其深加工使用在專案中
    • EFCore
    • Excel
    • RabbitMQ
    • Redis
    • Serilog
    • Swagger
    • Skywalking(暫未接入)
  • Services/Basic
    微服務:基礎支撐子系統
  • Services/NCDP
    微服務:業務子系統
  • Services/SystemService
    微服務:系統服務(包括資料庫的更新、定時任務、資料初始化、Swagger承載、RabbitMQ佇列事件處理器等)
  • sun.Core

首先我將sun.Core作為了中轉,其他外部或者自己封裝的類庫,在引用的時候都是在sun.Core中進行的引用,
算是間接引用,來簡化專案中的依賴關係。同時在sun.Core也封裝了一些核心元件和服務。

  • sun.Infrastructure
    其中主要封裝一些通用的方法,以及基礎設施元件,供外部使用。

4、已實現業務功能

目前基本實現的功能有

  • 使用者管理
  • 角色管理
  • 區域管理
  • 檢視日誌(登入日誌和操作日誌)
  • 選單管理
  • 基本的登入、登出、許可權控制都已實現
  • 系統管理:其中包含很多包括方便開發運維的功能想到就做進去

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文件工具

最終的效果如下圖所示

  • 包含可以承載多個微服務專案,透過右上角進行切換,便可以檢視當前微服務專案的介面文件,並可以進行測試
  • 測試介面直接可在swagger ui上進行
  • 統一新增介面中的Header引數

透過對swagger ui進行部分的自定義,使的更好的適配自己的專案,比如新增登入,這樣介面便直接可以在swagger ui上面進行。

同時透過配置檔案的方式,新增多個微服務專案進行切換測試

直接透過以下程式碼

        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的引數

這裡主要是為了寫入操作日誌時使用的,後面會專門提到。

9、初始化載入appsettings.json配置資訊

開發環境,我的配置檔案是單獨放在src/etc下面的

透過程式碼,這樣一方面配置檔案可以統一位置方便修改,以及編譯的時候配置檔案不在編譯目錄中,不用改來改去

            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進行視覺化快速定位問題

這個透過安裝一個docker容器遍可以跑起來了,非常簡單
安裝地址為:https://docs.datalust.co/docs/getting-started-with-docker

安裝成功後,訪問地址,然後在上面配置一下api-key
https://docs.datalust.co/docs/api-keys

然後便可以在程式呼叫中進行配置

程式碼的位置

其中還可以對日誌封裝一些特殊欄位,方便檢視日誌,定位問題的欄位。例如下面我封裝了三個特殊欄位

  • IpAddressEnricher 在日誌中記錄請求的 IP 地址
  • TokenEnricher 將TokenId寫入日誌
  • WorkerEnricher 將配置檔案中的WorkId寫入日誌

然後遍可以在seq視覺化平臺進行檢視定位問題

實現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而已,每個選單下的所有按鈕也會儲存在資料庫中

然後根據介面的action 先找有沒有對action介面方法進行標記

有進行標記,則將引數進行轉換即可,如果沒有標記,則透過反射進行讀取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進行的)。

目前設計是一個操作對應一個介面,也就是隻勾選一個介面即可。這裡其實勾選多個介面應該也沒什麼問題。操作日誌相當於一個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倉儲模式

這部分包含的程式碼和知識點還是比較多的,這裡暫時透過一個截圖來看看。

  • 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可以接收訊息。

我這裡封裝了一個統一的訊息佇列處理器,具體的訂閱邏輯都在EventSubscriber。
呼叫的時候參考如下程式碼

定義好要傳輸的訊息實體,釋出訊息,然後RabbitMQ通用方法收到訊息後會進行處理,然後交給指定的處理器

直接實現IEventHandler,這個T便是AsyncTaskEventData,根據需要進行定義就好了。

// 釋出任務
publisher.Publish(new AsyncTaskEventData(task));    

這裡其實可以透過RabbitMQ後臺管理檢視,這裡我的Queues佇列名中直接也包含了對應的事件處理器,方便檢視。
這裡我也可以將事件處理器批次寫入到資料庫,再寫個介面,方便在系統中直接檢視,後面有時間了加進去。

18、引入Cronos並結合自帶BackgroundService後臺任務實現秒級定時任務處理

所使用的開源類庫:https://github.com/HangfireIO/Cronos
表示式具體的使用規則可以直接開啟上面的連結進行學習檢視,也可以檢視線上的表示式進行對比檢視https://cron.qqe2.com/ 。

使用.net內建 BackgroundService後臺非同步執行任務程式執行後,定時任務便會一直執行著,封裝統一處理定時任務基類CronScheduleService,會在sun.SystemService系統服務開啟後將服務本身同步到Mysql和Redis(ScheduleTask)

會對定時任務的執行過程進行記錄,記錄到資料庫中(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 該什麼時候執行即可。

後面搞前端的時候順便加上定時任務的是否啟用,以及可以線上修改表示式,也就是修改定時任務的執行時間。

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-*,同樣也可以一起重啟和關閉服務

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一鍵部署後端專案

寫了個指令碼和Dockerfile檔案,可單獨更新某個服務,也可以三個服務一起更新。

同樣我現在開發使用的Mysql、Redis、RabbitMQ、Seq、等等也可以透過docker進行執行,很溼方便啊。

25、總結

經過這段時間的專案實踐,也學到了非常多的知識,同時也發現了一些自身的問題。同時也發現現有專案中方方面面如果再有一個月的時間,很多程式碼可以做一波新的最佳化和重寫。後面有時間我還會整理一套簡易的微前端框架,同時要將後端的大部分介面進行實現, pnpm + vue3 + vite5 + wujie 微前端。

專案中的一些問題:

  • 針對複雜業務的處理 EFCore事物的處理
  • RabbitMQ 更深入的使用
  • 微服務框架的有些地方設計的不夠合理吧
  • 快取中到底要儲存那些資料還可以進行調整
  • EFCore中的批次操作還可以進行最佳化調整
  • Linq多表查詢還可以進一步的學習使用
  • Excel匯入和匯出還可以進一步的通用化
  • 考慮處理sso單點登入和多端登入的問題
  • zabbix監控還可以進一步的學習使用
  • opentelemetry 可考慮接入
  • agileconfig分散式配置中心和服務治理
  • https://github.com/hashicorp/consul 當然服務治理也可以考慮使用

相關文章