HangFire多集群切换及DashBoard登录验证

项目中是有多个集群的,现在存在一个是:在切换web集群时,如何切换HangFire的周期性任务。

先采取的解决办法是:

  • 每个集群分一个队列,在周期性任务入队时分配当前web集群的集群id单做队列名称。
  • 之前已存在的周期性任务,在其入队时修正到正确的集群执行

通过BackgroundJobServerOptions配置,只监听当前web集群的队列(ps:可参考文档:https://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可以参考文档:https://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。

通过文档https://docs.hangfire.io/en/latest/configuration/using-dashboard.html,可以知道其提供了一个登录验证的实现,但是由于其是直接写死了密码在代码中的,觉得不好。(ps:具体的实现可以参考:https://gitee.com/LucasDot/Hangfire.Dashboard.Authorizationhttps://www.cnblogs.com/lightmao/p/7910139.html

我实现的思路是,在web集群界面直接打开标签页访问。在web集群后台生成token并在url中携带,在hangfire站点中校验token,验证通过则放行。同时校验url是否携带可修改的参数,如果有的话设置IsReadOnlyFunc放回false。(ps:可参考文档:https://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"))//通过localhost访问不校验
11             {
12                 return true;
13             }
14             var cookie = owinContext.Request.Cookies["Ld.HangFire"];
15             if (cookie != null)
16             {
17                 try
18                 {
19                     var ticket = FormsAuthentication.Decrypt(cookie);
20                     if (ticket?.Expired == false)
21                     {
22                         return true;
23                     }
24                 }
25                 catch (Exception e)
26                 {
27                     
28                 }
29                 
30             }
31 
32             var cluster = owinContext.Request.Query.Get("cluster");//集群名称
33             var isAdminS = owinContext.Request.Query.Get("isAdmin");//是否管理员(是则允许修改hangfire)
34             var token = owinContext.Request.Query.Get("token");
35             var t = owinContext.Request.Query.Get("t");//时间戳
36             if (!string.IsNullOrEmpty(token) && bool.TryParse(isAdminS, out var isAdmin) && long.TryParse(t, out var timestamp))
37             {
38                 try
39                 {
40                     var isValid = LicenceHelper.ValidSignature($"{cluster}_{isAdmin}", token, timestamp, TimeSpan.FromMinutes(5));//五分钟有效
41                     if (isValid)
42                     {
43                         var ticket = new FormsAuthenticationTicket(0, cluster, DateTime.Now, DateTime.Now + FormsAuthentication.Timeout, false, $"isAdmin:{isAdmin}");
44                         var authToken = FormsAuthentication.Encrypt(ticket);
45 
46                         owinContext.Response.Cookies.Append("Ld.HangFire", authToken, new CookieOptions()
47                         {
48                             HttpOnly = true,
49                             Path = "/hangfire"
50                         });
51                         return true;
52                     }
53                 }
54                 catch (Exception ex)
55                 {
56 
57                 }
58             }
59             return false;
60 
61         }
62 
63     }

 

在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     }

 

posted @ 2020-07-13 15:07  Cyril-Hcj  阅读(216)  评论(0编辑  收藏