使用ASP.NET Core和Hangfire實現HTTP異步化方案

Hi,大家好,我是Payne,歡迎大家一如既往地關注我的博客。今天這篇博客裏的故事背景,來自我工作中的一次業務對接,因爲客戶方提供的是長達上百行的XML,所以一度讓更喜歡使用JSON的博主感到沮喪,我這裏不是想討論XML和JSON彼此的優缺點,而是我不明白AJAX裏的X現在基本都被JSON替代了,爲什麼還有這麼多的人堅持使用並友好的XML作爲數據的交換協議呢?也許你會說,因爲有這樣或者那樣等等的理由,就像SOA、ESB、SAP等等類似的技術在企業級用戶依然大量流行一樣,而這些正是“消費”XML的主力軍。我真正想說的是,在對接這類接口時,我們會遇到一個異步化的HTTP協議場景,這裏的異步和多線程、async/await沒有直接關係,因爲它描述的實際上是業務流程上的一種“異步”。

引子-想對XML說不

我們知道,HTTP協議是一個典型的請求-響應模型,由調用方(Client)調用服務提供者(Server)提供的接口,在理想狀態下,後者在處理完請求後會直接返回結果。可是當後者面對的是一個“耗時”任務時,這種方式的問題就立馬凸顯出來,此時調用者有兩個選擇:一直等對方返回直至超時(同步)、隔一會兒就看看對方是否處理完了(輪詢)。這兩種方式,相信大家都非常熟悉了,如果繼續延伸下去,我們會聯想到長/短輪詢、SignalR、WebSocket。其實,更好的方式是,我們接收到一個“耗時”任務時,立即返回表明我們接收了任務,等任務執行完以後再通知調用者,這就是我們今天要說的HTTP異步化方案。因爲對接過程中,客戶採用的就是這種方案,ESB這類消息總線本身就提供了這種功能,可作爲調用方的博主就非常難受啦,因爲明明能“同步”地處理完的事情,現在全部要變成“異步”處理,就像一個習慣了async/await語法糖的人,突然間就要重新開始寫APM風格的代碼,寶寶心裏苦啊,“異步”處理就異步處理嘛,可要按人家要求去返回上百行的XML,博主表示想死的心都有了好嘛……

好了,吐槽歸吐槽,吐槽完我們繼續梳理下HTTP異步化的方案,這種方式在現實生活中還是相當普遍的,畢竟人類都是“異步”做事,譬如“等你哪天有空一起喫個飯”,測試同事對我說得最多的話就是,“等你這個Bug改完了同我說一聲”,更不用說,JavaScript裏典型的異步單線程的應用等等……實現“異步”的思路其實是非常多的,比如同樣在JavaScript裏流行的回調函數,比如通過一張中間表存起來,比如推送消息到消息隊列裏。在面向數據庫編程的時候,我聽到最多的話就是,沒有什麼問題是不能用一張中間表來解決的,如果一張不行那就用兩張。項目上我是用Quartz+中間表的方式實現的,因爲這是最爲普通的方式。這裏,我想和大家分享下,關於使用Hangfire來實現類似Quartz定時任務的相關內容,果然,我這次又做了一次標題黨呢,希望大家會對今天的內容感興趣。簡單來說,我們會提供一個接口,調用方提供參數和回調地址,調用後通過Hangfire創建後臺任務,等任務處理結束後,再通過回調地址返回結果給調用方,這就是所謂的HTTP異步化。

開箱即用的Hangfire

我們項目上是使用Quartz來實現後臺任務的,因爲它採用了反射的方式來調用具體的Job,因此,它的任務調度和任務實現是耦合在同一個項目裏的,常常出現單個Job引發整個系統卡頓的情況,尤其是是它的觸發器,常常導致一個Job停都停不下來,直到後來才漸漸開始通過Web API來分離這兩個部分。Quartz幾乎沒有一個自己的可視化界面,我們爲此專門爲它開發了一套UI。我這裏要介紹的Hangfire,可以說它剛好可以作爲Quartz的替代品,它是一個開箱即用的、輕量級的、開源後臺任務系統,想想以前爲Windows開發定時任務,只能通過定時器(Timer)來實現,尚不知道CRON爲何物,而且只能用命令行那種拙劣的方式來安裝/卸載,我至今都記得,測試同事問我,能不能不要每次都彈個黑窗口出來,這一起想起來還真是讓人感慨啊。好了,下面我們開始今天的實踐吧!首先,第一步自然是安裝Hangfire啦,這裏我們新建一個ASP.NET Core的Web API項目就好,然後通過NuGet依次安裝以下庫:

Install-Package HangFire
Install-Package Hangfire.MySql.Core

