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