我们怎样才能在服务器上使用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 ,
- System.Web.Caching.CacheDependency dependencies ,
- System.DateTime absoluteExpiration ,
- System.TimeSpan slidingExpiration ,
- System.Web.Caching.CacheItemPriority priority ,
- System.Web.Caching.CacheItemRemovedCallback onRemoveCallback )
onRemoveCallback
是一个方法的委托,该方法在一个缓存项失效时被调用。在该方法中,我们可以做任何我们想做的事情。所以,这是一个定期、持续运行代码而不需要任何页面请求的很好的候选。
这意味着,我们可以在一个缓存项失效时模拟一个简单的windows service。
创建缓存项的回调
首先,在Application_Start中,我们需要注册一个缓存项,并让它在两分钟后失效。请注意,你设置回调的失效时间的最小值是两分钟。尽管你可以设置一个更小的值,但它似乎不会工作。出现该问题最大的可能是,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;
- }
该缓存实体是一个虚设的实体。我们不需要在这里存储任何有价值的信息,因为无论我们在这里存储什么,他们都有可能在应用程序重启时丢失。另外,我们所需要的只是使该项的频繁回调。
在回调的内部,我们就可以完成“计划任务”:
- public void CacheItemRemovedCallback( string key,
- object value, CacheItemRemovedReason reason)
- {
- Debug.WriteLine("Cache item callback: " + DateTime.Now.ToString() );
- DoWork();
- }
在缓存项失效时再次存储缓存项
无论何时缓存项失效,我们都能够获得一个回调同时该项将永久地从缓存中消失。所以,我们将不能再次获得回调了。为了能提供一个持续的回调,我们需要在下次失效之前重新存储一个缓存项。这看起来似乎相当容易:我们可以在回调函数中调用我们上面展示的RegisterCacheEntry方法,可以这么做吗?它不会工作!当回调发生,HttpContext已经无法访问。HttpContext仅仅在一个请求正在被处理的时候才可以被访问。因为回调发生在web服务器的幕后,所以这里没有请求需要被处理,因而HttpContext对象无法获得。因此,你也无法从回调中访问Cache对象。
方案是,我们需要一个简单的请求。我们可以利用.netFramework中的WebClient类来实现一个对虚拟页面的“虚拟”访问。当虚拟页面被执行,我们可以Hold住HttpContext对象,然后再次注册一个缓存项的回调。
所以,回调方法作一点修改来发出一个虚拟调用。
- public void CacheItemRemovedCallback( string key,
- object value, CacheItemRemovedReason reason)
- {
- Debug.WriteLine("Cache item callback: " + DateTime.Now.ToString() );
- HitPage();
-
- DoWork();
- }
HitPage方法对一个虚拟页面发出调用:
- private const string DummyPageUrl =
- "http://localhost/TestCacheTimeout/WebForm1.aspx";
- private void HitPage()
- {
- WebClient client = new WebClient();
- client.DownloadData(DummyPageUrl);
- }
无论虚拟页面在什么时候被调用,Application_BeginRequest方法都将被调用。在那里,我们可以核查是否它是一个“虚拟”页面。
- protected void Application_BeginRequest(Object sender, EventArgs e)
- {
-
-
- if( HttpContext.Current.Request.Url.ToString() == DummyPageUrl )
- {
-
- RegisterCacheEntry();
- }
- }
我们仅仅截获虚拟页面的请求,并且让其他的页面以他们原来的方式继续执行。
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()
- {
- Debug.WriteLine("Begin DoWork...");
- Debug.WriteLine("Running as: " +
- WindowsIdentity.GetCurrent().Name );
- DoSomeFileWritingStuff();
- DoSomeDatabaseOperation();
- DoSomeWebserviceCall();
- DoSomeMSMQStuff();
- DoSomeEmailSendStuff();
- Debug.WriteLine("End DoWork...");
- }
测试文件“写”操作
让我们来测试一下是否我们真的能够向文件内写东西。在C盘创建一个文件夹,将其命名为“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
测试数据库的可连接性
在你的“tempdb”数据库中运行下面的代码(也可以自己建数据库测试)
- 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
上面的代码将创建一个名为ASPNETServiceLog的表。记住,因为该表创建于tempdb中,所以该表在SQL Server重启的时候将消失。
接下来,为ASPNET/NETWORKSERVICE账户授予tempdb数据库的db_datawriter权限。另外,你可以定义更多特殊的权限,并且只允许往表中写权限。
现在,写下测试方法:
- 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表中产生一些记录,你可以测试来确保“服务”的执行是否有延迟。你应该会再每两分钟获得一行数据。
测试邮件的分发
对运行一个windows service最基本的需求是定期发送邮件提醒,状态报告等等。所以,测试是否可以像windows service一样发送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();
- }
- }
另外,你可以调用队列的Receive方法来解析队列中需要被处理的消息。
这里,有一个你必须记住的问题是,不要订阅队列的Receive事件。因为线程可能随时会被杀死,并且web服务器可能随时会被重启,一个持续阻塞的Receive将不能正常地工作。另外,如果你调用BeginReceive方法同时阻塞代码的执行直到一个消息到达,服务将被卡住然后其他的代码将不会再运行。所以,在这种情况下,你将不得不调用Receive方法来解析消息。
扩展系统功能
Asp.net服务可以被用来扩展那些可插拔的任务。你可以从web页面中引入作业排队,让这种服务定期执行。例如,你可以将作业队列放入一个缓存项,让“服务”来选择任务然后执行它。采用这种方式,你可以在你的asp.net项目中实现一个简单的任务处理系统。
让我们实现一个简单的Job类,它包含了一个任务执行的信息。
- 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分钟之后被执行。该服务的代码每两分钟执行一次,它会检查任务队列,是否有任何逾期且需要被执行的任务。如果有任何的任务在等待,它将被从队列中移除并执行。服务代码有一个额外的方法,叫做ExecuteQueuedJobs。该方法做定期任务的执行:
- 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();
- }
- }
不要忘记锁住静态的“任务集合”,因为asp.net是多线程的。并且页面会在不同的线程上执行,所以同时往任务队列中写是很有可能的。