這裏我們選擇了MySQL來實現任務的持久化,從官方的流程圖中可以瞭解到,Hangfire有服務端、持久化存儲和客戶端三大核心部件組成,而持久化存儲這塊兒,除了官方默認的SQLServer(可以集成MSMQ)以外,還支持Redis、MongoDB等,Hangfire使用起來是非常簡單噠,首先在Startup類的ConfigureServices()方法中注入Hangfire相關的服務,然後在Configure()方法中使用HangfireServer和UseHangfireDashboard即可:

public void ConfigureServices (IServiceCollection services) {
    //爲了簡化說明,已忽略該方法中無關的代碼
    services.AddHangfire (x =>
        x.UseStorage (new MySqlStorage (Configuration.GetConnectionString ("Hangfire")))
        .UseFilter (new HttpJobFilter ())
        .UseSerilogLogProvider ()
    );
}

public void Configure (IApplicationBuilder app, IHostingEnvironment env) {
    //爲了簡化說明,已忽略該方法中無關的代碼
    app.UseHangfireServer (new BackgroundJobServerOptions () {
        Queues = new string[] { "default" },
        WorkerCount = 5,
        ServerName = "default",
    });
    app.UseHangfireDashboard ();
    app.ApplicationServices.GetService<ILoggerFactory> ().AddSerilog ();
}

注意到在配置持久化的部分,我們使用了一個數據庫連接字符串Hangfire,它需要我們在appsettings.json中配置ConnectionStrings部分。這裏我們爲Hangfire設置了默認隊列default、默認服務器default、併發數目爲5。與此同時,我們開啓了Hangfire中自帶的Dashboard,可以通過這個界面來監控後臺任務的執行情況。此時運行項目,輸入以下地址:http://locahost:/hangfire,就會看到下面的畫面,這說明Hangfire配置成功:

Hangfire Dashboard

Hangfire中默認支持四種類型的後臺任務,他們分別是Fire-and-forget jobsDelayed jobsRecurring jobsContinuations。嚴格來說,Fire-and-forget jobsDelayed jobs並不能算後臺任務,因爲它們在執行一次後就會從隊列中移除,屬於一次性“消費”的任務,這兩者的不同在於Delayed jobs可以在設定的時間上延遲執行。而Recurring jobsContinuations則是週期性任務,任務在入隊後可以按照固定的時間間隔去執行,週期性任務都是支持CRON表達式的,Continuations類似於Task中的ContinueWith()方法,可以對多個任務進行組合,我們現在的項目中開發了大量基於Quartz的Job,可當你試圖把這些Job相互組合起來的時候,你就會覺得相當尷尬,因爲後臺任務做所的事情往往都是大同小異的。從官方文檔中 ,我們會發現Hangfire的關鍵代碼只有下面這四行代碼,可以說是相當簡潔啦!

//Fire-and-forget jobs
var jobId = BackgroundJob.Enqueue(
    () => Console.WriteLine("Fire-and-forget!"));

//Delayed jobs
var jobId = BackgroundJob.Schedule(
    () => Console.WriteLine("Delayed!"),
    TimeSpan.FromDays(7));

//Recurring jobs
RecurringJob.AddOrUpdate(
    () => Console.WriteLine("Recurring!"),
    Cron.Daily);

//Continuations
BackgroundJob.ContinueWith(
    jobId,
    () => Console.WriteLine("Continuation!"));

Hangfire除了這種偏函數式風格的用法以外,同樣提供了泛型版本的用法,簡而言之,泛型版本是自帶依賴注入的版本。衆所周知,稍微複雜點的功能,常常會依賴多個服務,比如後臺任務常常需要給相關人員發郵件或者是消息,此時,Job的實現就會依賴MailService和MessageService。Hangfire內置了基於Autofac的IoC容器,因此,當我們使用泛型版本時,它可以自動地從容器中Resolve相應的類型出來。事實上,我們可以通過重寫JobActivator來實現自己的依賴注入,譬如博主就喜歡Castle。下面是一個簡單的例子:

//Define a class depends on IDbContext & IEmailService
public class EmailSender
{
    private IDbContext _dbContext;
    private IEmailService _emailService;

    public EmailSender()
    {
        _dbContext = new DbContext();
        _emailService = new EmailService();
    }

    // ...
}

//When it is registered in Ioc Container
BackgroundJob.Enqueue<EmailSender>(x => x.Send("Joe", "Hello!"));

可擴展的Hangfire

