我們怎樣才能在伺服器上使用asp.net定時執行任務而不需要安裝windows service?我們經常需要運行一些維護性的任務或者像發送提醒郵件給使用者這樣的定時任務。這些僅僅通過使用Windows Service就可以完成。Asp.net通常是一個無狀態的提供者,不支援持續運行代碼或者定時執行某段代碼。所以,我們不得不構建自己的windows service來運行那些定時任務。但是在一個共用的託管環境下,我們並不總是有機會部署我們自己的windwos service到我們託管服務提供者的web伺服器上。我們要麼買一個專用的伺服器,當然這是非常昂貴的,要麼就犧牲我們網站的一些功能。然而,運行一個定期執行的任務是一個非常有用的功能,特別是對那些需要發送提醒郵件的使用者、需要維護報表以及運行清理操作的的管理員而言。我將給你展示一種無須使用任何windows
service,僅僅採用asp.net來運行定期任務的方式。
它怎樣工作
首先,我們需要asp.net中的某些“情境”,能夠持續不斷地運行並且給我們一個回調。而IIS上的web伺服器就是一個很不錯的選擇。所以,我們需要從它那裡很“頻繁”地獲得回調,這樣我們可以查看一個任務隊列,並且能夠看到是否有任務需要執行。現在,這裡有一些方式可以為我們獲得對web伺服器的“操作權”:
(1) 當一個頁面被請求
(2) 當一個應用程式被啟動
(3) 當一個應用程式被停止
(4) 當一個會話開啟、結束或者逾時
(5) 當一個快取項目失效
一個頁面被請求是隨機的。如果幾個小時內沒有人訪問你的網站,那麼幾個小時內你都無法完成任何“任務”。另外,一個請求的執行時間是非常短的,並且它本身也需要越快越好。如果你計劃在頁面請求的時候執行“計劃任務”,這樣頁面將會被迫執行很長時間,這將導致一個很糟糕的使用者體驗。所以,選擇在頁面請求的時機做這樣的操作不是一個好的選擇。
一個頁面被請求是隨機的。如果幾個小時內沒有人訪問你的網站,那麼幾個小時內你都無法完成任何“任務”。另外,一個請求的執行時間是非常短的,並且它本身也需要越快越好。如果你計劃在頁面請求的時候執行“計劃任務”,這樣頁面將會被迫執行很長時間,這將導致一個很糟糕的使用者體驗。所以,選擇在頁面請求的時機做這樣的操作不是一個好的選擇。
當一個應該程式啟動時,Global.asax內的Application_Start方法給我們提供了一個回調。所以這是一個開啟後台線程的好地方,後台線程可以永久運行以執行“計劃任務”。然而,當該線程在web伺服器由於零負載而“休息”一會兒的時候,卻可能被隨時“殺死”。
當一個應用程式停止的時候,我們同樣可以從Application_End方法獲得一個回調。但是我們在這裡卻不能做任何事情,因為整個應該程式都已經快要結束運行了。Global.asax裡的Session_Start會在當一個使用者訪問一個需要被執行個體化為新會話的頁面時被觸發。所以這也是一個隨機事件。而我們需要一個能持久且定期啟動並執行“情境”。
一個快取項目的失效可以提供一個時間點或期間。在asp.net中你可以在Cache對象中增加一個實體,並且可以設定一個絕對失效時間,或者設定當其被從緩衝中移除後失效。你可以利用下面的Cache類中的方法來做這些:
public void Insert ( System.String key , System.Object value ,<br /> System.Web.Caching.CacheDependency dependencies ,<br /> System.DateTime absoluteExpiration ,<br /> System.TimeSpan slidingExpiration ,<br /> System.Web.Caching.CacheItemPriority priority ,<br /> System.Web.Caching.CacheItemRemovedCallback onRemoveCallback )
onRemoveCallback是一個方法的委託,該方法在一個快取項目失效時被調用。在該方法中,我們可以做任何我們想做的事情。所以,這是一個定期、持續運行代碼而不需要任何頁面請求的很好的候選。
這意味著,我們可以在一個快取項目失效時類比一個簡單的windows service。
建立快取項目的回調
首先,在Application_Start中,我們需要註冊一個快取項目,並讓它在兩分鐘後失效。請注意,你設定回調的失效時間的最小值是兩分鐘。儘管你可以設定一個更小的值,但它似乎不會工作。出現該問題最大的可能是,asp.net背景工作處理序每兩分鐘才查看一次快取項目。
private const string DummyCacheItemKey = "GagaGuguGigi";</p><p>protected void Application_Start(Object sender, EventArgs e)<br />{<br /> RegisterCacheEntry();<br />}</p><p>private bool RegisterCacheEntry()<br />{<br /> if( null != HttpContext.Current.Cache[ DummyCacheItemKey ] ) return false;</p><p> HttpContext.Current.Cache.Add( DummyCacheItemKey, "Test", null,<br /> DateTime.MaxValue, TimeSpan.FromMinutes(1),<br /> CacheItemPriority.Normal,<br /> new CacheItemRemovedCallback( CacheItemRemovedCallback ) );</p><p> return true;<br />}
該緩衝實體是一個虛設的實體。我們不需要在這裡儲存任何有價值的資訊,因為無論我們在這裡儲存什麼,他們都有可能在應用程式重啟時丟失。另外,我們所需要的只是使該項的頻繁回調。
在回調的內部,我們就可以完成“計劃任務”:
public void CacheItemRemovedCallback( string key,<br /> object value, CacheItemRemovedReason reason)<br />{<br /> Debug.WriteLine("Cache item callback: " + DateTime.Now.ToString() );</p><p> DoWork();<br />}
在快取項目失效時再次儲存快取項目
無論何時快取項目失效,我們都能夠獲得一個回調同時該項將永久地從緩衝中消失。所以,我們將不能再次獲得回調了。為了能提供一個持續的回調,我們需要在下次失效之前重新儲存一個快取項目。這看起來似乎相當容易:我們可以在回呼函數中調用我們上面展示的RegisterCacheEntry方法,可以這麼做嗎?它不會工作!當回調發生,HttpContext已經無法訪問。HttpContext僅僅在一個請求正在被處理的時候才可以被訪問。因為回調發生在web伺服器的幕後,所以這裡沒有請求需要被處理,因而HttpContext對象無法獲得。因此,你也無法從回調中訪問Cache對象。
方案是,我們需要一個簡單的請求。我們可以利用.netFramework中的WebClient類來實現一個對虛擬頁面的“虛擬”訪問。當虛擬頁面被執行,我們可以Hold住HttpContext對象,然後再次註冊一個快取項目的回調。
所以,回調方法作一點修改來發出一個虛擬調用。
public void CacheItemRemovedCallback( string key,<br /> object value, CacheItemRemovedReason reason)<br />{<br /> Debug.WriteLine("Cache item callback: " + DateTime.Now.ToString() );</p><p> HitPage();</p><p> // Do the service works</p><p> DoWork();<br />}
HitPage方法對一個虛擬頁面發出調用:
private const string DummyPageUrl =<br /> "http://localhost/TestCacheTimeout/WebForm1.aspx";</p><p>private void HitPage()<br />{<br /> WebClient client = new WebClient();<br /> client.DownloadData(DummyPageUrl);<br />}無論虛擬頁面在什麼時候被調用,Application_BeginRequest方法都將被調用。在那裡,我們可以核查是否它是一個“虛擬”頁面。
protected void Application_BeginRequest(Object sender, EventArgs e)<br />{<br /> // If the dummy page is hit, then it means we want to add another item</p><p> // in cache</p><p> if( HttpContext.Current.Request.Url.ToString() == DummyPageUrl )<br /> {<br /> // Add the item in cache and when succesful, do the work.</p><p> RegisterCacheEntry();<br /> }<br />}
我們僅僅截獲虛擬頁面的請求,並且讓其他的頁面以他們原來的方式繼續執行。
Web進程重啟時重啟快取項目回調
這裡有很多情況,可能導致web伺服器重啟。例如,如果系統管理員重啟IIS,或者重啟電腦,或者web進程陷入死迴圈(在windows 2003下)。在這樣的情況下,服務將停止運行,直到一個頁面被請求和Application_Start被調用。Application_Start僅僅在當一個頁面第一次被訪問時才會被調用。所以,當web進程被重啟時為了讓“服務”運行起來,我們只能手動調用“虛擬”頁面,或者某人需要訪問你網站的首頁。
一個“滑頭”的方案是:可以把搜尋引擎加入你的網站中。搜尋引擎時常會爬行頁面。因此,它們將訪問你網站的一個網頁,這就可以觸發Application_Start的執行,因此服務將被再次啟動運行。
另一個方案是向某些通訊或可用性監控服務註冊你的網站。有許多關注你網站以及可以檢查你的網站是否正常並且效能是否良好的Web 服務。所有這些服務都需要訪問你網站的頁面然後收集統計資訊。所以,通過註冊這樣的服務,你可以保證你的網站一直“存活”著。
測試可執行任務的類型
讓我們來測試一下,是否我們能夠做一個windowsservice能夠做的一切任務。首先,第一個問題是,我們不能做一個windows service能夠做的所有事情,因為windowsservice運行在一個本地系統賬戶的許可權下。這是一個具有非常高許可權的賬戶,使用這個賬戶你可以在你的系統中做任何事情。然而,asp.net web線程運行在ASPNET賬戶下(windows xp)或者NETWORKSERVICE賬戶下(windows 2003)。這是一個低許可權的賬戶,並且沒有許可權訪問硬碟。為了允許服務向硬碟寫東西,web進程需要被授予對檔案夾的寫入權限。我們都知道關於此的安全問題,所以我將不再詳述細節。
現在,我們將開始測試我們通常利用windowsservice完成的事情:
(1) 向檔案寫東西
(2) 資料庫操作
(3) Web Service調用
(4) MSMQ 操作
(5) Email 發送
讓我們來寫一些測試代碼:
private void DoWork()<br />{<br /> Debug.WriteLine("Begin DoWork...");<br /> Debug.WriteLine("Running as: " +<br /> WindowsIdentity.GetCurrent().Name );</p><p> DoSomeFileWritingStuff();<br /> DoSomeDatabaseOperation();<br /> DoSomeWebserviceCall();<br /> DoSomeMSMQStuff();<br /> DoSomeEmailSendStuff();</p><p> Debug.WriteLine("End DoWork...");<br />}
測試檔案“寫”操作
讓我們來測試一下是否我們真的能夠向檔案內寫東西。在C盤建立一個檔案夾,將其命名為“temp”(如果磁碟的格式是NTFS,允許ASPNET/NETWORKSERVICE賬戶向該檔案夾的寫入權限)。
private void DoSomeFileWritingStuff()<br />{<br /> Debug.WriteLine("Writing to file...");</p><p> try<br /> {<br /> using( StreamWriter writer =<br /> new StreamWriter(@"c:\temp\Cachecallback.txt", true) )<br /> {<br /> writer.WriteLine("Cache Callback: {0}", DateTime.Now);<br /> writer.Close();<br /> }<br /> }<br /> catch( Exception x )<br /> {<br /> Debug.WriteLine( x );<br /> }</p><p> Debug.WriteLine("File write successful");<br />}
開啟該檔案,然後你應該看到這樣的資訊:
Cache Callback: 10/17/2005 2:50:00 PM<br />Cache Callback: 10/17/2005 2:52:00 PM<br />Cache Callback: 10/17/2005 2:54:00 PM<br />Cache Callback: 10/17/2005 2:56:00 PM<br />Cache Callback: 10/17/2005 2:58:00 PM<br />Cache Callback: 10/17/2005 3:00:00 PM
測試資料庫的可串連性
在你的“tempdb”資料庫中運行下面的代碼(也可以自己建資料庫測試)
IF EXISTS (SELECT * FROM dbo.sysobjects WHERE id =<br /> object_id(N'[dbo].[ASPNETServiceLog]') AND<br /> OBJECTPROPERTY(id, N'IsUserTable') = 1)<br />DROP TABLE [dbo].[ASPNETServiceLog]<br />GO</p><p>CREATE TABLE [dbo].[ASPNETServiceLog] (<br /> [Mesage] [varchar] (1000)<br /> COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL ,<br /> [DateTime] [datetime] NOT NULL<br />) ON [PRIMARY]<br />GO
上面的代碼將建立一個名為ASPNETServiceLog的表。記住,因為該表建立於tempdb中,所以該表在SQL Server重啟的時候將消失。
接下來,為ASPNET/NETWORKSERVICE賬戶授予tempdb資料庫的db_datawriter許可權。另外,你可以定義更多特殊的許可權,並且只允許往表中寫入權限。
現在,寫下測試方法:
private void DoSomeDatabaseOperation()<br />{<br /> Debug.WriteLine("Connecting to database...");</p><p> using( SqlConnection con = new SqlConnection("Data Source" +<br /> "=(local);Initial Catalog=tempdb;Integrated Security=SSPI;") )<br /> {<br /> con.Open();</p><p> using( SqlCommand cmd = new SqlCommand( "INSERT" +<br /> " INTO ASPNETServiceLog VALUES" +<br /> " (@Message, @DateTime)", con ) )<br /> {<br /> cmd.Parameters.Add("@Message", SqlDbType.VarChar, 1024).Value =<br /> "Hi I'm the ASP NET Service";<br /> cmd.Parameters.Add("@DateTime", SqlDbType.DateTime).Value =<br /> DateTime.Now;</p><p> cmd.ExecuteNonQuery();<br /> }</p><p> con.Close();<br /> }</p><p> Debug.WriteLine("Database connection successful");<br />}
這將在log表中產生一些記錄,你可以測試來確保“服務”的執行是否有延遲。你應該會再每兩分鐘獲得一行資料。
測試郵件的分發
對運行一個windows service最基本的需求是定期發送寄件提醒,狀態報表等等。所以,測試是否可以像windows service一樣發送email很重要:
private void DoSomeEmailSendStuff()<br />{<br /> try<br /> {<br /> MailMessage msg = new MailMessage();<br /> msg.From = "abc@cde.fgh";<br /> msg.To = "ijk@lmn.opq";<br /> msg.Subject = "Reminder: " + DateTime.Now.ToString();<br /> msg.Body = "This is a server generated message";</p><p> SmtpMail.Send( msg );<br /> }<br /> catch( Exception x )<br /> {<br /> Debug.WriteLine( x );<br /> }<br />}
請將From和To 修改為某些有效地址,並且你應該每兩分鐘就可以收到一次寄件提醒。
測試MSMQ
讓我們寫一個簡單的方法來測試是否我們可以從asp.net直接存取MSMQ:
private void DoSomeMSMQStuff()<br />{<br /> using( MessageQueue queue = new MessageQueue(MSMQ_NAME) )<br /> {<br /> queue.Send(DateTime.Now);<br /> queue.Close();<br /> }<br />}
另外,你可以調用隊列的Receive方法來解析隊列中需要被處理的訊息。
這裡,有一個你必須記住的問題是,不要訂閱隊列的Receive事件。因為線程可能隨時會被殺死,並且web伺服器可能隨時會被重啟,一個持續阻塞的Receive將不能正常地工作。另外,如果你調用BeginReceive方法同時阻塞代碼的執行直到一個訊息到達,服務將被卡住然後其他的代碼將不會再運行。所以,在這種情況下,你將不得不調用Receive方法來解析訊息。
擴充系統功能
Asp.net服務可以被用來擴充那些可插拔的任務。你可以從web頁面中引入作業排隊,讓這種服務定期執行。例如,你可以將作業隊列放入一個快取項目,讓“服務”來選擇任務然後執行它。採用這種方式,你可以在你的asp.net項目中實現一個簡單的任務處理系統。
讓我們實現一個簡單的Job類,它包含了一個任務執行的資訊。
public class Job<br />{<br /> public string Title;<br /> public DateTime ExecutionTime;</p><p> public Job( string title, DateTime executionTime )<br /> {<br /> this.Title = title;<br /> this.ExecutionTime = executionTime;<br /> }</p><p> public void Execute()<br /> {<br /> Debug.WriteLine("Executing job at: " + DateTime.Now );<br /> Debug.WriteLine(this.Title);<br /> Debug.WriteLine(this.ExecutionTime);<br /> }<br />}在一個簡單的aspx頁面上,我們將一個任務排入一個定義在Global.Asax中的名為_JobQueue的ArrayList中。
Job newJob = new Job( "A job queued at: " + DateTime.Now,<br /> DateTime.Now.AddMinutes(4) );<br />lock( Global._JobQueue )<br />{<br /> Global._JobQueue.Add( newJob );<br />}所以,被排入隊列中的任務將在4分鐘之後被執行。該服務的代碼每兩分鐘執行一次,它會檢查任務隊列,是否有任何逾期且需要被執行的任務。如果有任何的任務在等待,它將被從隊列中移除並執行。服務代碼有一個額外的方法,叫做ExecuteQueuedJobs。該方法做定期任務的執行:
private void ExecuteQueuedJobs()<br />{<br /> ArrayList jobs = new ArrayList();</p><p> // Collect which jobs are overdue</p><p> foreach( Job job in _JobQueue )<br /> {<br /> if( job.ExecutionTime <= DateTime.Now )<br /> jobs.Add( job );<br /> }</p><p> // Execute the jobs that are overdue</p><p> foreach( Job job in jobs )<br /> {<br /> lock( _JobQueue )<br /> {<br /> _JobQueue.Remove( job );<br /> }</p><p> job.Execute();<br /> }<br />}
不要忘記鎖住靜態“任務集合”,因為asp.net是多線程的。並且頁面會在不同的線程上執行,所以同時往任務隊列中寫是很有可能的。