Блог программиста Еремина Вячеслава Викторовича
(ASP.NET) ASP.NET (2009 год)

Выполнение периодических задач в ASP.NET

Сайты на ASP.NET обычно основаны на множестве автономных заданий SQL-сервера. По крайней мере в тех сайтах, что делал я - таких заданий всегда десятки:


При работе в микрософтовской идеологии задания стали настолько необходимыми, что именно по заданиям проходит граница платный/бесплатный MS SQL Server. Бесплатный SQL Express отличается от платного MS SQL в основном именно отсутствием поддержки заданий SQLJOB. Все остальное там на порядок менее востребовано. За 4ГБ базы трудно перевалить - для этого надо очень мощный сайт иметь, секционирование требует множества физических отдельных дисков и невозможно на обычных убогих кампутерах с 4-мя дисками, хинты для доступа к индексам материализованных вьюшек и прочее - это еще более редко применяемые на практике возможности.


Итак, можно ли работать без SQL-заданий в ASP.NET-сайтах?

Ответ нет - в целом это невозможно, ибо есть время жизни приложения ASP.NET сайта - обычно около 24 часов. И никакой процесс сайта не существует дольше этого времени.




Но задачи, выполняемые через короткие промежутки времени (и результаты которых атомарны) - выполнять без SQLJOB на сайтах ASP.NET возможно. Надо только соблюсти эти требования атомарности. Те периодический процесс может быть прерван в любой момент времени перезагрузкой домена приложения. Следовательно, все свои шаги он должен помечать в базе - те он начал транзакцию (но не закончил). И закончил. Если выполнение было прервано перезагрузкой домена приложения, то незаконченный шаг повторяется. Надо учитывать также возможность блокировки SQL-обьектов в результате прерванной транзакции.

Если эти требования соблюдены - те периодические задачи выполняются во время меньше времени жизни домена приложения и они атомарны, то эти задачи можно выполнить в ASP.NET следующим кодом в Global.asax:


   1:  <%@ Application Language="VB" %>
   2:   
   3:  <script RunAt="server">
   4:   
   5:      Sub Application_Start(ByVal sender As Object, ByVal e As EventArgs)
   6:          'теперь инициализируем поток чистки неактивированных юзеров
   7:          '
   8:          Dim ClearJobThread As System.Threading.Thread = New System.Threading.Thread(AddressOf ClearJobSub)
   9:          ClearJobThread.Name = "ClearJobThread (Started " & Now.ToString & ")"
  10:          If System.Configuration.ConfigurationManager.AppSettings("ClearJobThread") Then ClearJobThread.Start()
  11:          Application("ClearJobThread") = ClearJobThread
  12:      End Sub
  13:   
  14:      Sub ClearJobSub()
  15:          Dim X As New VBNET2009.ClearInactiveUsers
  16:          While True
  17:              System.Threading.Thread.Sleep(cint(Application("ClearJobInterval")) * 3600000)
  18:              'диагностика фонового процесса
  19:              Dim ClearJobThread As System.Threading.Thread = CType(Application("ClearJobThread"), System.Threading.Thread)
  20:              My.Log.WriteEntry(ClearJobThread.Name & " : started at " & Now.ToString)
  21:              X.GO()
  22:          End While
  23:      End Sub

В реальности каждый мой реальный сайт помимо собственно заданий в SQL еще запускает десятки периодических процессов, инициированных в global.asax отдельными потоками от основного потока домена приложения - и это работает превосходно.




В заключение я покажу как сделать долгоиграющие задачи, которые нереализуемы с помощью задач, запускаемых в пямяти домена ASP.NET-приложения.

Постановка задачи на эту задачу такая - требуется стимулировать активность зарегистрированных на сайте пользователей и разослать им уведомление, что они полгода не посещали сайт. Потом выслать повторное уведомление и удалить их аккаунт.

Я покажу как решить эту задачку в том случае, когда ASP.NET-юзера хранятся в стандартной табле aspnet_Membership. Тогда надо создать небольшую дополнительную табличку, в которой мы будем вести учет отосланных сообщений первый-второй раз.


   1:  CREATE TABLE [dbo].[Reminder](
   2:      [id] [uniqueidentifier] NOT NULL,
   3:      [First] [bit] NULL,
   4:      [FirstMailed] [nvarchar](max) NULL,
   5:      [Second] [bit] NULL,
   6:      [SecondMailed] [nvarchar](max) NULL,
   7:      [Remove] [bit] NULL
   8:  ) ON [PRIMARY]