OK,在對Hangfire有了一個初步的瞭解以後,我們再回到本文的題目,我們希望實現一個基於HTTP方式調用的HttpJob。因爲我們不希望任務調度和具體任務放在一起,我們項目上採用Quartz來開發後臺任務,它要求我們實現一個特定接口IbaseJob,最終任務調度時會通過反射來創建Job,就在剛剛過去的這周裏,測試同事向我反饋了一個Bug,而罪魁禍首居然是因爲某個DLL沒有分發,所以,我希望實現一個基於HTTP方式調用的HttpJob,這既是爲了將任務調度和具體任務分離,同時爲了滿足這篇文章開頭描述的場景,得益於Hnagfire良好的擴展性,我們提供了一組Web API,代碼如下:

/// <summary>
/// 添加一個任務到隊列並立即執行
/// </summary>
/// <param name="jobDescriptor"></param>
/// <returns></returns>
[HttpPost ("AddEnqueue")]
public JsonResult Enqueue (HttpJobDescriptor jobDescriptor) {
    try {
        var jobId = string.Empty;
        jobId = BackgroundJob.Enqueue (() => HttpJobExecutor.DoRequest (jobDescriptor));
        return new JsonResult (new { Flag = true, Message = $"Job:#{jobId}-{jobDescriptor.JobName}已加入隊列" });
    } catch (Exception ex) {
        return new JsonResult (new { Flag = false, Message = ex.Message });
    }
}

/// <summary>
/// 添加一個延遲任務到隊列
/// </summary>
/// <param name="jobDescriptor"></param>
/// <returns></returns>
[HttpPost ("AddSchedule")]
public JsonResult Schedule ([FromBody] HttpJobDescriptor jobDescriptor) {
    try {
        var jobId = string.Empty;
        jobId = BackgroundJob.Schedule (() => HttpJobExecutor.DoRequest (jobDescriptor), TimeSpan.FromMinutes ((double) jobDescriptor.DelayInMinute));
        return new JsonResult (new { Flag = true, Message = $"Job:#{jobId}-{jobDescriptor.JobName}已加入隊列" });
    } catch (Exception ex) {
        return new JsonResult (new { Flag = false, Message = ex.Message });
    }
}

/// <summary>
/// 添加一個定時任務
/// </summary>
/// <param name="jobDestriptor"></param>
/// <returns></returns>
[HttpPost ("AddRecurring")]
public JsonResult Recurring ([FromBody] HttpJobDescriptor jobDescriptor) {
    try {
        var jobId = string.Empty;
        RecurringJob.AddOrUpdate (jobDescriptor.JobName, () => HttpJobExecutor.DoRequest (jobDescriptor), jobDescriptor.Corn, TimeZoneInfo.Local);
        return new JsonResult (new { Flag = true, Message = $"Job:{jobDescriptor.JobName}已加入隊列" });
    } catch (Exception ex) {
        return new JsonResult (new { Flag = false, Message = ex.Message });
    }
}

/// <summary>
/// 刪除一個定時任務
/// </summary>
/// <param name="jobName"></param>
/// <returns></returns>
[HttpDelete ("DeleteRecurring")]
public JsonResult Delete (string jobName) {
    try {
        RecurringJob.RemoveIfExists (jobName);
        return new JsonResult (new { Flag = true, Message = $"Job:{jobName}已刪除" });
    } catch (Exception ex) {
        return new JsonResult (new { Flag = false, Message = ex.Message });
    }
}

/// <summary>
/// 觸發一個定時任務
/// </summary>
/// <param name="jobName"></param>
/// <returns></returns>
[HttpGet ("TriggerRecurring")]
public JsonResult Trigger (string jobName) {
    try {
        RecurringJob.Trigger (jobName);
        return new JsonResult (new { Flag = true, Message = $"Job:{jobName}已觸發執行" });
    } catch (Exception ex) {
        return new JsonResult (new { Flag = false, Message = ex.Message });
    }

}

/// <summary>
/// 健康檢查
/// </summary>
/// <returns></returns>
[HttpGet ("HealthCheck")]
public IActionResult HealthCheck () {
    var serviceUrl = Request.Host;
    return new JsonResult (new { Flag = true, Message = "All is Well!", ServiceUrl = serviceUrl, CurrentTime = DateTime.Now });
}

你可以注意到,這裏用到其實還是四種後臺任務,在此基礎上增加了刪除Job和觸發Job的接口,尤其是觸發Job執行的接口,可以彌補Quartz的不足,很多時候,我們希望別人調了接口後觸發後臺任務,甚至希望在編寫Job的過程中使用依賴注入,因爲種種原因,實施起來總感覺有點礙手礙腳。這裏我們定義了一個HttpJobExecutor的類,顧名思義,它是執行Http請求的一個類,說來慚愧,我寫作這篇博客時,是一邊看文檔一邊寫代碼的,所以,等我實現了這裏的HttpJobExecutor的時候,我忽然發現文檔中關於依賴注入的內容,簡直相見恨晚啊。這裏直接給出它的實現,我要再一次安利RestSharp這個庫,比HttpWebRequest、HttpClient這兩套官方的API要好用許多,可還是有人喜歡一遍又一遍地封裝啊,話說自從我們把WCF換成Web API後,看着相關同事在Git上的折騰歷史,果然還是回到了寫Http Client的老路上來,話說在糾結是手寫代理還是動態代理的時候,Retrofit瞭解下啊!

