Урок 22. Создание кастомных команд для форм
Как говорится в статье Состав элементов архитектуры WT-программы серверная и клиентская части WT-приложения помимо штатных библиотек платформы (dll-файлов) могут включать кастомные библиотеки, созданные разработчиками для расширения функциональности платформы для приложения.
Этот урок посвящен созданию кастомной команды на клиентской части, а в следующем уроке мы узнаем, как создавать кастомные команды на сервере. Платформа Workflow Technology написана на языке C#, поэтому все кастомные команды и объекты будем так же писать на этом языке. В уроках в качестве среды разработки будем использовать Microsoft Visual Studio 2022.
Если хотите начать практику с этого урока, то вам необходимо развернуть учебный проект по инструкции в статье Разворачивание проекта.
При разворачивании проекта используйте backup базы данных, который можете найти в архиве из раздела Ответы прошлого урока. Скопируйте папки Forms, Workflow и Patterns в папку с развернутым проектом, например, в папку D:\WT\Projects\Template\Projects\1. Template.
Инструкция по подключению шаблонов находится по ссылке.
Подготовка
При разработке кастомных библиотек к проекту в Visual Studio будем подключать штатные библиотеки. В этом уроке нам понадобятся бинарники, которые мы скачивали при разворачивании клиентской части. Штатные библиотеки платформы WT позволят использовать классы из этих библиотек и создавать собственные классы, которые платформа WT будет с легкостью интегрировать в приложение.
В дальнейшей работе с платформой Workflow Technology при разработке новых приложений будет удобнее, если для штатных бинарников платформы будет отведен отдельный каталог, в котором они будут храниться. А при создании нового WT-приложения можно будет копировать бинарники и файлы конфигурации из соответствующих папок этого каталога в папку, где будет разворачиваться приложение. Такое разделение позволит хранить штатные библиотеки отдельно от кастомных, созданных для конкретного приложения, что упростит копирование dll-файлов платформы в новые приложения.
В уроке будем работать с отдельным каталогом для штатных библиотек, и вам советуем сделать так же.
Настройка Visual Studio
Запустите Visual Studio Installer.

По кнопке "Изменить" откроется окно установки компонент среды разработки:

На вкладке "Рабочие нагрузки" поставьте галочку на компоненте ASP.NET и разработка веб-приложений и нажмите кнопку "Изменить".
Создание проекта
В папке \Template\Projects\1. Template создадим папку Objects, в которой будут храниться исходники кастомных объектов для форм.
Запустим Visual Studio 2022.
На начальной странице выберем пункт Create a new project (Создать проект):

Если среда разработки Visual Studio уже открыта, то проект можно создать, выбрав пункт File -> New -> Project... в строке меню. А также нажав кнопку New Project на панели инструментов, или нажав комбинацию клавиш Ctrl+Shift+N.

На странице Create a new project введите в поле поиска library. Так как платформа написана на платформе .NET Core, то нам необходимо выбрать соответствующий тип приложения:

В диалоговом окне Configure your new project (Настройка нового проекта) доступны параметры, позволяющие присвоить имя проекту (и решению), выбрать расположение на диске:

В поле Project name укажем имя нашего проекта - Template. В поле Location - ранее созданную папку Objects.
Галочку Place solution and project in the same directory можно снять.
В диалоговом окне Additional information (Дополнительные сведения) содержится параметр для выбора версии платформы:

Оставим здесь .NET Core 3.1 (Out of support) и нажмем на кнопку Create.
По умолчанию новый проект имеет один пустой класс Class1 в файле Class1.cs:

Сборка проекта
Откроем свойства проекта, вызвав контекстное меню и выбрав пункт Properties. Или нажав комбинацию клавиш Alt+Enter.

На вкладке Build->Events необходимо прописать команду, которая будет выполняться после сборки решения:

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

Проверьте, что файл Template.dll скопировался в папку с установленной клиентской частью.
Отладка проекта
Чтобы запускать приложение напрямую из Visual Studio, сделаем настройки режима отладки. Для этого откроем свойства проекта и перейдем на вкладку Debug:

Кликнем по тексту Open debug launch profiles UI. В открывшемся окне профилей запуска кликнем по кнопке Create a new profile. В меню выберем пункт Executable:

Новый профиль сразу переименуем, кликнув по кнопке Rename selected profile:

В поле Executable укажем путь до exe-файла приложения, размещенного в папке развернутой клиентской части:

Теперь мы можем запустить приложение, нажав клавишу F5 или кнопку на панели инструментов:

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

Кастомный объект
В этом уроке мы создадим кастомный объект на форме заказа.
Первым делом необходимо описать xml-код объекта, чтобы иметь представление, какие элементы нам понадобятся:
<MyObject Name="PaymentCounter" Type="PaymentCounter" Assembly="Template">
<PositionDatabaseTable Name="OrderPositionDatabaseTable">
<ColumnSumToPay Name="TotalPrice" />
</PositionDatabaseTable>
<PaymentDatabaseTable Name="OrderPaymentDatabaseTable">
<ColumnPaidSum Name="Summ" />
</PaymentDatabaseTable>
</MyObject>
Здесь стоит обратить внимание на несколько моментов.
Во-первых, у тэга <MyObject>
есть атрибут Assembly
. В нем указывается имя библиотеки, в которой описан класс объекта. Так как это кастомный объект, то в атрибуте указано имя кастомной сборки Template, которую мы создали ранее.
Во-вторых, в атрибуте Type
указывается имя класса, который описывает логику работы объекта.
Итак, в объекте мы будем передавать имена таблиц и их колонок. По этим именам в C# коде мы сможем получать доступ к объектам формы и их свойствам.
Объект PaymentCounter должен будет рассчитывать сумму начислений (позиций заказа), сумму всех оплат и остаток к оплате. Эти три значения будем получать через get-проперти объекта и хранить в переменных на форме.
Ранее в уроках мы создавали переменные TotalSumToPayVariable, TotalPaidSumVariable и TotalDebtVariable, в которых обращались к таблицам и через get-проперти получали нужные значения. Перепишем эти переменные - теперь будем заполнять их через get-проперти кастомного объекта:
<MyObject Name="TotalSumToPayVariable" Type="Variable" Assembly="SimpleControls" ChangeForm="False">
<Value>
<Object Name="PaymentCounter">
<Property Name="TotalSumToPay" />
</Object>
</Value>
</MyObject>
<MyObject Name="TotalPaidSumVariable" Type="Variable" Assembly="SimpleControls" ChangeForm="False">
<Value>
<Object Name="PaymentCounter">
<Property Name="TotalPaidSum" />
</Object>
</Value>
</MyObject>
<MyObject Name="TotalDebtVariable" Type="Variable" Assembly="SimpleControls" ChangeForm="False">
<Value>
<Object Name="PaymentCounter">
<Property Name="TotalDebt" />
</Object>
</Value>
</MyObject>
Подключение библиотеки
Подключим библиотеки WT-платформы, необходимы для создания кастомных объектов форм.
Для этого в контекстном меню проекта выберем пункт Add -> Project Reference...:

В открывшемся окне перейдем на вкладку Browse, где по кнопке Browse... откроем диалоговое окно выбора файлов и в папке со штатными бинарниками клиентской части найдем файлы WorkflowForms.dll и FormObjects.dll и добавим их.

WorkflowForms.dll - основная библиотека форм.
В FormObjects.dll описаны базовые классы, необходимые для работы любого объекта, а также класс Control, от которого будет наследоваться наш кастомный объект.
Дальше необходимо подключить фреймворк Microsoft.WindowsDesktop.App.WindowsForms.
Для этого в контекстном меню выберем пункт Edit Project File:

Откроется файл Template.csproj, в нем в тэг <PropertyGroup>
добавим строчку:
<UseWindowsForms>true</UseWindowsForms>
Таким образом, структура решения будет иметь вид:

Создание класса
Удалим файл Class1.cs и создадим папку CustomControls, в которую добавим новый класс PaymentCounter:
using System.Xml;
namespace WorkflowForms.Controls
{
public class PaymentCounter : Control
{
public override object Value
{
get
{
return null;
}
set
{ }
}
public override bool Visible
{
get
{
return false;
}
set
{
}
}
public override bool Enabled
{
get
{
return false;
}
set
{
}
}
protected override void UpdateValue()
{
}
public PaymentCounter(IWorkflowForm form, XmlNode node) : base(form, node)
{
}
protected override void InternalInit(XmlNode node, IWorkflowForm form, IControl parentControl, System.Windows.Forms.Control parentWindowsControl)
{
base.InternalInit(node, form, parentControl, parentWindowsControl);
}
}
}
Это минимально необходимый код для создания кастомного объекта.
В классе PaymentCounter мы переопределили свойства: Value (значение объекта), Visible (признак видимости объекта) и Enabled (признак активности объекта).
Так как кастомный объект не будет иметь значения, то его свойство Value возвращает null. И раз объект не будет иметь графического представления на форме, то свойства Visible и Enabled возвращают false.
Метод UpdateValue переопределяется для реализации логики обновления значения свойства Value. Подробнее об этом в примерах кастомных элементов, описанных в приложении к блоку.
Метод InternalInit срабатывает в момент загрузки формы, когда происходит парсинг xml-файла. В этом методе и будем описывать логику получения данных из файла.
Метод InternalInit принимает параметры:
Первый параметр XmlNode
node
- это сам тэг<MyObject>
и его содержимое.Второй параметр IWorkflowForm
form
- наша форма. Через нее сможем получать доступ ко всем элементам формы.Третий параметр IControl
parentControl
- родительский объект формы, в котором описан текущий. В нашем случае это TotalPanel.Четвертый параметр System.Windows.Forms.Control
parentWindowsControl
- родительский контрол, который является экземпляром класса System.Windows.Forms.Panel.
Обратите внимание, что для кастомных контролов в качестве namespace
указывается WorkflowForms.Controls. Это позволяет обращаться к публичным классам штатных библиотек платформы без использования директивы using
для импорта типов.
Давайте немного изменим начальный код класса:
using System;
using System.Xml;
namespace WorkflowForms.Controls
{
public class PaymentCounter : Control
{
#region Base Properties
public override object Value { get => null; set => throw new NotImplementedException(); }
public override bool Visible { get => false; set => throw new NotImplementedException(); }
public override bool Enabled { get => false; set => throw new NotImplementedException(); }
#endregion
protected override void UpdateValue()
{
throw new NotImplementedException();
}
public PaymentCounter(IWorkflowForm form, XmlNode node) : base(form, node)
{
}
protected override void InternalInit(XmlNode node, IWorkflowForm form, IControl parentControl, System.Windows.Forms.Control parentWindowsControl)
{
base.InternalInit(node, form, parentControl, parentWindowsControl);
}
}
}
В свойства Value, Visible и Enabled мы добавили генерацию ошибки NotImplementedException, чтобы обозначить, что для данного класса эти свойства не реализованы. Аналогичную ошибку генерируем в методе UpdateValue.
Также добавили директиву #region, чтобы объединить базовые свойства класса в единый логический блок кода, который можно сворачивать.
Парсинг xml-кода
Получать данные из xml-файла можно в текущем классе (PaymentCounter). Либо можно создать дополнительный класс настроек, чтобы разделить парсинг xml и бизнес-логику.
Давайте создадим класс PaymentCounterSettings:
using System.Xml;
namespace WorkflowForms.Controls
{
public class PaymentCounterSettings : ControlSettings
{
public PaymentCounterSettings(XmlNode node, IWorkflowForm form) : base(node, form)
{
}
}
}
Вернемся в файл PaymentCounter.cs и создадим метод, в котором будем получать данные от PaymentCounterSettings:
private void LoadSettings(XmlNode node, IWorkflowForm form)
{
var settings = new PaymentCounterSettings(node, form);
}
Добавим вызов этого метода в InternalInit.
Вернемся в файл PaymentCounterSettings.cs и вспомним описание нашего объекта на xml:
<MyObject Name="PaymentCounter" Type="PaymentCounter" Assembly="Template">
<PositionDatabaseTable Name="OrderPositionDatabaseTable">
<ColumnSumToPay Name="TotalPrice" />
</PositionDatabaseTable>
<PaymentDatabaseTable Name="OrderPaymentDatabaseTable">
<ColumnPaidSum Name="Summ" />
</PaymentDatabaseTable>
</MyObject>
Для начала создадим константы с именами вложенных тэгов:
private const string POSITION_DATABASE_TABLE_ELEMENT = "PositionDatabaseTable";
private const string PAYMENT_DATABASE_TABLE_ELEMENT = "PaymentDatabaseTable";
private const string COLUMN_NAME_SUM_TO_PAY_ELEMENT = "ColumnSumToPay";
private const string COLUMN_NAME_PAID_SUM_ELEMENT = "ColumnPaidSum";
Так как наш объект должен работать с данными из конкретных столбцов таблиц позиций заказа и оплат, то все вложенные тэги и их атрибуты являются обязательными для описания в xml-коде. Чтобы получить значение обязательного атрибута, воспользуемся статическим методом GetRequiredAttributeValue класса XmlParser:
if (node[POSITION_DATABASE_TABLE_ELEMENT] != null)
{
var databaseTableName = XmlParser.GetRequiredAttributeValue<string>(
form, node,
POSITION_DATABASE_TABLE_ELEMENT,
NAME_ATTRIBUTE);
}
else
{
throw new InvalidXmlException($"Не задано имя таблицы в поле {POSITION_DATABASE_TABLE_ELEMENT} объекта \"{Name}\" типа \"{GetType().Name}\".");
}
Для того чтобы использовать исключение типа InvalidXmlException, подключите к проекту библиотеку Exceptions.dll, а в код добавьте необходимый using.
В метод GetRequiredAttributeValue первыми двумя параметрами передаем объект формы (IWorkflowForm form) и объект xml-узела (XmlNode node), который содержит xml-код нашего кастомного объекта. В третьем параметре (string path) указываем полный путь до тэга, значение атрибута которого нужно получить. Имя атрибута указывается в четвертом параметре (string attribute). В данном случае используется константа NAME_ATTRIBUTE
, описанная в базовом классе. Если элемент или его атрибут отсутствует, будет возвращено исключение типа InvalidXmlException.
Подключите к проекту библиотеку ComplexControls.dll, чтобы иметь доступ до класса DatabaseTable.
Создадим проперти для хранения ссылок на таблицу PositionDatabaseTable:
public DatabaseTable PositionDatabaseTable { get; private set; }
Зная имя таблицы, мы можем получить на нее ссылку. Для этого обратимся к объекту form и его методу GetControl. Добавим код в блок if
:
PositionDatabaseTable = (DatabaseTable)form.GetControl(databaseTableName);
Создадим проперти, в котором будем хранить имя колонки для расчета суммы, необходимой к оплате:
public string ColumnSumToPayName { get; private set; }
С помощью метода GetRequiredAttributeValue так же получим значение атрибута Name
тэга <ColumnSumToPay>
:
ColumnSumToPayName = XmlParser.GetRequiredAttributeValue<string>(
form, node,
$"{POSITION_DATABASE_TABLE_ELEMENT}/{COLUMN_NAME_SUM_TO_PAY_ELEMENT}",
NAME_ATTRIBUTE);
Так как тэг <ColumnSumToPay>
является вложенным, то полный путь до него включает имя внешнего тэга <PositionDatabaseTable>
и его собственное имя, разделенные символом /
.
Полный синтаксис блока if
для получения информации об элементах формы с позициями заказа:
if (node[POSITION_DATABASE_TABLE_ELEMENT] != null)
{
var databaseTableName = XmlParser.GetRequiredAttributeValue<string>(
form, node,
POSITION_DATABASE_TABLE_ELEMENT,
NAME_ATTRIBUTE);
PositionDatabaseTable = (DatabaseTable)form.GetControl(databaseTableName);
ColumnSumToPayName = XmlParser.GetRequiredAttributeValue<string>(
form, node,
$"{POSITION_DATABASE_TABLE_ELEMENT}/{COLUMN_NAME_SUM_TO_PAY_ELEMENT}",
NAME_ATTRIBUTE);
}
else
{
throw new InvalidXmlException($"Не задано имя таблицы в поле {POSITION_DATABASE_TABLE_ELEMENT} объекта \"{Name}\" типа \"{GetType().Name}\".");
}
Аналогично сделайте и для таблицы оплат (PaymentDatabaseTable).
Отлично! Теперь можем вернуться в файл PaymentCounter.cs и продолжить работу с методом LoadSettings.
Создадим переменные:
private DatabaseTable _positionDatabaseTable;
private DatabaseTable _paymentDatabaseTable;
private string _columnSumToPayName;
private string _columnPaidSumName;
Дополним метод LoadSettings:
private void LoadSettings(XmlNode node, IWorkflowForm form)
{
var settings = new PaymentCounterSettings(node, form);
_positionDatabaseTable = settings.PositionDatabaseTable;
_columnSumToPayName = settings.ColumnSumToPayName;
_paymentDatabaseTable = settings.PaymentDatabaseTable;
_columnPaidSumName = settings.ColumnPaidSumName;
}
Отлично! У нас есть данные с формы, и мы можем перейти к реализации расчетов.
Реализация бизнес-логики
Чтобы наш кастомный объект PaymentCounter самостоятельно следил за изменением данных в таблицах OrderPositionDatabaseTable и OrderPaymentDatabaseTable, создадим handler, который будет срабатывать каждый раз при изменении данных в таблицах.
Добавим следующий код в метод InternalInit:
_positionDatabaseTable.Change += RecountChangeHandler;
_paymentDatabaseTable.Change += RecountChangeHandler;
И добавим код самого handler и метода, который он будет вызывать:
private void RecountChangeHandler(Object sender, EventArgs args)
{
Recount();
}
private void Recount()
{
TotalSumToPay = (double)_positionDatabaseTable.GetProperty("ColumnSum", new Dictionary<string, object>() { { "ColumnName", _columnSumToPayName } });
TotalPaidSum = (double)_paymentDatabaseTable.GetProperty("ColumnSum", new Dictionary<string, object>() { { "ColumnName", _columnPaidSumName } });
TotalDebt = TotalSumToPay - TotalPaidSum;
}
В методе Recount получаем суммы в колонках таблиц на форме и рассчитываем остаток, необходимый к оплате. Ниже создадим, в котором опишем переменные TotalDebt, TotalSumToPay и TotalPaidSum.
Через метод GetProperty можем обращаться к get-проперти объекта, передавая в качестве аргументов имя get-проперти и словарь параметров:
_positionDatabaseTable.GetProperty(
"ColumnSum",
new Dictionary<string, object>() { { "ColumnName", _columnSumToPayName } }
)
Эта запись аналогична записи в xml-коде:
<Object Name="OrderPositionDatabaseTable">
<Property Name="ColumnSum">
<Parameters>
<Parameter Name="ColumnName">TotalPrice</Parameter>
</Parameters>
</Property>
</Object>
Полученные значения сохраняются в свойства класса. Добавим в PaymentCounter.cs следующий код:
private const string TOTAL_SUM_TO_PAY_PROPERTY = "TotalSumToPay";
private const string TOTAL_PAID_SUM_PROPERTY = "TotalPaidSum";
private const string TOTAL_DEBT_PROPERTY = "TotalDebt";
private double _totaSumToPay;
private double TotalSumToPay
{
get
{
return _totaSumToPay;
}
set
{
_totaSumToPay = value;
OnPropertyChange(TOTAL_SUM_TO_PAY_PROPERTY);
}
}
private double _totalPaidSum;
private double TotalPaidSum
{
get
{
return _totalPaidSum;
}
set
{
_totalPaidSum = value;
OnPropertyChange(TOTAL_PAID_SUM_PROPERTY);
}
}
private double _totalDebt;
private double TotalDebt
{
get
{
return _totalDebt;
}
set
{
_totalDebt = value;
OnPropertyChange(TOTAL_DEBT_PROPERTY);
}
}
Метод OnPropertyChange описан в родительском классе и необходим, чтобы объект формы рассылал сообщение о том, что значение его свойства изменилось. Благодаря этому переменные TotalSumToPayVariable, TotalPaidSumVariable и TotalDebtVariable будут получать свежие значения get-проперти.
Get-проперти
Переопределим метод GetProperty, чтобы кастомный объект имел get-проперти, которые можно использовать на форме:
public override object GetProperty(string propertyName, Dictionary<string, object> parameters)
{
if (propertyName.Equals(TOTAL_SUM_TO_PAY_PROPERTY, StringComparison.OrdinalIgnoreCase))
{
return TotalSumToPay;
}
else if (propertyName.Equals(TOTAL_PAID_SUM_PROPERTY, StringComparison.OrdinalIgnoreCase))
{
return TotalPaidSum;
}
else if (propertyName.Equals(TOTAL_DEBT_PROPERTY, StringComparison.OrdinalIgnoreCase))
{
return TotalDebt;
}
else
{
return base.GetProperty(propertyName, parameters);
}
}
Этот метод форма будет вызывать каждый раз, когда происходит обращение к get-проперти объекта. Таким образом, когда метод OnPropertyChange делает рассылку сообщения об изменении свойства объекта, переменные на форме, поймав это сообщение, инициируют вызов этого метода.
Первым делом проверяем, было ли обращение к нашему get-проперти и возвращаем соответствующее значение, иначе передаем вызов в базовый метод.
Запустим проект из Visual Studio, чтобы проверить значения в полях TotalSumToPayTextBox, TotalPaidSumTextBox и TotalDebtTextBox:

Но если мы внесем изменения в одну из таблиц, то значения текстовых полей обновятся. Это связано с тем, что метод Recount вызывается только в момент изменения таблиц OrderPositionDatabaseTable и OrderPaymentDatabaseTable, а не при открытии формы.
Реализуем set-проперти Recount, которое будет инициировать вызов метода Recount.
Set-проперти
Чтобы кастомный объект имел set-проперти, необходимо переопределить метод SetProperty:
public override void SetProperty(string propertyName, Dictionary<string, object> parameters)
{
if (propertyName.Equals(RECOUNT_PROPERTY, StringComparison.OrdinalIgnoreCase))
{
Recount();
}
else
{
base.SetProperty(propertyName, parameters);
}
}
Этот метод вызывается в момент выполнения команды ValueSetCommand.
Добавим в класс константу:
private const string RECOUNT_PROPERTY = "Recount";
Добавим на форму команду ValueSetCommand, через которую обратимся к set-проперти Recount:
<Command Name="PaymentCounterRecountValueSetCommand" Type="ValueSetCommand" Assembly="Commands">
<Object Name="PaymentCounter">
<Property Name="Recount" />
</Object>
</Command>
Теперь для этой команды создадим Execution на системное условие FormLoadedCondition, которое необходимо дополнительно прописать в тэге <Conditions>
.
Запустите проект из Visual Studio и проверьте отображение данных на форме заказа.
Итоги
В этом уроке мы рассмотрели возможность создавать кастомные объекты на формах, чтобы в них реализовать бизнес-логику, которую проще и быстрее описать на языке C#, либо настроить интеграцию со сторонними сервисами.
В приложении Клиентская часть собраны статьи о написании кастомных условий, команд и DataConnection.
Ответы
В архиве присутствуют xml-файлы форм и серверный xml-файл, исходный код кастомки Template, а также бэкап базы данных - с помощью файлов можете проверить себя.
Last updated