Далее создадим процедурку, которая будет определять пользователей, которым надо разослать напоминания

   1:  Alter procedure ReminderMail
   2:  as
   3:  --скопировали все новые логины в таблу Reminder
   4:  insert dbo.Reminder(Id)
   5:  select UserId from dbo.aspnet_Membership 
   6:  left join reminder on aspnet_Membership.UserId=dbo.Reminder.id
   7:  where dbo.Reminder.id is null
   8:   
   9:  --отметили кто не заходил на сайт 180 дней
  10:  update dbo.Reminder
  11:  set First=1 from dbo.Reminder
  12:  join aspnet_Membership on aspnet_Membership.UserId=dbo.Reminder.id
  13:  where DATEDIFF(day, dateadd(day,180,LastLoginDate),GETDATE())>0
  14:   
  15:  --280 дней
  16:  update dbo.Reminder
  17:  set Second=1 from dbo.Reminder
  18:  join aspnet_Membership on aspnet_Membership.UserId=dbo.Reminder.id
  19:  where DATEDIFF(day, dateadd(day,280,LastLoginDate),GETDATE())>0
  20:   
  21:  --300 дней
  22:  update dbo.Reminder
  23:  set Remove=1 from dbo.Reminder
  24:  join aspnet_Membership on aspnet_Membership.UserId=dbo.Reminder.id
  25:  where DATEDIFF(day, dateadd(day,300,LastLoginDate),GETDATE())>0
  26:   
  27:  --отметили кто заходил чтобы не удалить их
  28:  update dbo.Reminder
  29:  set First=NULL, FirstMailed=NULL, Second=NULL, SecondMailed= NULL, Remove=NULL from dbo.Reminder
  30:  join aspnet_Membership on aspnet_Membership.UserId=dbo.Reminder.id
  31:  where DATEDIFF(day, dateadd(day,180,LastLoginDate),GETDATE())<0
  32:   
  33:  update dbo.Reminder
  34:  Set FirstMailed=dbo.WebRequest('http://xxx.ru/SendMail.ashx?id='+cast(id as nvarchar(36))+'&First='+ISNULL(cast(First as nvarchar),'0')+'&Second='+ISNULL(cast(Second as nvarchar),'0'))
  35:  where First=1 and FirstMailed is NULL
  36:   
  37:  update dbo.Reminder
  38:  Set SecondMailed= dbo.WebRequest('http://xxx.ru/SendMail.ashx?id='+cast(id as nvarchar(36))+'&First='+ISNULL(cast(First as nvarchar),'0')+'&Second='+ISNULL(cast(Second as nvarchar),'0'))
  39:  where Second=1 and SecondMailed is NULL
  40:   

Теперь эту процедурку надо просто запустить в задании:



Для того, чтобы довести эту задачку до конца - покажу еще Sql-Сlr-Assembly WebRequest, которую вызывается в этой процедуре.


   1:  Imports System
   2:  Imports System.Data
   3:  Imports System.Data.SqlClient
   4:  Imports System.Data.SqlTypes
   5:  Imports Microsoft.SqlServer.Server
   6:   
   7:  'ALTER DATABASE airts SET TRUSTWORTHY ON
   8:  'CREATE ASSEMBLY [WebRequest] FROM 0x4D5A90000300000004000000FF... WITH PERMISSION_SET = EXTERNAL_ACCESS
   9:  Partial Public Class UserDefinedFunctions
  10:      <Microsoft.SqlServer.Server.SqlFunction()> _
  11:      Public Shared Function WebRequest(ByVal URL As String) As SqlString
  12:          Dim HTML As String
  13:          Try
  14:              HTML = GetRequest(URL)
  15:          Catch ex As Exception
  16:              Return "Error: " & ex.Message
  17:          End Try
  19:          Return HTML
  20:      End Function
  21:   
  22:      Public Shared Function GetRequest(ByVal URL As String) As String
  23:          Try
  24:              'запрос по HTTP 
  25:              Dim Request As Net.HttpWebRequest = CType(System.Net.WebRequest.Create(URL), Net.HttpWebRequest)
  26:              Request.AllowAutoRedirect = True
  27:              Dim Response As Net.WebResponse = Request.GetResponse()
  28:              Dim Reader As New System.IO.StreamReader(Response.GetResponseStream(), System.Text.Encoding.Default)
  29:              Dim HTML As String = Reader.ReadToEnd
  30:              Reader.Close()
  31:              Return HTML
  32:          Catch ex As Exception
  33:              Return "Error: " & ex.Message
  34:          End Try
  35:      End Function
  36:  End Class

