Шаблон Состояние (State): Управление состоянием объекта
Просто о NET | создано: 18.08.2016 | опубликовано: 18.08.2016 | обновлено: 13.01.2024 | просмотров: 6746
Очень часто в своей работе мне приходилось использовать перечисления (Enum) в качестве информации о состоянии объекта. И всё бы вроде как хорошо, но есть некоторое неудобство, при таком подходе логика по проверке состояния (validation) объекта при смене статуса "размазывалась" по всей системе. И часто получалось, что отследить все правила перехода от одного состояния к другому практически непосильная задача, особенно если проект разрабатывает группа программистов.
Объект в “Состоянии”
Итак, паттерн “Состояние” (State). Предположу что вы уже знакомы с паттерном, если же нет, прочитать о нем можно в статье википедии. Примером сущности для пилотного проекта я возьму понятие Документ (Document). На самом деле не важно какой это документ, но а в нашем случает пусть это будет документ, который может и должен быть утвержден, после чего вступает в силу. Пример такого документа – документ на внесения изменений в какую-либо систему (например, Штатное расписание). В общем, документ в моем примере будет иметь следующие состояния:
Черновик (Draft) – новый документ, который только что появился в системе имеет именно этот статус.
На проверке (In validation) – документ был заполнен и отправлен на утверждение начальству. В документе заполнены все необходимые реквизиты и он готов к утверждению.
Утверждён (Complete) – документ проверен начальником и/или соответствующей инстанцией, подписан и вступил (вступит) в силу в момента наступления даты указанной в документе.
Пусть у моего документа немного состояний, смена состояний должна следовать некоторым правилам.
Правила при смене статуса
Первое состояние “черновик” документ получает автоматически. Но раз у нас некая организация, то никто не может создать документ анонимно. Следовательно, в статус черновика может быть переведен документ, у которого заполнено свойство автор (Author). Итак, первое правило:
1. Статус Черновик требует заполненное свойство Автор
Далее статус На проверке. В этот статус пользователь отправляет документ, когда документ имеет все необходимые реквизиты. Пусть таким необходимым реквизитом будет свойство Регистрационный номер. Пусть этот номер должен быть определен где-нибудь в канцелярии. Пусть в нашей организации документооборот со строгой отчетностью. Итак, второе правило:
2. Статус На проверке требует заполненное свойство Регистрационный номер
После того, как специальная служба в нашей организации проверит документ, его отнесут на подпись нашему директору, директор подписывает. После этого документ приобретает статус Утвержден. Итак, третье правило:
3. Статус Утвержден требует подписи (цифровой) директора
На данный момент обрисована идеальная ситуация, когда документ проходит все состояния без каких-либо помех. Но нужно быть реалистом и иметь в виду, что Директор или специальная служба может вернуть документ в статус Черновик, потому что необходимые реквизиты были заполнены неправильно (или еще какие-нибудь заморочки).
Переходы по статусам
Давайте опишем направления перехода, которые, в свою очередь, тоже являются своего рода правилами.
- Статус Черновик может переходить в статус На проверке
- Статус На проверке может переходить в статус Утвержден
- Статус На проверке может переходить в статус Черновик
Пусть правила слишком надуманны, но суть не в том, чтобы рассказать как строить правила, а в том, как эти правила соблюдать, храня их и управляя ими централизованно. Вот наверное и всё по правилам. Давайте перейдем к реализации.
Класс Document
Мой класс для простоты будет содержать только те свойства, которые требуются для показа в демонстрации. В реальном мире этот класс не имеет смысла, а для пример идеально подходит по своей простоте.
namespace Calabonga.StateProcessor.Demo { /// <summary> /// Entiti with states /// </summary> public class Document { /// <summary> /// Unique indentificator /// </summary> public int Id { get; set; } /// <summary> /// Author of the document /// </summary> public string Author { get; set; } /// <summary> /// Registration number /// </summary> public string Number { get; set; } } }
StateProcessor
Скажу сразу, что я буду использовать свой nuget-пакет StatusProcessor, который создан мной как реализация паттерна "Состояние". Вы можете написать свою реализацию, а можете использовать мою. Итак, для чего нужен StateProcessor. StateProcessor добавит к вашей сущности, которая должна иметь состояния, всего одно свойство - это идентификатор состояния типа Guid. Также StateProcessor даст возможность создать объекты состояния и правила перехода между ними. Плюс ко всему, вы получите вспомогательные методы и функции для управления этими состояними. Давайте начнем с установки nuget-пакета.
Для установки пакета в Package Managment Console надо выполнить команду установки. Вот результат выполнения моей команды:
PM> Install-Package Calabonga.StatusProcessor Attempting to gather dependency information for package 'Calabonga.StatusProcessor.1.6.0' with respect to project 'Calabonga.StateProcessor.Demo', targeting '.NETFramework,Version=v4.5.2' Gathering dependency information took 1,24 sec Attempting to resolve dependencies for package 'Calabonga.StatusProcessor.1.6.0' with DependencyBehavior 'Lowest' Resolving dependency information took 0 ms Resolving actions to install package 'Calabonga.StatusProcessor.1.6.0' Resolved actions to install package 'Calabonga.StatusProcessor.1.6.0' Retrieving package 'Calabonga.StatusProcessor 1.6.0' from 'nuget.org'. GET https://api.nuget.org/packages/calabonga.statusprocessor.1.6.0.nupkg OK https://api.nuget.org/packages/calabonga.statusprocessor.1.6.0.nupkg 608ms Installing Calabonga.StatusProcessor 1.6.0. Adding package 'Calabonga.StatusProcessor.1.6.0' to folder 'D:\Dev\_TEMP\Calabonga.StateProcessor\packages' Added package 'Calabonga.StatusProcessor.1.6.0' to folder 'D:\Dev\_TEMP\Calabonga.StateProcessor\packages' Added package 'Calabonga.StatusProcessor.1.6.0' to 'packages.config' Successfully installed 'Calabonga.StatusProcessor 1.6.0' to Calabonga.StateProcessor.Demo Executing nuget actions took 1,77 sec Прошло времени: 00:00:04.7423125 PM>
DraftStatus - первый статус "черновик"
Класс будет иметь именно такое название, и будет унаследован от EntityStatus. В классе переопределены абстрактные методы и один виртуальный. Я создать статическое свойство типа Guid как идентификтор, который будет доступен всегда и везде. А также создать русскоязычное названия статуса для отчетов и для UI-отображения. Вот как выглядит класс:
namespace Calabonga.StateProcessor.Demo { /// <summary> /// Draft Status /// </summary> public class DraftStatus : EntityStatus { public static Guid Guid => Guid.Parse("49446068-0CCE-4168-B7AC-CD36E032F2BC"); public static string StatusDisplayName = "Черновик"; public override string GetDisplayName() { return StatusDisplayName; } protected override string StatusName() { return DocumentStatus.Draft.ToString(); } protected override Guid GetUniqueInentifier() { return Guid; } } }
Остальные статусы создаю подобным образом, изменив название для DisplayName и Guid. Идентификатор статусов в таком случае будут уникальны и статичны. Обратите внимание, что метод StatusName возвращает название статуса на английском языке. Я создал перечисление, которое будет использоваться в реализации.
namespace Calabonga.StateProcessor.Demo { /// <summary> /// Ducument statuses as enum /// </summary> public enum DocumentStatus { Draft, InValidation, Complete } }
После того, как созданы все статусы, можно приступать к созданию правил. Но перед этим надо сделать так, чтобы сущность Document имела реализацию IEntity. Другими словами, надо унаследовать Document от IEntity:
namespace Calabonga.StateProcessor.Demo { /// <summary> /// Entiti with states /// </summary> public class Document : IEntity { /// <summary> /// Unique indentificator /// </summary> public int Id { get; set; } /// <summary> /// Author of the document /// </summary> public string Author { get; set; } /// <summary> /// Registration number /// </summary> public string Number { get; set; } /// <summary> /// Status (IEntity) /// </summary> public Guid EntityActiveStatus { get; set; } } }
Обратите внимание на 6 и 26 строки. Вот теперь можно создавать первое правило DraftRule:
namespace Calabonga.StateProcessor.Demo.Rules { /// <summary> /// Rules validation for Draft status /// </summary> public class DraftRule : StatusRule<Document, EntityStatus> { public DraftRule(IEnumerable<EntityStatus> statuses) : base(statuses) { } public override bool CanEnter(RuleContext<Document, EntityStatus> context) { return true; } public override bool CanLeave(RuleContext<Document, EntityStatus> context) { return true; } protected override EntityStatus ValidationForStatus(IEnumerable<EntityStatus> statuses) { return statuses.SingleOrDefault(x => x.Name.Equals(DocumentStatus.Draft.ToString())); } } }
В строке 6 указан наследник StatusRule, его абстрактные методы проверки CanEnter и CanLeave означающие "может ли сущность войти в статус Draft" и "может ли сущность выйти из статуса Draft" соответственно, на данный момент возвращаю true, что разрешает смену статуса.
В строке 18 мы указываем, к какому значения перечисления DocumentStatus принадлежит это правило.
Усложнение задачи
Давайте остановимся подробнее на строке 18, или если точнее сказать, на сигнатуре метода. Коллекция статусов системы (наследников от EntityStatus), которая указана в сигнатуре должны быть где-то зарегистрирована. Заглядывая вперед скажу, что будет создан DocumentRuleProcessor, при помощи которого и будут управляться статусы сущности Document. Так вот, как раз в конструктор этого объекта и требуется набор правил и статусов. Можно создать свойство AvaliableStatuses по такому примеру:
namespace Calabonga.StateProcessor.Demo { class Program { static void Main(string[] args) { var holder = new StatusHolder(); foreach (var status in holder.AvaliableStatuses) { Console.WriteLine($"{status.Name} - {status.DisplayName} - {status.Id}"); } } } public class StatusHolder { public IEnumerable<EntityStatus> AvaliableStatuses { get { yield return new DraftStatus(); yield return new InValidationStatus(); yield return new CompleteStatus(); } } } }
Или сделать представленный класс реализующим паттерн Singleton.
Результатом этой программы будет такой скриншот:
В общем, вы в праве поступать так, как вам хочется. Но мне нравится всё регистрировать в DI-контейнере (Depedency Injection container). Мой выбор - Autofac. Давайте усложним поставленную задачу и подключим DI-контейнер, чтобы пример получился максимально практичным. И для пушей практичности можно не только подключить конейнер, но и создать БД на EntityFramework, чтобы документы хранить в базе данных.
Установка EntityFramework и Autofac
Я установил nuget-пакеты, и вот логи выполненых команд, чтобы вы смогли отследить версии установленных мной пакетов:
PM> Install-Package Autofac Attempting to gather dependency information for package 'Autofac.4.0.0' with respect to project 'Calabonga.StateProcessor.Demo', targeting '.NETFramework,Version=v4.5.2' Gathering dependency information took 1,23 sec Attempting to resolve dependencies for package 'Autofac.4.0.0' with DependencyBehavior 'Lowest' Resolving dependency information took 0 ms Resolving actions to install package 'Autofac.4.0.0' Resolved actions to install package 'Autofac.4.0.0' Retrieving package 'Autofac 4.0.0' from 'nuget.org'. Adding package 'Autofac.4.0.0' to folder 'D:\Dev\_TEMP\Calabonga.StateProcessor\packages' Added package 'Autofac.4.0.0' to folder 'D:\Dev\_TEMP\Calabonga.StateProcessor\packages' Added package 'Autofac.4.0.0' to 'packages.config' Successfully installed 'Autofac 4.0.0' to Calabonga.StateProcessor.Demo Executing nuget actions took 1,15 sec Прошло времени: 00:00:04.1491785 PM> Install-Package EntityFramework Attempting to gather dependency information for package 'EntityFramework.6.1.3' with respect to project 'Calabonga.StateProcessor.Demo', targeting '.NETFramework,Version=v4.5.2' Gathering dependency information took 21,16 ms Attempting to resolve dependencies for package 'EntityFramework.6.1.3' with DependencyBehavior 'Lowest' Resolving dependency information took 0 ms Resolving actions to install package 'EntityFramework.6.1.3' Resolved actions to install package 'EntityFramework.6.1.3' Retrieving package 'EntityFramework 6.1.3' from 'nuget.org'. Adding package 'EntityFramework.6.1.3' to folder 'D:\Dev\_TEMP\Calabonga.StateProcessor\packages' Added package 'EntityFramework.6.1.3' to folder 'D:\Dev\_TEMP\Calabonga.StateProcessor\packages' Added package 'EntityFramework.6.1.3' to 'packages.config' Выполнение файла скрипта "D:\Dev\_TEMP\Calabonga.StateProcessor\packages\EntityFramework.6.1.3\tools\init.ps1" Выполнение файла скрипта "D:\Dev\_TEMP\Calabonga.StateProcessor\packages \EntityFramework.6.1.3\tools\install.ps1" Type 'get-help EntityFramework' to see all available Entity Framework commands. Successfully installed 'EntityFramework 6.1.3' to Calabonga.StateProcessor.Demo Executing nuget actions took 3,48 sec Прошло времени: 00:00:04.0479574 PM>
Теперь всё необходимое установлено и можно приступать к созданию контейнера и базы данных. Для начала создадим ApplicationDbContext.cs, который унаследован от DbContext, то ест, это будет Code First база данных.
namespace Calabonga.StateProcessor.Demo.Data { /// <summary> /// EntityFramework Code Fisrt Database class /// </summary> public class ApplicationDbContext : DbContext { public ApplicationDbContext() : base("DocumentDbConnection") { } public DbSet<Document> Documents { get; set; } } }
Так как для примера я использую ConsoleApplication, то информацию для DbContext я прописал в файле конфигурации App.config:
<connectionStrings> <add name="DocumentDbConnection" connectionString="Data Source=SQL2014;Initial Catalog=RuleProcessorTest; Integrated Security=true" providerName="System.Data.SqlClient"/> </connectionStrings>
Обратите внимание на названия "DocumentDbConnection". Теперь можно подключить DB-миграции, хотя это делать и не обязательно, потому что БД при первом к ней обращении будет создана автоматически. Но я буду создавать базу данных "вручную" при помощи команды Update-database в Package Manager Console, а для этого надо подключить миграции Code First, выполнив команду:
PM> Enable-Migrations Checking if the context targets an existing database... Code First Migrations enabled for project Calabonga.StateProcessor.Demo. PM>
Для начала надо в файле настроек миграций Configuration, который появился в новой папке Migrations после выполнения команды, установить разрешения на перегенерацию базы данных:
namespace Calabonga.StateProcessor.Demo.Migrations { using System.Data.Entity.Migrations; internal sealed class Configuration : DbMigrationsConfiguration<Data.ApplicationDbContext> { public Configuration() { AutomaticMigrationsEnabled = true; AutomaticMigrationDataLossAllowed = true; } protected override void Seed(Data.ApplicationDbContext context) { // This method will be called after migrating to the latest version. // You can use the DbSet<T>.AddOrUpdate() helper extension method // to avoid creating duplicate seed data. E.g. // // context.People.AddOrUpdate( // p => p.FullName, // new Person { FullName = "Andrew Peters" }, // new Person { FullName = "Brice Lambson" }, // new Person { FullName = "Rowan Miller" } // ); // } } }
Чтобы база создавалась с перезаписью (или сбросом) уже текщих настроек Code Fisrt, параметры 9 и 10 строки надо устрановить в True. После этого можно создать базу данных, выполнив команду в Package Manager. Я не стал создавать конкретную миграцию, а положился на автоматически созданную Code Fisrt:
PM> Update-Database Specify the '-Verbose' flag to view the SQL statements being applied to the target database. No pending explicit migrations. Applying automatic migration: 201608190950399_AutomaticMigration. Running Seed method. PM>
Если вы обратили внимание, то в файле настроек название каталога RuleProcessorTest, и именно с таким именем Code First и создал мне базу данных.
В общем, пришло время создавать контейнер для DI (dependency injection). Я создал новый класс со статичным методом:
namespace Calabonga.StateProcessor.Demo { public class DependencyContainer { internal static IContainer Initialize() { // Создаем построитель для контейнера var builder = new ContainerBuilder(); // Регистрируем правила для сущности Document builder.RegisterType<DraftRule>().As<IStatusRule<Document, EntityStatus>>(); builder.RegisterType<InValidationRule>().As<IStatusRule<Document, EntityStatus>>(); builder.RegisterType<CompleteRule>().As<IStatusRule<Document, EntityStatus>>(); // Регистрируем статусы для сущности Document builder.RegisterType<DraftStatus>().As<EntityStatus>(); builder.RegisterType<InValidationStatus>().As<EntityStatus>(); builder.RegisterType<CompleteStatus>().As<EntityStatus>(); // Возвращаем готовый контейнер return builder.Build(); } } }
В комментариях указал, что для чего и к чему: сначала создаем builder, который является неотемлемой частью Autofac, далее регистрируем правила и статусы.
RuleProcessor для сущности Document
Собственно говоря, пришло время создать то, ради чего всё затевалось. Создаем DocumentRuleProcessor, который призван управлять статусами документа (Document) на основании правил созданных нами правил.
namespace Calabonga.StateProcessor.Demo { /// <summary> /// Document RuleProcessor /// </summary> public class DocumentRuleProcessor : RuleProcessor<Document, EntityStatus> { public DocumentRuleProcessor(IEnumerable<IStatusRule<Document, EntityStatus>> rules, IEnumerable<EntityStatus> statuses) : base(rules, statuses) { } /// <summary> /// Returns initial status for entity Document /// </summary> /// <returns></returns> protected override IEntityStatus InitialStatus() { return Statuses.FirstOrDefault(x => x.Name.Equals(DocumentStatus.Draft.ToString())); } } }
Класс является наследником RuleProcessor, обобщенными параметрами для которого является сущность Document и EntityStatus. Обратите внимание на конструктор этого класса. При создании DI-контейнера мы регистрируем в нем требуемый набор правил и статусов. Процессор правил будет управлять нашими статусами используя наши правила. Таким образом, достаточно просто зарегистрировать наш новый DocumentRuleProcessor в контейнере, и контейнер сам наполнит нужными зависимостями класс. Теперь класс регистрации контейнера выглядит так:
namespace Calabonga.StateProcessor.Demo { public class DependencyContainer { internal static IContainer Initialize() { // Создаем построитель для контейнера var builder = new ContainerBuilder(); // Регистрируем процессор обработки правил для Document builder.RegisterType<DocumentRuleProcessor>().AsSelf(); // Регистрируем правила для сущности Document builder.RegisterType<DraftRule>().As<IStatusRule<Document, EntityStatus>>(); builder.RegisterType<InValidationRule>().As<IStatusRule<Document, EntityStatus>>(); builder.RegisterType<CompleteRule>().As<IStatusRule<Document, EntityStatus>>(); // Регистрируем статусы для сущности Document builder.RegisterType<DraftStatus>().As<EntityStatus>(); builder.RegisterType<InValidationStatus>().As<EntityStatus>(); builder.RegisterType<CompleteStatus>().As<EntityStatus>(); // Возвращаем готовый контейнер return builder.Build(); } } }
Пример использования процессора правил
Пришло время переключить статусы у какого-нибудь документа. Мне хочется показать всё и сразу, но давайте постепенно.
var processor = container.Resolve<DocumentRuleProcessor>();
Первым делом я получаю из DI-контейнера processor, который будет осуществлять все манипуляции с сущностями. Далее хочу продемонстрировать некоторые полезные утилиты. В процессоре есть метод-конвертор, который принимает несколько перегрузок. Этот метод является вспомогательной утилитой.
// Получение Enum из Guid var result1 = processor.ToEnum<DocumentStatus>("Draft"); if (result1.Ok) { DocumentStatus status1 = result1.Result; Console.WriteLine($"Test 1 converted : {status1.ToString()}"); } // Получение Enum из строки (String) по имени статуса var result2 = processor.ToEnum<DocumentStatus>(Guid.Parse("49446068-0CCE-4168-B7AC-CD36E032F2BC")); if (result2.Ok) { DocumentStatus status2 = result2.Result; Console.WriteLine($"Test 2 converted : {status2.ToString()}"); } // Получение Enum из EntityStatus var statusInProcessor = processor.Statuses.SingleOrDefault(x => x.Id == Guid.Parse("49446068-0CCE-4168-B7AC-CD36E032F2BC")); var result3 = processor.ToEnum<DocumentStatus>(statusInProcessor); if (result3.Ok) { DocumentStatus status3 = result3.Result; Console.WriteLine($"Test 3 converted : {status3.ToString()}"); }
Теперь наверное следует показать обновленное правило для статуса Draft, которое я адаптировал под оговоренные выше требования.
namespace Calabonga.StateProcessor.Demo.Rules { /// <summary> /// Rules validation for Draft status /// </summary> public class DraftRule : StatusRule<Document, EntityStatus> { public DraftRule(IEnumerable<EntityStatus> statuses) : base(statuses) { } public override bool CanEnter(RuleContext<Document, EntityStatus> context) { var hasAuthor = !string.IsNullOrEmpty(context.Processor.Entity.Author); var hasNoStatus = context.Processor.Entity.EntityActiveStatus == Guid.Empty; return hasAuthor && hasNoStatus; } public override bool CanLeave(RuleContext<Document, EntityStatus> context) { return true; } protected override EntityStatus ValidationForStatus(IEnumerable<EntityStatus> statuses) { return statuses.SingleOrDefault(x => x.Name.Equals(DocumentStatus.Draft.ToString())); } } }
В строке 11 проверяется наличие автора, а в строке 12 проверяется, что документ не имел никакого статута. Следовательно, если я использую вторую утилиту и создам документ
// Метод Create создает экземпляр класса Document с установленным по умолчанию статусом. // Статус по умолчанию задается в DocumentRuleProcessor при переопределении метода InitialStatus // При вызове метода Create будет проведена проверка правил var document = processor.Create();
У меня нечего не получится, потому что метод Create создает экземплар и применяет правила при установки статуса по умолчанию. То есть, метод Create создаст объект Document и попытается его перевести в статутс Draft, но так как у меня во вновь созданном документе нет автора, я получу пустой экземпляр класса. Наверняка, ваши правила и статусы сущности позволят вам создать экземпляр без проблем, а мне не повезло, буйство фантазии привело к такому стечению обстоятельств.
С моим набором правил и сущностью Document я могу создать экземпляр таким образом:
// Создаем экземпляр класса с указанием автора var document2 = new Document { Author = "Director" };
Теперь получаем статус черновика из списка статусов зарегистрированных в процессоре:
// Список всех статусов всегда доступен в процессоре var statusDraft = processor.Statuses.Single(x => x.Name.Equals(DocumentStatus.Draft.ToString()));
Напоминаю, что процессор проверит соответствующие правила перед применением статуса.
// Устанавливаем статус документу processor.UpdateStatus(document2, statusDraft);
Вот как это выглядит вместе с сохранением данных в базу и чтеним данных из нее:
using (var db = new ApplicationDbContext()) { // сохраним созданый выше документ в базу данных db.Documents.Add(document2); db.SaveChanges(); PrintStatus(document2, processor); // загрузим снова документ базы данных var documentInDatabase = db.Documents.SingleOrDefault(x => x.Id == document2.Id); if (documentInDatabase != null) { // Выберем новый статус для сущности. А для этого выбираем статус из всех доступных var entityStatus = processor.Statuses.SingleOrDefault(x => x.Name.Equals(DocumentStatus.InValidation.ToString())); document2.Number = "Номер строгой отчетности"; // и обновляем его у сущности processor.UpdateStatus(documentInDatabase, entityStatus); PrintStatus(documentInDatabase, processor); // сохраним изменения db.SaveChanges(); } }
Заключение
В результате использования шаблона проектирования "Состояние" (Status) и, в частности RuleProcessor, мы получили следующие плюсы:
- Фиксированный (но расширяемый) набор статусов для определенной сущности;
- Фиксированный (но расширяемый) набор правил переключения статуса с проверкой "входа в статус" и "выхода из статуса";
- Сущность (Document), которая не претерпела никаких изменений, за исключением только одно свойства типа Guid, а вся информация по статусам и правилам расположена в коде и сосредоточена в одном месте;
- Правила и статусы могут быть загружены с любого сервера, из XML-файла, из любой сборки, а также могут варьироваться в зависимости от каких-либо параметров;
- Unit-тестирование бизнес-логики на основе правил и статусов, определяющих четкое поведение в конкретных случаях;
- Вспомогательные методы по работе со статусами.
но также не обошлось и без минусов:
- Усложнение кода.
На этом позвольте закончить. Единственное что, хотелось бы еще предложить, так это Github на скачивание демонстрационного проекта. Пишите комментарии если будут вопросы.