[HttpJobFilter]
public static void DoRequest (HttpJobDescriptor jobDestriptor) {
    var client = new RestClient (jobDestriptor.HttpUrl);
    var httpMethod = (object) Method.POST;
    if (!Enum.TryParse (typeof (Method), jobDestriptor.HttpMethod.ToUpper (), out httpMethod))
        throw new Exception ($"不支持的HTTP動詞:{jobDestriptor.HttpMethod}");
    var request = new RestRequest ((Method) httpMethod);
    if (jobDestriptor.JobParameter != null) {
        var json = JsonConvert.SerializeObject (jobDestriptor.JobParameter);
        request.AddParameter ("application/json", json, ParameterType.RequestBody);
    }
    var response = client.Execute (request);
    if (response.StatusCode != HttpStatusCode.OK)
        throw new Exception ($"調用接口{jobDestriptor.HttpUrl}失敗,接口返回:{response.Content}");
}

在這裏,我們以HealthCheck這個接口爲例,來展示HttpJob是如何工作的。顧名思義,這是一個負責健康檢查的接口。我們現在通過Postman來觸發健康檢查這個後臺任務。在這裏,該接口是一個GET請求:

通過Postman創建後臺任務

接下來,我們我們就會在Hangfire的Dashborad中找到對應的記錄,因爲這是一個Fire & Forget類型的任務,因此我們幾乎看不到中間的過程,它就已經執行結束啦。我們可以在Dashboard中找到對應的任務,然後瞭解它的具體執行情況。值得一提的是,Hangfire自帶了重試機制,對於執行失敗的任務,我們可以重試欄目下看到,這裏是其中一條任務的執行記錄。可以注意到,Hangfire會把每個Job的參數序列化爲JSON並持久化起來,仔細對照的話,你會發現,它和我們在Postman中傳入的參數是完全一樣的!

Hangfire中Job執行詳情查看

在執行Job的過程中,我們可能會希望記錄Job執行過程中的日誌。這個時候,Hangfire強大的擴展性再次我們提供了這種可能性。注意到在HttpJobExecutor類上有一個 [HttpJobFilter]的標記,顯然這是由Hangfire提供的一個過濾器,博主在這個過濾器中對Job的ID、狀態等做了記錄,因爲在整個項目中博主已經配置了Serilog作爲Hangfire的LogProvider,所以,我們可以在過濾器中使用Serilog來記錄日誌,不過博主個人感覺這個Filtre稍顯雞肋,這裏還是給出代碼片段吧!

public class HttpJobFilter : JobFilterAttribute, IApplyStateFilter {
    private static readonly ILog Logger = LogProvider.GetCurrentClassLogger ();

    public void OnStateApplied (ApplyStateContext context, IWriteOnlyTransaction transaction) {
        if (context.NewState is FailedState) {
            var failedState = context.NewState as FailedState;
            if (failedState != null) {
                Logger.ErrorException (
                    String.Format ("Background Job #{0} 執行失敗。", context.BackgroundJob.Id),
                    failedState.Exception);
            }
        } else {
            Logger.InfoFormat (
                "當前執行的Job爲:#{0}, 狀態爲:{1}。",
                context.BackgroundJob.Id,
                context.NewState.Name
            );
        }
    }

    public void OnStateUnapplied (ApplyStateContext context, IWriteOnlyTransaction transaction) {

    }
}

爲什麼我說這個Filter有點雞肋呢?因爲你看下面的圖就會明白了啊!

使用Serilog記錄日誌

本文小結

果然,我還是不得不承認,這又是一篇徹徹底底的"水文"啊,因爲寫着寫着就發現自己變成了標題黨。這篇文章總結下來其實只有兩句話,一個不喜歡寫XML報文的博主,如何與ERP、SAP、ESB裏的XML報文鬥智鬥勇的故事,在這樣一個背景下,爲了滿足對方的"異步"場景, 不得不引入一個後臺任務系統來處理這些事情,其實,這個事情用消息隊列、用Redis、甚至普通的中間表都能解決,可惜我寫這篇文章的時候,是有一點個人化的情緒在裏面的,這種情緒化導致的後果就是,可能我越來越難以控制一篇文章的寫作走向啦,大概是寫東西越來越困難,而又沒有時間取吸收新的知識進來,這讓我覺得自己的進步越來越少,Hangfire的有點說起來就是挺好用的,以上!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章