HangFire多集群切換及DashBoard登錄驗證
- 2020 年 7 月 13 日
- 筆記
項目中是有多個集群的,現在存在一個是:在切換web集群時,如何切換HangFire的周期性任務。
先採取的解決辦法是:
- 每個集群分一個隊列,在周期性任務入隊時分配當前web集群的集群id單做隊列名稱。
- 之前已存在的周期性任務,在其入隊時修正到正確的集群執行
通過BackgroundJobServerOptions配置,只監聽當前web集群的隊列(ps:可參考文檔://docs.hangfire.io/en/latest/background-processing/configuring-queues.html)
1 //只監聽當前集群的隊列 2 var options = new BackgroundJobServerOptions() 3 { 4 Queues = new[] { GlobalConfigSection.Current.WebClusterId } 5 }; 6 HangfireAspNet.Use(() => new[] { new BackgroundJobServer(options) });
通過實現IElectStateFilter,重寫OnStateElection方法,在任務入隊前修正其入當前集群隊列執行。
配置使用自定義屬性
GlobalJobFilters.Filters.Add(new CustomJobFilterAttribute());
重寫OnStateElection方法,通過修改enqueuedState的queue熟悉修正隊列
1 /// <summary> 2 /// HangFire Filter 3 /// </summary> 4 public class CustomJobFilterAttribute : JobFilterAttribute, IElectStateFilter 5 { 6 public void OnStateElection(ElectStateContext context) 7 { 8 if (context.CandidateState is EnqueuedState enqueuedState) 9 { 10 var tenantConfigProvider = ObjectContainer.GetService<ITenantConfigProvider>(); 11 var contextMessage = context.GetJobParameter<ContextMessage>("_ld_contextMessage"); 12 var webClusterId = tenantConfigProvider.GetWebClusterIdByTenant(contextMessage.TenantId); 13 if (enqueuedState.Queue != webClusterId)//修正隊列 14 { 15 enqueuedState.Queue = webClusterId; 16 } 17 } 18 } 19 20 }
ps(更多的filter可以參考文檔://docs.hangfire.io/en/latest/extensibility/using-job-filters.html)
附上HangFire執行失敗記錄日誌實現
1 /// <summary> 2 /// HangFire Filter 3 /// </summary> 4 public class CustomJobFilterAttribute : JobFilterAttribute, IServerFilter 5 { 6 7 public void OnPerforming(PerformingContext filterContext) 8 { 9 10 } 11 12 /// <summary> 13 /// 未成功執行的 14 /// </summary> 15 /// <param name="filterContext"></param> 16 public void OnPerformed(PerformedContext filterContext) 17 { 18 var error = filterContext.Exception; 19 if (error==null) 20 { 21 return; 22 } 23 //記錄日誌到後台 24 ILoggerFactory loggerFactory = ObjectContainer.GetService<ILoggerFactory>(); 25 ILogger logger; 26 if (error.TargetSite != null && error.TargetSite.DeclaringType != null) 27 { 28 logger = loggerFactory.Create(error.TargetSite.DeclaringType.GetUnProxyType()); 29 } 30 else 31 { 32 logger = loggerFactory.Create(GetType()); 33 } 34 var contextMessage = filterContext.GetJobParameter<ContextMessage>("_ld_contextMessage"); 35 var message = GetLogMessage(contextMessage, error.ToString(), filterContext.BackgroundJob.Id); 36 37 var logLevel = ErrorLevelType.Fatal; 38 39 if (error.InnerException is AppException ex) 40 { 41 logLevel = ex.ErrorLevel; 42 } 43 44 switch (logLevel) 45 { 46 case ErrorLevelType.Info: 47 logger.Info(message, error); 48 break; 49 case ErrorLevelType.Warning: 50 logger.Warn(message, error); 51 break; 52 case ErrorLevelType.Error: 53 logger.Error(message, error); 54 break; 55 default: 56 logger.Fatal(message, error); 57 break; 58 } 59 } 60 61 62 63 /// <summary> 64 /// 獲取當前日誌對象 65 /// </summary> 66 /// <returns></returns> 67 private LogMessage GetLogMessage(ContextMessage contextMessage, string errorMessage, string backgroundJobId) 68 { 69 var logMessage = new LogMessage(contextMessage, errorMessage) 70 { 71 RawUrl = backgroundJobId 72 }; 73 return logMessage; 74 } 75 76 77 }
View Code
現在還有一個問題是,HangFire DashBoard 默認只支持localhost訪問,現在我需要可以很方便的在外網通過web集群就能訪問到其對應的HangFire DashBoard。
通過文檔//docs.hangfire.io/en/latest/configuration/using-dashboard.html,可以知道其提供了一個登錄驗證的實現,但是由於其是直接寫死了密碼在代碼中的,覺得不好。(ps:具體的實現可以參考://gitee.com/LucasDot/Hangfire.Dashboard.Authorization,//www.cnblogs.com/lightmao/p/7910139.html)
我實現的思路是,在web集群界面直接打開標籤頁訪問。在web集群後台生成token並在url中攜帶,在hangfire站點中校驗token,驗證通過則放行。同時校驗url是否攜帶可修改的參數,如果有的話設置IsReadOnlyFunc放回false。(ps:可參考文檔://docs.hangfire.io/en/latest/configuration/using-dashboard.html)
在startup頁面配置使用dashboard,通過DashboardOptions選擇配置我們自己實現的身份驗證,以及是否只讀設置。
1 public void Configuration(IAppBuilder app) 2 { 3 try 4 { 5 6 Bootstrapper.Instance.Start(); 7 8 var dashboardOptions = new DashboardOptions() 9 { 10 Authorization = new[] { new HangFireAuthorizationFilter() }, 11 IsReadOnlyFunc = HangFireIsReadOnly 12 }; 13 app.UseHangfireDashboard("/hangfire", dashboardOptions); 14 15 16 } 17 catch (Exception ex) 18 { 19 Debug.WriteLine(ex); 20 } 21 22 } 23 24 /// <summary> 25 /// HangFire儀錶盤是否只讀 26 /// </summary> 27 /// <param name="context"></param> 28 /// <returns></returns> 29 private bool HangFireIsReadOnly(DashboardContext context) 30 { 31 var owinContext = new OwinContext(context.GetOwinEnvironment()); 32 if (owinContext.Request.Host.ToString().StartsWith("localhost")) 33 { 34 return false; 35 } 36 37 try 38 { 39 var cookie = owinContext.Request.Cookies["Ld.HangFire"]; 40 char[] spilt = { ',' }; 41 var userData = FormsAuthentication.Decrypt(cookie)?.UserData.Split(spilt, StringSplitOptions.RemoveEmptyEntries); 42 if (userData != null) 43 { 44 var isAdmin = userData[0].Replace("isAdmin:", ""); 45 return !bool.Parse(isAdmin); 46 } 47 } 48 catch (Exception e) 49 { 50 51 } 52 53 return true; 54 }
View Code
在HangFireAuthorizationFilter的具體實現中,先校驗是否已存在驗證後的cookie如果有就不再驗證,或者如果是通過localhost訪問也不進行校驗,否則驗證簽名是否正確,如果正確就將信息寫入cookie。
1 /// <summary> 2 /// HangFire身份驗證 3 /// </summary> 4 public class HangFireAuthorizationFilter : IDashboardAuthorizationFilter 5 { 6 public bool Authorize(DashboardContext context) 7 { 8 var owinContext = new OwinContext(context.GetOwinEnvironment()); 9 10 if (owinContext.Request.Host.ToString().StartsWith("localhost")|| owinContext.Request.Cookies["Ld.HangFire"] != null)//通過localhost訪問不校驗,與cookie也不校驗 11 { 12 return true; 13 } 14 15 var cluster = owinContext.Request.Query.Get("cluster");//集群名稱 16 var isAdminS = owinContext.Request.Query.Get("isAdmin");//是否管理員(是則允許修改hangfire) 17 var token = owinContext.Request.Query.Get("token"); 18 var t = owinContext.Request.Query.Get("t");//時間戳 19 if (!string.IsNullOrEmpty(token) && bool.TryParse(isAdminS, out var isAdmin) && long.TryParse(t, out var timestamp)) 20 { 21 try 22 { 23 var isValid = LicenceHelper.ValidSignature($"{cluster}_{isAdmin}", token, timestamp, TimeSpan.FromMinutes(5));//五分鐘有效 24 if (isValid) 25 { 26 var ticket = new FormsAuthenticationTicket(0, cluster, DateTime.Now, DateTime.Now + FormsAuthentication.Timeout, false, $"isAdmin:{isAdmin}"); 27 var authToken = FormsAuthentication.Encrypt(ticket); 28 29 owinContext.Response.Cookies.Append("Ld.HangFire", authToken, new CookieOptions() 30 { 31 HttpOnly = true, 32 Path = "/hangfire" 33 }); 34 return true; 35 } 36 } 37 catch (Exception ex) 38 { 39 40 } 41 } 42 return false; 43 44 } 45 46 }
在web的管理後台具體實現為,同選中集群點擊後台任務直接訪問改集群的HangFire DashBoard
點擊後台任務按鈕,後台放回token相關信息,然後js打開一個新的標籤頁展示dashboard
1 public ActionResult GetHangFireToken(string clusterName) 2 { 3 var isAdmin=WorkContext.User.IsAdmin; 4 var timestamp = DateTime.UtcNow.Ticks; 5 var token = LicenceHelper.Signature($"{clusterName}_{isAdmin}", timestamp); 6 return this.Direct(new 7 { 8 isAdmin, 9 token, 10 timestamp=timestamp.ToString() 11 }); 12 }
1 openHangFire:function() { 2 var me = this, rows = me.getGridSelection('查看後台任務的集群', true); 3 if (!rows) { 4 return; 5 } 6 if (rows.length > 1) { 7 me.alert('只能選擇一行'); 8 return; 9 } 10 var clusterName = rows[0].get('ClusterName'); 11 var opts = { 12 actionName: 'GetHangFireToken', 13 extraParams: { 14 clusterName: clusterName 15 }, 16 success: function (result) { 17 var data = result.result; 18 var url = Ext.String.format("{0}/hangfire?cluster={1}&isAdmin={2}&token={3}&t={4}", 19 rows[0].get("AccessUrl"), 20 clusterName, 21 data.isAdmin, 22 data.token, 23 data.timestamp); 24 window.open(url, clusterName); 25 } 26 }; 27 me.directRequest(opts); 28 }