返回文章列表文章索引阅读全文
STARBLOG / 阅读手记
Asp-Net-Core开发笔记

Asp-Net-Core Development Notes: Integrating Hangfire for Async Task Queues and Scheduled Jobs

Introduction Recently I've been migrating a data collection platform written in Python to .Net Core. Originally the collection tasks used multiprocessing + a thread pool to speed up collection, and used Celery as an async task queue that also handled scheduled tasks. This setup worked okay, but hey, tinkering is the point, so let's just go with C#~ This article records how Hangfire is used in a real application. I found that most articles online just use Hangfire's async jobs to print Hello World, and then that's it. I really don't know what the point of writing such articles

曦远 (DealiAxy)约 27 分钟
目录14 个章节

Introduction

Recently I've been migrating a data collection platform written in Python to .Net Core. Originally the collection tasks used multiprocessing + a thread pool to speed up collection, and used Celery as an async task queue that also handled scheduled tasks. This setup worked okay, but hey, tinkering is the point, so let's just go with C#~

This article records how Hangfire is used in a real application. I found that most articles online just use Hangfire's async jobs to print Hello World, and then that's it. I really don't know what the point of writing such articles is?? Besides wasting the reader's time, they also waste the author's time writing them...

.NetCore as a high-performance platform naturally shouldn't lose to Python, but I don't want to reinvent the wheel (I'm a noob), so I'll find an existing solution to use, to avoid pitfalls~

First I researched the current .Net Core ecosystem and found a few options:

  • FreeScheduler
  • Quartz.net
  • Hangfire

The first one, FreeScheduler, is from the same author as the FreeSQL project. My project happens to use FreeSQL as the ORM as well, but looking at GitHub it has relatively few stars, and the docs aren't complete yet. The dependency injection support I care about most isn't easy to set up for now, so I had to drop it.

Then between Quartz.net and Hangfire, I chose the latter, because I had used Hangfire when working on the CrawlCenterNet project before. It was pretty good, and it comes with a simple dashboard, which is quite intuitive~

So let's get started~

Choosing a Backend

Here "backend" refers to the storage backend for the task queue, i.e. Storage as described in the Hangfire docs.

I checked the official site — most relational and non-relational databases are supported, so I was relieved. Since the production environment currently uses Oracle, I initially chose Oracle as Storage. But when I ramped up to 2000+ jobs at the same time it threw an error. Looking into it, the reason was insufficient tablespace — an Oracle issue...

So for performance and stability, I dropped it, then tried SQLite (for local testing only). I found that even though I defined a Connection String in the config, it ignored me and used that Connection String as the database name. Speechless... That way I couldn't set the SQLite database to async mode, so the speed was basically a turtle crawl...

Dropped it again... This time I went straight with Redis. For better performance, I gave up persistence~ Redis didn't disappoint me either — thousands of jobs without even batting an eye, nice~

Data Collection Code

The Hangfire component wasn't introduced from the start. I'll first show the most basic data collection code, so the later explanation will be clearer~

The key code is in the Services/CrawlService.cs file

public class CrawlService {
  // 依赖注入一些服务
  private readonly IBaseRepository<Proc> _repo;
  
  public async Task CrawlAllProc() {
    for(var i=1; i<2000; i++) {
      await CrawlProcList(i);
    }
  }
  
  public async Task CrawlProcList(int page) {
    // 具体代码省略了
    var procList = ; //...
    foreach (var proc in procList) {
      await CrawlProc(proc);
    }
  }
  
  public async Task CrawlProc(Proc proc) { }
}

Then, when starting a collection job, we just call the CrawlAllProc method, which starts collecting page by page. Each page has many Proc records, all of which get collected.

The code above uses await, which waits for the async method to complete, so it's very slow. If we remove await, the task runs on a new thread without waiting for it to finish, but the problem is obvious: if something goes wrong it's hard to debug, which makes it difficult to guarantee system stability.

Next we'll refactor this with Hangfire.

Installing Hangfire

This project uses the following dependencies:

  • Hangfire.Core
  • Hangfire.AspNetCore
  • Hangfire.Redis.StackExchange

Just nuget them all in one go and done

Registering Services

To distinguish from the later content, here's the official example first

Registering services:

services.AddHangfire(
  configuration => configuration
  .SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
  .UseSimpleAssemblyNameTypeSerializer()
  .UseRecommendedSerializerSettings()
  // 根据实际使用的 Storage 来注册
  .UseRedisStorage();
services.AddHangfireServer();

Adding middleware:

app.UseHangfireDashboard();

Basic Usage

Hangfire is registered as a singleton by default, so you can add async jobs or scheduled jobs from any code using its static methods.

Async jobs:

BackgroundJobs.Enqueue(() => Console.WriteLine("Hello world!"));

Scheduled jobs:

Hangfire's scheduled jobs are called recurrent tasks. I used to usually call them scheduled task, and at first I almost couldn't find the docs~

The following code adds a job that runs once a day. If you need a different schedule, you can customize the Cron parameter that follows. Look up Cron syntax yourself~

RecurringJob.AddOrUpdate("easyjob", () => Console.Write("Easy!"), Cron.Daily);

Proper Usage

OK, finally getting to the main content

As I said at the beginning, the usage introduced above is far from enough. If it were just introducing a Hello World, there would be no need to write a dedicated article...

Now I'll introduce how to use Hangfire together with my CrawlService

Because CrawlService needs to operate on the database, it uses dependency injection, so we need Hangfire to support DI as well.

The official docs have a section on IoC containers (https://docs.hangfire.io/en/latest/background-methods/using-ioc-containers.html), but they don't cover the AspNetCore container. Time to just DIY it~

Adding AspNetCore's DI Container

I searched for a long time at first and couldn't find anything. Eventually I found an example on GitHub. The AspNetCore version in it was really old — it was actually version 1.1, which I've never even used... But that didn't stop me from borrowing their approach~

This step needs a subclass of JobActivator

Let's write one. I put it under the Infrastructure directory

using Hangfire;

public class HangfireActivator : JobActivator {
    private readonly IServiceProvider _serviceProvider;

    public HangfireActivator(IServiceProvider serviceProvider) {
        _serviceProvider = serviceProvider;
    }

    public override object? ActivateJob(Type jobType) {
        return _serviceProvider.GetService(jobType);
    }
}

Here, in the HangfireActivator constructor we pass in AspNetCore's IoC container object, and override the ActivateJob method so that when Hangfire activates a job it gets the object from the IoC container. Pretty straightforward.

Modifying the Service Registration Code

Actually the service registration part is the same and doesn't need to be changed

But as a habit, to keep Program.cs or Startup.cs relatively clean, I still wrote an extension method for this part.

Add ConfigureHangfire.cs in the Extensions directory

public static class ConfigureHangfire {
    public static void AddHangfirePkg(this IServiceCollection services, IConfiguration configuration) {
        services.AddHangfire(conf => conf
            .SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
            .UseSimpleAssemblyNameTypeSerializer()
            .UseRecommendedSerializerSettings()
            .UseRedisStorage()
        );

        services.AddHangfireServer();
    }

    public static void UseHangfire(this WebApplication app) {
        GlobalConfiguration.Configuration.UseActivator(new HangfireActivator(app.Services));
        app.UseHangfireDashboard();
    }
}

You can see the change is that before adding the middleware, we configure the Activator with this line:

GlobalConfiguration.Configuration.UseActivator(new HangfireActivator(app.Services));

Just pass in the IoC container

Done~

Then in Program.cs (I'm using .Net6), just use this extension method and you're done~

builder.Services.AddHangfirePkg(builder.Configuration);
// 中间件
app.UseHangfire();

Creating Jobs

With DI in place, creating an async job looks like this. There's just an extra generic parameter.

BackgroundJob.Enqueue<CrawlService>(a => a.CrawlAllProc());

Scheduled job, runs once every hour

RecurringJob.AddOrUpdate<CrawlService>(a => a.CrawlAllProc(), Cron.Hourly);

Refactoring the Data Collection Code

OK, finally back to the original data collection code, with the following changes:

public class CrawlService {
  // 依赖注入一些服务
  private readonly IBaseRepository<Proc> _repo;
  
  public async Task CrawlAllProc() {
    for(var i=1; i<2000; i++) {
      // await CrawlProcList(i);
      BackgroundJob.Enqueue(() => CrawlProcList(i, 100));
    }
  }
  
  public async Task CrawlProcList(int page, int pageSize = 100) {
    // 具体代码省略了
    var procList = ; //...
    foreach (var proc in procList) {
      // await CrawlProc(proc);
      BackgroundJob.Enqueue(() => CrawlProc(proc));
    }
  }
  
  public async Task CrawlProc(Proc proc) { }
}

Comment out the original awaits and replace them with creating async jobs via Hangfire. Run it, open the dashboard, and you can see jobs shoot up to thousands in a flash — extremely fast~

One thing to note is that the second parameter pageSize of the CrawlProcList method has a default value of 100. In normal use that's fine — you can omit this parameter and it defaults to 100.

But in the BackgroundJob.Enqueue method you cannot omit this parameter, otherwise you'll get an error saying the compiler can't resolve something. This is probably a C# language limitation; I haven't looked into it in depth yet.

Wrap-up

OK, that roughly wraps up the data collection & scheduled collection features. This part happened to be finished on the first day of National Day overtime. I'll leave the rest to time~ Let it run to its heart's content over the remaining days of the National Day holiday, and I'll check the results when the holiday is over. If there's new progress I'll update the blog promptly.

Oh right, I also plan to wrap async jobs and scheduled jobs behind an interface (seems like AspNetCore doesn't have this functionality?). I don't want the code to be too tightly coupled with Hangfire. Wrapping it as an abstract interface means switching to another library later won't be a problem.

I'll add this to the todo list for now~

References

_EOF_

Asp-Net-Core Development Notes: Integrating Hangfire for Async Task Queues and Scheduled Jobs

本文作者:
程序设计实验室
版权声明:
本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

相关文章

Asp-Net-Core开发笔记:使用NPM和gulp管理前端静态文件

前言 本文介绍的是AspNetCore的MVC项目,WebApi+独立前端这种前后端分离的项目就不需要多此一举了~默认前端小伙伴是懂得使用前端工具链的。 为啥要用MVC这种服务端渲染技术呢? 简单项目不需要强行分离增加复杂度(如:我正在开发的博客项目) 后端渲染利于SEO,对博客网站友好 OK,虽然MVC的技术老了点,但依然可以结合现代前端工具链来提高效率 本文的食用需要先安装好Node.js环境

2022-02-17 · Asp-Net-Core开发笔记 · 约 1 分钟

讨论

共 0 条

良言一句三冬暖,恶语伤人六月寒。

加载评论中…