Урок 23. Создание кастомных команд для серверной части
В уроке продолжим знакомиться с созданием кастомных библиотек для WT-приложений и создадим команду для отправки e-mail на стороне сервера.
Если хотите начать практику с этого урока, то вам необходимо развернуть учебный проект по инструкции в статье Разворачивание проекта.
При разворачивании проекта используйте backup базы данных, который можете найти в архиве из раздела Ответы прошлого урока. Скопируйте папки Forms, Workflow и Patterns в папку с развернутым проектом, например, в папку D:\WT\Projects\Template\Projects\1. Template.
Инструкция по подключению шаблонов находится по ссылке.
Подготовка
В этом уроке нам понадобятся бинарники, которые мы скачивали при разворачивании серверной части. В уроке так же будем работать с отдельным каталогом для штатных библиотек.
Создание проекта
В папке \Template\Projects\1. Template создадим папку Engine, в которой будут храниться исходники кастомных объектов для серверной части.
Запустите Visual Studio 2022 и создайте новый проект из шаблона Class library для .NET или .NET Standart:

В прошлом уроке мы подробно рассмотрели процесс создания нового приложения из шаблона Class library для .NET.
Сборка проекта
Откроем свойства проекта, вызвав контекстное меню и выбрав пункт Properties. Или нажав комбинацию клавиш Alt+Enter.
На вкладке Build->Events необходимо прописать команду, которая будет выполняться после сборки решения:

В поле Post-build event command line пропишем команду копирования собранного dll-файла из папки проекта в папку, в которую установили серверную часть:
copy "$(ProjectDir)$(OutDir)$(TargetName).dll" "D:\WorkflowEngine\Template"
По умолчанию Visual Studio собирает проект в папку:
\Template\Projects\1. Template\Engine\TemplateEngine\TemplateEngine\bin\Debug\netcoreapp3.1
Давайте пересоберем проект, для этого в контекстном меню выберем пункт Build или Rebuild:

Проверьте, что файл TemplateEngine.dll скопировался в папку с установленной серверной частью.
Отладка проекта
Чтобы запускать сервер из Visual Studio напрямую, в свойствах проекта на вкладке Debug создайте профиль запуска проекта, как мы делали в прошлом уроке. В профиле укажем следующие настройки:

В поле Executable прописываем путь до exe-файла самого web-приложения, размещенного в папке развернутого сервера. В поле Command line argument прописываем --console
аналогично аргументу из файла _start.bat, который используем для запуска серверной части. А в поле Working directory укажем путь до папки, в которой развернут сервер учебного проекта.
Теперь мы можем запустить приложение, нажав клавишу F5 или кнопку на панели инструментов:

Перед запуском проекта не забудьте остановить серверную часть приложения, если она у вас запущена.
В проекте появился новый файл launchSettings.json, в котором будут храниться настройки профилей запуска приложения:

Кастомная команда
В этом уроке создадим кастомную команду для отправки e-mail на стороне сервера.
Первым делом необходимо описать саму команду, чтобы иметь представление, какие элементы нам понадобятся.
Добавим в серверный xml-файл тэг <Commands>
, который можно разместить перед тэгом <SqlQueries>
. Опишем в нем команду для отправки клиентам писем с поздравлением с днем рождения:
<Command Name="HappyBirthdayEmailSendCommand" Type="EmailSendCommand" Assembly="TemplateEngine">
<EmailSettingsSqlQuery>
<Text>
SELECT
smtp_server AS "SmtpServerAddress",
smtp_port AS "SmtpServerPort",
ssl AS "SSL",
email_address AS "AuthorEmailAddress",
email_password AS "AuthorEmailPassword"
FROM template.settings
LIMIT 1;
</Text>
</EmailSettingsSqlQuery>
<MessageMailingSqlQuery>
<Text>
SELECT
'С днём рождения!' AS "Subject",
'Уважаемый(ая), ' || C.title || '! Поздравляем Вас с днем рождения!' AS "Text",
C.email AS "Email"
FROM
template.client C
WHERE
C.email NOTNULL AND
date_part('month'::text, C.date_of_birth) = date_part('month'::text, CURRENT_DATE) AND
date_part('day'::text, C.date_of_birth) = date_part('day'::text, CURRENT_DATE);
</Text>
</MessageMailingSqlQuery>
</Command>
Здесь стоит обратить внимание на несколько моментов.
Во-первых, у тэга <Command>
есть атрибут Assembly
. В нем указывается имя библиотеки, в которой описан класс команды. Так как это кастомная команда, то в атрибуте указано имя кастомной сборки TemplateEngine, которую мы создали ранее.
Во-вторых, в атрибуте Type
указывается имя класса, который описывает логику выполнения команды.
Для отправки писем нам понадобятся адреса клиентов и настройки почтового агента. Поэтому в команде описаны вложенные тэги <EmailSettingsSqlQuery>
и <MessageMailingSqlQuery>
, которые будут содержать sql-запросы на получение необходимых данных. Первый будет предоставлять настройки почтового агента, а второй будет получать из базы адреса клиентов и текст сообщений.
Подключение библиотеки
Подключим необходимые библиотеки. Для этого в контекстном меню проекта выберем пункт Add -> Project Reference..., в открывшемся окне на вкладке Browse, где по кнопке Browse... откроем диалоговое окно выбора файла и в папке со штатными бинарниками серверной части найдем файлы WorkflowEngine.dll и Common.dll и добавим их.

Создание классов
Удалим файл Class1.cs и создадим папку Commands, в которую добавим новые классы EmailSendCommand и EmailSendCommandExecutor:

При старте сервера для каждой команды из серверного xml-файла создается один экземпляр класса EmailSendCommand. А каждый раз при обращении к команде для ее выполнения создается экземпляр EmailSendCommandExecutor.
Базовый код кастомной команды EmailSendCommand.cs имеет вид:
using System.Xml;
namespace WorkflowEngine
{
public class EmailSendCommand : AbstractCommand
{
public override void Init(XmlNode node, IWorkflowType workflowType)
{
base.Init(node, workflowType);
}
public override ICommandExecutor CreateExecutor()
{
return new EmailSendCommandExecutor(this);
}
}
}
Метод void Init(XmlNode, IWorkflowType)
будет выполняться в момент старта сервера и парсинга серверного xml файла. В качестве первого параметра XmlNode node
метод принимает текст описания команды на xml, из которого мы и будем получать необходимые данные. Второй параметр метода IWorkflowType workflowType
содержит процесс, в рамках которого запущен сервер.
Обратите внимание, что для кастомных команд в качестве namespace
указывается WorkflowEngine - это позволяет обращаться к публичным классам штатных библиотек платформы без использования директивы using
для импорта типов.
Базовый код класса исполняемой части имеет вид:
using System.Threading.Tasks;
namespace WorkflowEngine
{
public class EmailSendCommandExecutor : AbstractCommandExecutor
{
private readonly EmailSendCommand _command;
public EmailSendCommandExecutor(EmailSendCommand command) : base(command)
{
_command = command;
}
public override async Task Execute(IParameterized parameters)
{
}
}
}
В конструкторе сохраняем контекст команды. Метод Task Execute(IParameterized)
вызывается в момент выполнения команды. В этом методе будет описываться вся бизнес-логика.
Парсинг xml-кода
Перейдем в файл EmailSendCommand.cs и займемся реализацией метода инициализации команды.
Вспомним описание нашей команды из серверного xml-файла:
Для начала создадим константы с именами вложенных тэгов и текстом сообщения об ошибке:
private const string TEXT_ELEMENT = "Text";
private const string EMAIL_SETTINGS_SQL_QUERY_ELEMENT = "EmailSettingsSqlQuery";
private const string MESSAGE_MAILING_SQL_QUERY_ELEMENT = "MessageMailingSqlQuery";
Создадим два свойства, в которые будем сохранять прочитанные значения тэгов:
public string EmailSettingsSqlQueryText { get; private set; }
public string MessageMailingSqlQueryText { get; private set; }
Теперь можем в методе Init() прописать код для получения значений тэгов:
EmailSettingsSqlQueryText = XmlParser.GetRequiredElementValue<string>(
node, $"{EMAIL_SETTINGS_SQL_QUERY_ELEMENT}/{TEXT_ELEMENT}", Name, this);
MessageMailingSqlQueryText = XmlParser.GetRequiredElementValue<string>(
node, $"{MESSAGE_MAILING_SQL_QUERY_ELEMENT}/{TEXT_ELEMENT}", Name, this);
Значения тэгов <EmailSettingsSqlQuery>
и <MessageMailingSqlQuery>
по сути просто строки, поэтому для получения их значений используем метод GetRequiredElementValue статического класса XmlParser. Чтобы иметь возможность работать с методами класса XmlParser, добавьте в код using Common
.
Метод GetRequiredElementValue принимает параметры:
Первый параметр XmlNode
node
- это сам тэг<Command>
;Второй параметр string
path
- путь до нужного элемента. Так как нам нужно получить значение тэга<Text>
, вложенного в тэг<EmailSettingsSqlQuery>
, то в параметр передаем строку вида "EmailSettingsSqlQuery/Text";Третий параметр string
name
- имя объекта, в котором происходит получение значения. Это имя будет указываться в текст сообщения об ошибке;Последний параметр object
targetObject
- класс объекта, в котором происходит получение значения.
Давайте поставим точку останова на строке вызова базового метода Init и запустим приложение для отладки:

Нажимая клавишу F10, вы можете пройтись по всем строкам метода и посмотреть, что находится в параметрах метода Init и в наших переменных.
Если навести курсор на свойство EmailSettingsSqlQueryText, то увидим всплывающую подсказку с содержимым:

Нажмите на значок лупы, чтобы посмотреть текст содержимого:

Отлично! На этом работу с данным классом можно считать законченной. В дальнейшем будем возвращаться к нему только тогда, когда будет необходимость добавить новый элемент и передавать в него значение через xml-файл.
Исполнитель
Перейдем в файл EmailSendCommandExecutor.cs.
Из xml мы получили тексты запросов. Теперь нам понадобятся объекты для подключения к базе данных, чтобы иметь возможность выполнять эти запросы в момент исполнения команды и получать актуальные данные.
Добавим соответствующие глобальные переменные:
private readonly IDatabaseConnection _connection;
private readonly ISqlQueryHelper _sqlQueryHelper;
А в конструкторе EmailSendCommandExecutor(EmailSendCommand)
пропишем строки:
_connection = ServiceProvider.GetRequiredService<IDatabaseConnection>();
_sqlQueryHelper = ServiceProvider.GetRequiredService<ISqlQueryHelper>();
Если у вас подсветились ошибки, это значит, что в проекте не подключены дополнительные библиотеки. Чтобы подключить их, необходимо через контекстное меню по пункту Manage NuGet Packages... открыть окно управления пакетами:

На вкладке Installed можно увидеть, что нет ни одного установленного пакета:

Через вкладку Browse можно найти и установить необходимые пакеты:

В поисковой строке введем имя пакета Microsoft.Extensions.DependencyInjection и в списке ниже выберем нужный пакет. В правой части окна нужно выбрать версию пакета. В платформе используется версия 3.1.2 - ее и нужно устанавливать. Нажмите на кнопку Install, чтобы запустить мастер установки пакета.
После успешной установки вернемся в файл EmailSendCommandExecutor.cs. Поставим курсор на место подсвеченной ошибки и нажмем комбинацию клавиш Alt+Enter, чтобы вызвать контекстное меню:

