Выполнение периодических задач в ASP.NET
Сайты на ASP.NET обычно основаны на множестве автономных заданий SQL-сервера. По крайней мере в тех сайтах, что делал я - таких заданий всегда десятки:- Кеширование всех тяжелых запросов в SQL. Например всего при 50 тысячах пользователей (если дату рождения пользователя хранить в базе) - то вот такой поиск по дате рождения, как у меня в http://search.votpusk.ru/ - будет выполняться часами. Понятно, что пользователь сайта готов ждать от силы секунду или две - потом он просто уходит с сайта.
В заданиях выполняют кеширование всех тяжелых запросов сайта. В данном каждый час строится кеш зарегистрированных пользователей. Их свойства из встроенного профиля вытаскиваются вот такой сборкой, запущенной вот такой процедурой вот в таком задании - все это преобразуется в реляционную структуру, индексируется и потом уже по этому кешу делаются запросы.
Таблицы кешей сайта размещаются в другой базе, чтобы они не бекапились по тому же расписанию, что бекапятся основные базы сайта.
- В заданиях строят и другие кеши для сайта. Например если сайт в плашке имеет комбобокс с какими-то параметрами отбора - нет смысла генерить эту плашку при каждом реквесте странички. Есть смысл сгенерить эту плашку раз в день в задании и просто включить готовый HTML в шапку сайта директивой <!--#include
- На серверной стороне в заданиях выполняются все периодические импорты данных на сайт. Например импорты прайс-листов или импорт метерологических данных, выполняемый вот этой моей сборкой - SQL-Client_for_remote_XML-WebService - клиент meteonova.ru.
- В заданиях на SQL-сервере выполняются все экспорты данных из базы, например Выгрузка базы в XML
- В заданиях делаются все отборы типа - лучшее фото дня, самый активный пользователь дня, совет дня и тому подобные отборы. Например, если вы посмотрите на любую мою страничку, например http://foto.votpusk.ru/ то поймете, что абсолютно все фрагменты этой странички сформированы заданиями.
- В заданиях генерят RSS-ленты подписчиками и почтовые рассылки пользователям сайта всяких новостей и анонсов.
- В заданиях выполняется видеоконвертация, как на http://video.votpusk.ru/ (если конечно нет отдельного выделенного сервера видеоконвертации).
- В заданиях чистят устаревшие объявления о продаже, поиске попутчиков, всякие сообщения юзерам от администрации - срок действия которых установлен с момента публикации. например какое-то предложение или сообщение действует три дня.
- В заданиях формируют всевозможные отчеты для администрации сайта. Например средняя загрузка процессора SQL-сервера в течении суток и по дням недели.
- В заданиях также делаются все бекапы изменяющихся баз сайта и делаются перестроения статистики индексов.
- Существует целых класс специальных сайтов, в ядре основанных на создании динамически по мере необходимости кодом сайта. У меня есть даже свой Web-интерфейс создания SqlJob (взамен Win-интерфейса MSSMS).
При работе в микрософтовской идеологии задания стали настолько необходимыми, что именно по заданиям проходит граница платный/бесплатный 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 сборку.
Also please see alternatives to this technology:
- Use Hangfire to start periodic task
- VB.NET ASP.NET Core 3.1 Quartz services Project Template for VS2019, download from [Github], [VisualStudio Marketplace]
|