ASP.NET 程式中的背景工作 (1)

摘要:示範如何在 ASP.NET 應用程式中處理長時間執行的背景工作,以及如何設定 IIS 來讓網站自動啟動並持續運行。換言之,就是把 ASP.NET 網站當成 Windows Service 來用。

前言

以往要撰寫自動隨 Windows 啟動而自動在背景執行的應用程式,通常第一個想到的作法就是撰寫 Windows Services 應用程式。

有時候,Web 應用程式也需要執行一些比較花時間的背景工作,例如發送大量電子郵件。或者說,正因為這些工作需要花費較多時間,所以只能放在背景處理。如果處理這些背景工作的程式碼能直接寫在 ASP.NET 應用程式裡面,對於已經熟悉 ASP.NET 程式開發模型的人來說,不是很方便嗎?

這種作法,說穿了就是把 ASP.NET 應用程式當成 Windows Service 應用程式來用。

聽起來好像不太保險對吧?至少不是正規作法。

以前我也這麼覺得,直到一位同事在某個專案中使用,實際上線運行之後,發現其實挺穩定的,沒有出什麼亂子。後來我在另一個專案中如法炮製,運行幾個月了,也都相安無事。所以我想,應該可以把這篇筆記整理出來了。

環境需求

IIS 8 或之後的版本。IIS 7.5 也可以,只是部署的程序稍微麻煩些。(Windows Server 2008 R2 裡面的 IIS 是 7.5 版)

註:IIS 7.0 或之前的版本不支援應用程式自動初始化,故不適用。

範例

這裡先用一個簡單的範例來測試和觀察程式運行的狀況。假設的情境是我們需要在背景發送電子郵件。

首先建立一個 ASP.NET 應用程式專案,然後在專案中加入一個 class:SendMailTask。我打算由這個類別來負責發送電子郵件的背景工作。程式碼如下:

public class SendMailTask
{
    private bool _stopping = false;

    public SendMailTask()
    {
        // 從組態檔載入相關參數,例如 SmtpHost、SmtpPort、SenderEmail 等等.
    }

    public void Run()
    {
        var aThread = new Thread(TaskLoop);
        aThread.IsBackground = true;
        aThread.Priority = ThreadPriority.BelowNormal;  // 避免此背景工作拖慢 ASP.NET 處理 HTTP 請求.
        aThread.Start();
    }

    public void Stop()
    {
        _stopping = true;
    }


    private void Log(string msg)
    {
        System.IO.File.AppendAllText(@"C:\Temp\log.txt", msg + Environment.NewLine);
    }

    private void TaskLoop()
    {
        // 設定每一輪工作執行完畢之後要間隔幾分鐘再執行下一輪工作.
        const int LoopIntervalInMinutes = 1000 * 60 * 1;

        Log("TaskLoop on thread ID: " + Thread.CurrentThread.ManagedThreadId.ToString());
        while (!_stopping)
        {
            try
            {
                DoSendMail();
            }
            catch (Exception ex)
            {
                // 發生意外時只記在 log 裡,不拋出 exception,以確保迴圈持續執行.
                Log(ex.ToString());                    
            }
            finally
            {
                // 每一輪工作完成後的延遲.
                System.Threading.Thread.Sleep(LoopIntervalInMinutes);
            }
        }
    }

    private void DoSendMail()
    {
        // 發送 email。這裡只固定輸出一筆文字訊息至 log 檔案,方便觀察測試結果。
        string msg = String.Format("DoSendMail() at {0:yyyy/MM/dd HH:mm:ss}", DateTime.Now);
        Log(msg);
    }
}

呼叫此類別的 Run 方法就會開始執行發送郵件的工作。其中有幾個地方需要特別說明:
  • 發送郵件的背景工作是由一個單獨的執行緒來負責處理。由於我們需要一個長時間執行的專屬執行緒,所以建立執行緒時不透過 thread pool。
  • 把背景執行緒的優先權設定成 BelowNormal,是為了避免影響 ASP.NET 處理 HTTP 請求的效能。
  • 執行於背景執行緒的程式碼必須格外小心處理 exception。我們自行建立的執行緒當中如果有未處理的 exception,將會導致 ASP.NET 應用程式意外終止。
  • 此範例中的 Stop 方法可供外界手動停止背景工作,但實際上不見得會用到。
  • 在 TaskLoop 方法中,每隔一分鐘會呼叫一次 DoSendMail 方法。DoSendMail 方法實際上根本沒有發信,只是把文字訊息輸出至一個 log 檔,方便觀察運行過程。

接著,在 Global.asax 的 Application_Start 方法中建立 SendMailTask 物件,並呼叫其 Run 方法:

public class Global : HttpApplication
{
    void Application_Start(object sender, EventArgs e)
    {
        var task = new SendMailTask();
        task.Run();
    }
}