Эта сборка вызывает хандлер на сайте, который формирует тело напоминания (и при необхождимости выполняет другие действия). Хандлер выглядит примерно вот так:


   1:  <%@ WebHandler Language="VB" Class="SendMail" %>
   2:   
   3:  Imports System
   4:  Imports System.Web
   5:   
   6:  Public Class SendMail : Implements IHttpHandler
   7:      
   8:      Public Sub ProcessRequest(ByVal context As HttpContext) Implements IHttpHandler.ProcessRequest
   9:          Try
  10:              'этот  хандлер разрешено вызывать только с резрешенных IP-адресов SendMailAllowIP
  11:              Dim AllowIP As String() = System.Configuration.ConfigurationManager.AppSettings("SendMailAllowIP").Split(";")
  12:              For Each OneAllowIp As String In AllowIP
  13:                  If context.Request.UserHostAddress = OneAllowIp Then GoTo Start
  14:              Next
  15:              context.Response.ContentType = "text/plain"
  16:              context.Response.Write("ip deny")
  17:              Exit Sub
  18:              '
  19:  Start:
  20:              If context.Request.QueryString("id") IsNot Nothing Then
  21:                  Dim User1 As MembershipUser = Membership.GetUser(New Guid(context.Request.QueryString("Id")))
  22:                  If User1 IsNot Nothing Then
  23:                      Dim MyTypedUserProfile As New ProfileCommon
  24:                      MyTypedUserProfile = ProfileBase.Create(User1.UserName)
  25:                      If MyTypedUserProfile Is Nothing Then
  26:                          Dim LastDays As String
  27:                          If context.Request.QueryString("Second") = 1 Then
  28:                              LastDays = "280"
  29:                          ElseIf context.Request.QueryString("First") = 1 Then
  30:                              LastDays = "180"
  31:                          End If
  32:                          '
  33:                          Mail2.SendMail(User1.UserName, "Напоминание", _
  34:                          "Здравствуйте, уважаемый ......")
  43:                          '
  44:                          context.Response.ContentType = "text/plain"
  45:                          context.Response.Write("OK")
  46:                      Else
  47:                          context.Response.ContentType = "text/plain"
  48:                          context.Response.Write("Profile is nothing")
  49:                      End If
  50:                  Else
  51:                      context.Response.ContentType = "text/plain"
  52:                      context.Response.Write("User is nothing")
  53:                  End If
  54:              Else
  55:                  context.Response.ContentType = "text/plain"
  56:                  context.Response.Write("ID is nothing")
  57:              End If
  58:          Catch ex As Exception
  59:              context.Response.ContentType = "text/plain"
  60:              context.Response.Write(ex.Message)
  61:          End Try
  62:   
  63:      End Sub
  64:   
  65:      Public ReadOnly Property IsReusable() As Boolean Implements IHttpHandler.IsReusable
  66:          Get
  67:              Return False
  68:          End Get
  69:      End Property
  70:   
  71:  End Class

Обратите внимание - что касается долгоиграющих заданий, не релеализуемых в IIS, то тут рассмотрено построение статических заданий. Вы создаете задания (даже когда их десятки) один раз ручками и они существуют в течении всего срока жизни вашего приложения. Другое дело - задания динамические - которые создаются при каждом реквесте вашей странички (и тоже могут существовать долго) - построение таких заданий я описал на страничке Реализация таймаута на динамически создаваемых SQL JOB, вызывающих SQL CLR сборку.



Комментарии к этой страничке ( )
ссылка на эту страничку: http://www.vb-net.ru/PeriodicAspNetJob/index.htm
<Назад>  <На главную>  <В раздел ASP>  <В раздел NET>  <В раздел SQL>  <В раздел Разное>  <Написать автору>  < Поблагодарить>