ASP.NET Core Library – Hangfire

前言

以前写过 Hangfire 的学习笔记, 但写的很乱. 这篇做个整理.

 

介绍

Hangfire 是用来做 server task 的, 比如: background job, delay job, schedule job 等等. 它可以做到分钟级别的 schedule, 任务会通过 SQL Server 来管理 (也可以支持其它 database 但不推荐)

 

参考:

C#-初识Hangfire

官网 docs

 

安装 & Startup

参考: 官网教程

安装 nuget

dotnet add package Hangfire.Core
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer

startup

builder.Services.AddHangfire(configuration => configuration
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage("Server=192.168.1.152;Database=TestHangfire;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True", new SqlServerStorageOptions
    {
        CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
        SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
        QueuePollInterval = TimeSpan.Zero,
        UseRecommendedIsolationLevel = true,
        DisableGlobalLocks = true
    }));
builder.Services.AddHangfireServer();

配置不重要, 这里是按照官网 example 默认配置, 链接上 database 就可以了 (注: Hangfire 会负责创建 tables, 但我们需要先创建好 database, 不然会报错)

启动

app.MapHangfireDashboard();

 

Background Job

一般网站都有一个 send enquiry 的功能, 当用户提交 enquiry 表单后, 系统需要发 email 给管理人.

发 SMTP 是很慢的, 如果让用户等待会影响用户体验. 所以这种情况就很适合跑一个 background job. 

request 设定好 background job 后就可以直接 response user. 然后系统才背地里去发 SMTP.

这种场景就可以用 Hangfire 来实现了.

public void OnPost()
{
    var enquiryId = 1; // create enquiry to database
    BackgroundJob.Enqueue<EmailService>(e => e.SendEmail(enquiryId));
}

调用 BackgroundJob.Enqueue 就可以了. 它会在 response 之后立刻被执行.

有几个点需要注意

1. BackgroundJob.Enqueue 的参数是 Expression 而不是 Func, 所以它只能简单地表达式, 如果要写负责逻辑就需要开一个方法, 让表达式去调用方法.

2. 方法必须是 public 的

3. Hangfire 执行 job 时, 会动态的创建这个方法的 class / interface, 它是通过 ActivatorUtilities.CreateInstance 创建的, 支持依赖注入哦, 如果创建失败 task 就 fail 了, 会去 rety.

4. 方法执行时是完全独立的一个 scope (线程), 和之前的 request 完全没有关系. 如果注入 HttpContext 会发现它是 null.

public class IndexModel : PageModel
{
    public string Value { get; set; } = "default";

    public void OnPost()
    {
        Value = "Not Default";
        BackgroundJob.Enqueue(() => DoSomething());
    }

    public void DoSomething() 
    {
        var value = Value; // default
    }
}

上面, DoSomething 执行是是全新的一个 scope, IndexModel 会被创建, 所以 value 是 default.

5. 尽量不要让方法依赖原本环境的东西, 做一个中间人负责.

比如, 与其把把所有信息以 parameters 形式传入方法, 更好的做法是让方法自己去获取所有信息, 通过一个 Id 作为中间人.

 

Delay Background Job

上面提到的例子是是属于马上执行的 background job, 还有一种是 delay job. 比如, 希望 user submit 之后 10 second 才发 email.

public void OnPost()
{
    var enquiryId = 1; // create enquiry in database
    BackgroundJob.Schedule<IEmailService>(s => s.SendEmail(enquiryId), TimeSpan.FromSeconds(10));
}

调用的方法是 .Schedule, 传入 delay 的 timespan 或者一个绝对时间 DateTimeOffset.

Hangfire 是通过一个 interval 在背后检查 schedule 的, 它默认的时间是 15 second 检查一次.

可以通过 options 修改它, 估计是性能考量所以才放 15 秒吧, 不然一直要去 query database check job 也挺伤的.

builder.Services.AddHangfireServer(options => {
    options.SchedulePollingInterval = TimeSpan.FromSeconds(1);
});

 

Recurring Job

上面说的都是一次性执行, recurring job 是用来处理那种每星期/月要执行的 job.

说到这个就得说说 cron expression 了. 它就是用来表达, 每星期, 每月, 还是每逢...什么时辰的.

参考:

CRON 表达式详解

cron表达式详解

crontab guru 小工具

Hangfire create/remove recurring job

RecurringJob.AddOrUpdate("job name", () => Console.Write("Easy!"), "cron expression");
RecurringJob.AddOrUpdate("job name", () => Console.Write("Easy!"), Cron.Daily);
RecurringJob.RemoveIfExists("job name");

Cron.Daily 是 Hangfire 的 helper 类, 帮我们创建 cron expression.

也可以封装成 Service

RecurringJob.AddOrUpdate<FacebookReviewService>("Sync Facebook Review", service => service.SyncToDatabaseAsync(), Cron.Daily(hour: 1));

Service.cs

public class FacebookReviewService
{
    private readonly IWebHostEnvironment _env;

    public FacebookReviewService(
        IWebHostEnvironment env
    )
    {
        _env = env;
    }