這個簡單的範例程式就完成了。

把這個應用程式部署到 IIS 中,然後觀察輸出的 log 檔案內容。結果 log.txt 這個檔案根本沒有產生。這是因為 ASP.NET 應用程式必須等到有第一個 HTTP 請求出現才會啟動,也就是說,我們的 Application_Start 事件根本未曾觸發過。

接著開啟瀏覽器,對此 ASP.NET 應用程式發出一個 HTTP 請求(例如瀏覽網站首頁),然後查看 log.txt 檔案。現在應該可以看到 log.txt 檔案了,內容像這樣:

TaskLoop on thread ID: 7
DoSendMail() at 2014/03/02 00:19:17
DoSendMail() at 2014/03/02 00:20:17
DoSendMail() at 2014/03/02 00:21:17
DoSendMail() at 2014/03/02 00:22:17
...(略)

每過一分鐘查看這個 log 檔案,都會多一筆新的 log 訊息。但只維持了 20 分鐘,之後就沒有新的 log 進來了。這表示我們的 SendMailTask 又停掉了。停止的原因是 ASP.NET 應用程式的 app pool 預設於閒置 20 分鐘後自動停止。

也就是說,要讓我們的 ASP.NET 應用程式中的背景工作能夠像 Windows Service 那樣自動啟動且持續運行,還需要 IIS 的幫助。

設定 IIS 8

IIS 8 提供了應用程式持續運行與預先載入的功能,只要幾個簡單的步驟就能設定好。首先必須先確定已經有安裝「應用程式初始化」模組,如下圖:


接著開啟 IIS 管理員,修改你的 ASP.NET 網站所屬的應用程式集區的進階設定,將「啟動模式」從預設的「OnDemand」改為「AlwaysRunning」:


光這樣不夠,還必須修改你的 ASP.NET 網站的進階設定,把「預先載入已啟用」(Preload Enabled)由預設的 False 改為 True。


這樣就行了。

眼見為憑,你可以重啟 IIS,觀察先前的範例是否有建立 log 檔案並輸出訊息。然後,把應用程式集區的閒置逾時時間由原本的 20 分鐘改為 5 分鐘,以便觀察 5 分鐘之後,log 檔案的內容是否仍然持續增加,來確認應用程式的背景工作依然活著。

設定 IIS 7.5

如果是 IIS 7.5 就稍微麻煩一點了,得額外下載兩個擴充模組來安裝才行。

首先,到微軟網站下載 Application Initialization Module for IIS 7.5。注意:此模組安裝完成後可能需要重新啟動。

由於此模組沒有包含視覺化元件,所以安裝完後,IIS 管理員裡面並沒有任何變化。這個視覺化模組可以從以下連結下載:

http://blogs.msdn.com/cfs-file.ashx/__key/communityserver-components-postattachments/00-10-38-83-23/ApplicationInitializationInstaller_5F00_x64.zip

此模組安裝好之後,IIS 管理員裡面就會出現一個新功能:Application Initialization,參考下圖。


雙擊 Application Initialization 圖示之後,會開啟一個對話窗,裡面有兩個頁籤:Application Pool 和 WebSites,分別用來設定應用程集區的 StartMode 以及網站的 Preload 選項。這個部分就不抓圖了。

小結

就我個人而言,碰到需要背景定期執行的需求時,Windows Service 總是最後的選擇,因為無論是開發、部署還是除錯,Windows Service 都比較麻煩。我寧願寫個 Console 程式,然後設定 Windows 排程來定期執行這個命令列程式。現在,從 IIS 7.5 之後,ASP.NET 開發人員又多了一個可以選擇的方案:把長時間執行的背景工作寫在 ASP.NET 程式裡。此作法不論開發、部署、還是除錯都很方便,但也有一些需要特別注意的地方:
  1. 有別於 ASP.NET 用來處理 HTTP 請求的執行緒,我們自行建立的背景執行緒如果有未處理的 exception,將會導致整個 ASP.NET 應用程式異常終止(即使有在 Application_Error 事件中處理錯誤也一樣)。
  2. 通常不能部署在 Web Farm 環境,因為當同一個背景工作在不同機器上同時執行,很容易出問題,例如重複發送電子郵件。
  3. 有數種情況會令 ASP.NET 應用程式的 app domain 被釋放掉(例如 web.config 改動、app pool 自動回收等等),使得背景執行緒也被強迫終止,因而可能有資料遺失的問題。

本文的範例只是為了方便實驗,寫得比較簡陋,沒有處理上述第 3 個問題。如果你想找看看有沒有功能更強、考慮更周延的範例或函式庫,以下列出幾個可以參考的方案:
續集:
參考資料

Post Comments

技術提供:Blogger.