經過大概三個月的學習和沉澱,我將.NET8.0的學習和使用,整理成了一個簡單的微服務項目,目前還在初級階段,後續會進行持續的更新和優化。 ...
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
通過輸入用戶名和密碼以及驗證碼之後,調用介面進行返回結果如下
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
// 發佈任務
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 當然服務治理也可以考慮使用