    [AutomaticRetry(Attempts = 0)]
    public async Task SyncToDatabaseAsync()
    {
       // do anything
    }
}

AutomaticRetry 是声明是否失败了要自动重试. 0 就是不要. 

 

状况

在设计 job 的时候要记得, server schedule 并不稳定. 有可能遇到 server down, job runtime error 等等的情况.

1. Runtime error and retry

当 job 出现 runtime error 时, Hangfire 默认会 retry 10 次, 每次 retry 都会有一个间隔时间.

它的 delay 算法是

如果想修 retry count 和 delay, 可以放 AutomaticRetryAttribute 到 job 方法上, 0 表示不要 retry, AttemptsExceededAction.Delete | Fail 意思是 error 以后是否要把这个 job 洗掉后者留一个 status fail 做计入 (这个不影响它 retry).

public class EmailService : IEmailService
{
    [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
    public void SendEmail(int enquiryId)
    {

    }
}

由于它有 retry 的机制, 所以在设计 job 时, 需要做 transaction 确保原子性, 或者把方法做成幂等,

2. Miss execute time

Server down, retry 都有可能导致 job 运行的时间和预想的不一致. 比如预设每星期一凌晨 12 点跑.

结果那个时段 server down 了, Hangfire 会在 server up 的时候立刻补上错过的 job. 

Retry 的 delay 间隔, 也会造成运行时间和预期不符. 

所以在设计 job 时也需要考虑到时间.

3. 超时任务

job 运行太耗时, 与至于下一次的运行也启动了. 这时就容易出现混乱. 这个视乎是风水的问题. 应该避免大任务执行, 把它切分成小任务.

部分部分去 complete.

 

数据结构

Job 是查看所有运行过的 job, 不管是成功失败.

State 是所有 job 每一次 state change 的记入, 包括了 Enqueued, Processing, Succeeded 等

Set 是 recurring job 的 definition, crod expression 这些

其它就比较少会去看.

 

Dashboard

26-01-2022 Issue: Dashboard page blank after upgrade to .net 6.0

hot reload 和 Hangfire dashboard 撞. 目前没有看到有 github issue. wordaround 是关掉 hot reload.

访问 /Hangfire 就可以看见 build-in 的 dashboard 了

这里还可以 manual trigger job 或者移除 job 哦. 这也是 Hangfire 的一大卖点.

想自定义 url 可以这样做

app.MapHangfireDashboard("/jobs");

Read-only

app.MapHangfireDashboard("/Hangfire", new DashboardOptions
{
    IsReadOnlyFunc = (DashboardContext context) => true
});

Authorize

默认只有 dev 情况下才能无授权访问 dashboard, 通过自定义 IDashboardAuthorizationFilter 就可以设定权限访问.

public class MyAuthorizationFilter : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        var httpContext = context.GetHttpContext();

        // Allow all authenticated users to see the Dashboard (potentially dangerous).
        return httpContext.User.Identity.IsAuthenticated;
    }
}

注: UseHangfireDashboard 要在 authentication, authorize middleware 之后. 

app.MapHangfireDashboard("/Hangfire", new DashboardOptions
{
    Authorization = new [] { new MyAuthorizationFilter() }
});

上面这个方法比较过时了, 如果是有搭配 login 界面的话推荐使用 MapHangfireDashboardWithAuthorizationPolicy 来做

builder.Services.AddAuthorization(options =>
    options.AddPolicy("HangfireDashboard", policy => policy.RequireAuthenticatedUser())
);
app.MapHangfireDashboardWithAuthorizationPolicy("HangfireDashboard");

还有一招是用 basic authentication, 参考: Stack Overflow – ASP.NET Core MVC Hangfire custom authentication

 

MapHangfireDashboard vs UseHangfireDashboard

Map 是 endpoint routing 的版本, 尽量用 Map 呗

 

Error : JobStorage.Current property value has not been initialized

如果没有调用 app.MapHangfireDashboard 而直接使用 RecurringJob.AddOrUpdate 是会报错的.

相关提问: Stack Overflow – JobStorage.Current property value has not been initialized. You must set it before using Hangfire Client or Server API

解决方式有 2 种

1. 调用 MapHangfireDashboard

2. RequiredService<JobStorage> 激活它一下

app.Services.GetRequiredService<JobStorage>();
RecurringJob.AddOrUpdate("job name", () => Console.Write("Easy!"), Cron.Daily(hour: 6));

 

Multiple Server

今天突然发现 job duplicated 了. 然后发现既然有 2 个 server instance.

后来发现, 原来是我忘了把 staging server 关掉. 低级错误. 哈哈

下面这个代码可以查看当前的 server instance 有哪些

var monitoringApi = JobStorage.Current.GetMonitoringApi();
var removingServers = monitoringApi.Servers().Where(e => e.Name.Contains("myserver")).ToList();
removingServers.ForEach(removingServer => JobStorage.Current.GetConnection().RemoveServer(removingServer.Name));

 

posted @ 2022-01-06 16:54  兴杰  阅读(811)  评论(1编辑  收藏  举报