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     }