當前位置: 妍妍網 > 碼農

.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技能

    點贊和在看就是最大的支持❤️