Нажмем клавишу Enter, чтобы применился выбранный вариант замены, который добавит в файл директиву using
с загрузкой необходимого пространства имен.
Прежде чем приступить к реализации бизнес-логики команды, необходимо обеспечить вызов команды на сервере, чтобы иметь возможность отладки кода. Для этого воспользуемся <Scheduler>
, который по расписанию будет вызывать нашу команду.
Скопируйте код и добавьте его в серверный xml-файл перед описанием тэга <Commands>
:
<Scheduler>
<Task Name="HappyBirthdayEmailSendTask">
<Condition Type="OnStart" />
<Commands>
<Command Name="HappyBirthdayEmailSendCommand" />
</Commands>
</Task>
</Scheduler>
В следующем уроке подробно познакомимся с планировщиком задач, а пока кратко рассмотрим его значение.
Тэг <Task>
представляет конкретную задачу, которая будет выполнять последовательность команд, описанную в тэге <Commands>
по расписанию или при старте сервера. В нашем случае задача будет выполняться при старте сервера, о чем говорит значение атрибута Type
тэга <Condition>
.
Теперь можем перейти к реализации метода Task Execute(IParameterized)
.
В качестве входного параметра метод принимает контекст параметров, которые мы можем передавать в команду при ее вызове в xml-файле и использовать в тексте sql-запросов:
<Command Name="HappyBirthdayEmailSendCommand">
<Parameter Name=""></Parameter>
</Command>
Чтобы иметь возможность работать с такими параметрами, как с привычным словарем, добавим в метод Execute строку:
var parameterDictionary = parameters.ToDictionary();
Обращение к базе данных
В xml-файле мы указали текст sql-запроса для получения настроек почтового агента. Давайте посмотрим, как можно выполнять такие запросы в кастомных элементах.
В конструкторе класса мы получали _connection, который позволяет выполнять запросы к базе данных, и _sqlQueryHelper, который помогает построить текст запроса, подставляя в него вместо переменных нужные значения из параметров.
Подключим к проекту библиотеку Exceptions.dll:

Добавим в метод Execute код на выполнения запроса на получение настроек:
var settingsData = await _connection.ExecuteQueryAsync(_sqlQueryHelper.GetQueryText(_command.EmailSettingsSqlQueryText, parameterDictionary));
if (settingsData.Rows.Count == 0)
{
throw new InvalidXmlException($"Запрос на получение настроек подключения SMTP сервера не вернул ни одной строки в команде \"{_command.Name}\" типа {GetType().Name}.");
}
В метод GetQueryText передаем текст запроса и словарь параметров. Метод сам произведет подстановку значений вместо переменных.
Логирование
Ошибку InvalidXmlException, которую мы генерируем, необходимо отлавливать и писать в журнал событий.
Для логирования ошибок необходимо получить соответствующий сервис. Добавим глобальную переменную:
private readonly ILogger<EmailSendCommand> _logger;
Мы видим, что подсвечивается ошибка. Поставим курсор и нажмем комбинацию клавиш Alt+Enter. В контекстном меню выберем пункт Install package и затем пункт Install with package manager...:

В открывшейся вкладке выберем нужный пакет и версию пакета 3.1.2:

Теперь в конструктор класса добавим строку:
_logger = ServiceProvider.GetService<ILogger<EmailSendCommand>>();
И обернем код в конструкцию try-catch, чтобы отлавливать ошибки при выполнении запросов и обработки данных:
try
{
var settingsData = await _connection.ExecuteQueryAsync(_sqlQueryHelper.GetQueryText(_command.EmailSettingsSqlQueryText, parameterDictionary));
if (settingsData.Rows.Count == 0)
{
throw new InvalidXmlException($"Запрос на получение настроек подключения SMTP сервера не вернул ни одной строки в команде \"{_command.Name}\" типа {GetType().Name}.");
}
}
catch (Exception e)
{
_logger.LogError(message: $"Ошибка выполнения команды отправки сообщений:\r\n{e.ToPrettyString()}");
}
В блоке catch с помощью метода LogError будет писать в журнал событий сообщение об ошибке.
Обработка результата sql-запроса
Метод ExecuteQueryAsync возвращает объект типа DataTable, который имеет массив строк. Но так как у нас только одна запись в таблице, то достаточно получать первую строку из этого массива и из значений ее ячеек формировать словарь для хранения настроек.
Создадим метод для разбора строки:
private Dictionary<string, object> GetSettings(DataRow row)
{
return new Dictionary<string, object>()
{
["SmtpServerAddress"] = row["SmtpServerAddress"] is string smtpAddress ? smtpAddress : throw new Exception($"Не задан адрес SMTP в команде \"{_command.Name}\" типа {GetType().Name}."),
["SmtpServerPort"] = row["SmtpServerPort"] is int smptPort ? smptPort : throw new Exception($"Не корректно задан порт SMTP в команде \"{_command.Name}\" типа {GetType().Name}."),
["AuthorEmailAddress"] = row["AuthorEmailAddress"] is string authorEmail ? authorEmail : throw new Exception($"Не задан почтовый адрес отправителя в команде \"{_command.Name}\" типа {GetType().Name}."),
["AuthorEmailPassword"] = row["AuthorEmailPassword"] is string authorPassword ? authorPassword : throw new Exception($"Не задан пароль почтового адреса отправителя в команде \"{_command.Name}\" типа {GetType().Name}."),
["AuthorName"] = string.Empty,
["SSL"] = row["SSL"] is bool ssl ? ssl : throw new Exception($"Не корректно указан признак шифрования SSL в команде \"{_command.Name}\" типа {GetType().Name}."),
["Timeout"] = 1000
};
}
Добавим в метод Execute вызов этого метода:
var settings = GetSettings(settingsData.Rows[0]);
Создайте файл EmailClient.cs и скопируйте в него код:
Это вспомогательный класс, который будет создавать клиента SmtpClient для отправки писем и генерировать письмо.
Итак, у нас есть настройки и мы можем создать почтового клиента SmtpClient:
var smtpClient = EmailClient.GetSmtpClient((string)settings["SmtpServerAddress"],
(int)settings["SmtpServerPort"],
(string)settings["AuthorEmailAddress"],
(string)settings["AuthorEmailPassword"],
(int)settings["Timeout"],
(bool)settings["SSL"]);
Теперь необходимо получать информацию о клиентах, которым хотим отправить письма. Добавим код выполнения соответствующего запроса к базе данных:
var messageMailingData = await _connection.ExecuteQueryAsync(_sqlQueryHelper.GetQueryText(_command.MessageMailingSqlQueryText, parameterDictionary));
Реализуем цикл, в котором будем проходить по строкам полученной таблицы, формировать письма и отправлять их клиентам:
var emailClient = string.Empty;
foreach (DataRow sendInfoRow in messageMailingData.Rows)
{
emailClient = sendInfoRow["Email"] != null ? (string)sendInfoRow["Email"] : throw new Exception($"Не задан почтовый адрес адресата в команде \"{_command.MessageMailingSqlQueryText}\" типа {GetType().Name}.");
var subject = sendInfoRow["Subject"] is string sub ? sub : string.Empty;
var text = sendInfoRow["Text"] is string txt ? txt : string.Empty;
var mailMessage = EmailClient.CreateMailMessage((string)settings["AuthorEmailAddress"],
(string)settings["AuthorName"],
emailClient,
subject,
text);
await smtpClient.SendMailAsync(mailMessage);
}
Запустите проект из студии и проверьте, что письма пришли на вашу тестовую почту.
Итоги
В этом уроке мы рассмотрели возможность создавать кастомные элементы на стороне сервера, чтобы в них реализовать бизнес-логику, которую проще и быстрее описать на языке C#, либо настроить интеграцию со сторонними сервисами.
В приложении Серверная часть собраны статьи о написании кастомных команд и запросов.
Ответы
В архиве присутствуют xml-файлы форм и серверный xml-файл, исходный код кастомки TemplateEngine, а также бэкап базы данных - с помощью файлов можете проверить себя.
Last updated