Советы новичкам при проектировании модульных производственных систем

от автора

В этой статье я попытаюсь поделиться своим опытом в проектировании пользовательской бизнес-логики. Это явно не претендует на полноценный ликбез, т.к. я всего лишь вспоминаю то, через что прошёл лично я, какие ошибки я допустил, и как мне их удалось (или не удалось) исправить в будущем. Наверняка, опытные системные архитекторы уже все проходили и знают, однако надеюсь, что некоторые советы таки будут полезны.
Мы использовали (и используем) клиентскую часть на WPF/Silverlight, WCF сервисы и СУБД Oracle, Postrges, MsSQL. Код написан по MVVM, использована Prism для модульности и навигации. Не могу точно сказать, какие из тезисов подойдут для других платформ и языков.

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

1. Стремление избавиться от повторения кода должно быть разумным

Получилось, что в двух разных сборках (говоря об интерфейсе – в двух разных модулях системы, которые вызываются в разные моменты времени) нужно было выбирать из БД похожий список сущностей, скажем методом GetComissions(). Программист Петя, увидев у программиста Васи уже написанный на сервисе метод GetComissions() (а у нас тогда был один сервис с одним эндпоинтом), недолго думая, взял и его использовал. Все бы хорошо, только через пару недель заказчику понадобилось в одном из мест кроме комиссий показывать еще и их статистику, статусы, решения, и много всего другого. Как результат, второй модуль сразу же стал падать с ошибкой.
Увы, но таких случаев было явно больше одного, и в последствии, понадобилось очень много усилий по разнесению методов сервиса на разные сервисы-эндпоинты, или хотя бы на отдельные партиал классы (в случае с одним сервисом).
Вывод: два проекта могут использовать одну и ту же функцию на сервисе тогда и только тогда, когда заранее заведомо известно, что эти проекты всегда будут работать с этой функцией одинаково. В таком случае метод должен выноситься в общий класс, который и подключается к каждому проекту.
Во всех остальных случаях принцип написания кода на сервере должен следовать чему-то похожему на Single Responsibility principle (один из принципов SOLID).

2. Старайтесь не писать логику в датаконтрактах

По моему опыту, абсолютно всегда решение проще, если все датаконтракты (в случае с RiaServices – энтити классы) остаются всегда девственно чистыми, и совпадают 1к1 со схемой БД. Всё может начаться с безобидного кода конкатенации ФИО работника в одну переменную, а заканчивается это огромными вычислениями каких-то непонятных коэффициентов (нужных, как правило, только в одном месте). В итоге датаконтракты стали на 80% состоять либо из методов (да, если в геттере свойства написан какой-то код, это тоже я считаю методом), либо из полей других таблиц, которые нужны были кому-то одному в какой-то момент. Всегда лучше делать наследники или обёртки на сервисе или на клиенте (в зависимости от задачи), которые будут использоваться сугубо для целей этого модуля. Про проблемы с ними – в следующем пункте.

3. Что лучше на клиенте – наследник, партиал?

Увы, но идеального решения мы так и не нашли. Рассмотрим несколько случаев:

а) Модули работают с одним клиентским референсом на сервис. Классы на сервере разбиты на партиал классы от одного сервиса.
Минусы:

  • Клиентские партиалы, созданные для одного модуля, видны везде.
  • Клиентские наследники, созданные для одного модуля, видны везде (с ними попроще – их можно не использовать где не нужно)
  • Геморрой с настройкой параметров copy local для референсов проекта с клиентским сервисом (при работе с MEF и необходимости иметь одну сущность какого-то класса в сервис локаторе).
  • Геморрой с мерджем сервисов в свне, при одновременной перегенерации двумя людьми.

Плюсы:

  • возможность передачи объектов датаконтрактов между клиентскими модулями.


б) Каждый модуль самостоятельно делает клиентский референс на сервис.
Минусы:

  • выбор между возможностью передачи датаконтрактов между клиентскими модулями (в таком случае создаётся проект клиентских датаконтрактов, см. рис) и возможностью создания клиентских наследников и партиалов (в варианте на рис. это очень неудобно)

Мы выбираем чаще всего вариант а), т.к. передача объектов между модулями довольно полезная штука. Вернемся теперь к заголовку: именно в варианте а) разница между наследниками и партиалами становится очень заметной.


Допустим, у нас в БД есть таблички Person и Document. По канонам, в датаконтрактах у класса Person будет List<Document>, т.к. у Document есть вторичный ключ на Person. Одной из задач может быть создание на клиенте сущностей Person и всех его Documents сразу, и последующее сохранение этого всего добра в БД. В этом нет никаких проблем, если использовать класс Person и его поле List<Document>.
Однако, если в таком случае, нам потребуется помимо создания отображать и информационные поля из других таблиц (статусы, количества других сущностей, связанных вторичными ключами с Person или Document), и мы для этого сделаем наследника PersonEx и DocumentEx, то у PersonEx уже не будет поля List<DocumentEx>! В таком случае приходится писать кучу обёрток и переприсваиваний, что усложняет код – ведь в настоящей задаче уровней вложений может быть не два, а гораздо больше. В таких случаях нам помогут партиал-классы, однако нужно бдить, чтобы случайно эти же поля не заполнил кто-то другой в другом месте, т.к. партиалы видны везде.

4. Никогда не связывайте колонки, созданные для базовых нужд, и бизнес-логику

Под этими колонками я имею, прежде всего, идентификаторы. Связывание с бизнес логикой означает сортировки, группировки, использования идентификаторов в каких-то функциях, отображение их в интерфейсе, или еще хуже – функционал их редактирования напрямую.
Наверняка, это холиварная тема – но на моей практике еще не было случая, когда заказчик требовал бы какую-то сортировку, и для ее осуществления не было бы подходящего поля, заполняемого отдельно (руками или триггерами в БД – уже неважно).
При наличии связи идентификаторов с бизнес-логикой любой переход на другую схему БД, или на другую СУБД в принципе, или любое масштабирование системы на несколько БД с последующей репликацией повлечёт за собой переписывание логики.
Hint: очень удобно на каждую табличку в БД создавать две колонки date_insert и date_update, в которые триггерами на те же инсерт-апдейт заполнять время этих операций.

5. Избегайте неявных инсертов в БД, не связанных с осознанным действием пользователя

Речь идет, конечно же, о создании сущностей бизнес-логики, а не о логгировании каких-либо действий. Я лично сталкивался с сопровождением системы, в которой запись о пропуске появлялась сразу же после печати его на принтере. Это была большая головная боль, т.к.:

  • Пришлось писать говнокод и передавать колбеки в модуль печати, который по-хорошему не должен знать вообще ни о чём.
  • Из-за плохого качества принтеров, драйверов и их обслуживаний, достаточно часто ивент о законченности печати приходил, данные вставлялись, а печать не происходила.
  • Перепечатывание стало мукой, т.к. приходилось править БД.

Идеальный вариант был сделан чуть позже – появилась кнопка «сохранить пропуск в БД», а первая кнопка осталась простым предпросмотром с возможностью печати бесконечное кол-во раз.
К тому же, предварительное сохранение данных в БД не кнопкой «сохранить» чревато очень быстрым засорением базы пустыми (лишними) записями, которые кто-то потом явно забудет (или не сможет) удалить.

6. Полностью ограничьте любую возможность присутствия в БД неверных данных

Рассмотрим для примера случай на рисунке.

Допустим, мы имеем дело с поставками какой-то нумерованной сущности в больших партиях в разные регионы (в данном случае — бланк). У него есть номер и серия, печатная компания печатает ParentDiapason, а потом подрядчик делит его на ChildDiapason-ы и распределяет по регионам.
Однако такая схема еще далека до идеала, т.к. в БД сможет храниться бланк с серией ПП и номером 245 в чайлд диапазоне с сериями от АА до КК и номерами от 300 до 400. Кроме этого, поле quantity наверняка зависит от границ диапазонов, поэтому БД должна запрещать наличие записи, у которой quantity будет неравна результату некой функции от (startSeries, endSeries, startNumber, endNumber). Таким образом, мы приходим к списку триггеров:

  • на инсерт и апдейт бланка:
    • его серия и номер должны входить в чайлд диапазон
  • на инсерт и апдейт чайлд диапазона:
    • его множество серий и номеров должно быть подмножестом парента
    • не пересекаться с другими чайлдами
    • все смотрящие на него бланки должны быть в его множестве серий и номеров
    • поле квонтити должно совпадать с размером, вычисляемым по сериям и номерам
  • на инсерт и апдейт парент диапазона – аналогично с чайлдом.

Только после включения этих констреинтов с такой схемой можно работать в реальной системе.
Hint: при работе с MsSQL Server 2012 можно перенести класс-проверяльщик правильности данных в отдельную сборку, и ее использовать как на веб-сервере, так и в триггерах БД.

7. Используйте статусы сущностей вместе с конвертерами для интерфейса

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

  1. Создаётся класс, где над каждым статусом помечается атрибутом его текстовое значение (родительский класс – название таблицы, вложенный – название колонки)
    public class Users {     public class Status : StatusBase<Status>     {         /// <summary>         /// Активен (может логиниться)         /// </summary>         [StringStatus("Активный")]         public const int Active = 1;          /// <summary>         /// Неактивен (не может логиниться)         /// </summary>         [StringStatus("Неактивный")]         public const int Inactive = 0;     } } 

  2. Класс StatusBase – дженерик, создающий статический словарь (значение-объяснение), заполняющий его с помощью рефлексии
    public class StatusBase<T> where T : class {           private static Dictionary<int, string> statusesDictionary;      public static string GetStringStatus(int key)     {         if (statusesDictionary == null)         {             CreateStatusDictionary();         }          string result = statusesDictionary.ContainsKey(key) ? statusesDictionary[key] : "Неизвестный статус";         return result;     }      private static void CreateStatusDictionary()     {         statusesDictionary = new Dictionary<int, string>();         FieldInfo[] fields = typeof(T).GetFields();         foreach (FieldInfo field in fields)         {             string TextStatus = ((StringStatusAttribute)field.GetCustomAttributes(typeof(StringStatusAttribute), false)[0]). TextStatus;             int statusKey = (int)field.GetValue(null);             statusesDictionary.Add(statusKey, TextStatus);         }     } } 

  3. Теперь, в любом конвертере, который будет в интерфейсе приводить цифровое значение в текстовое, достаточно просто вызвать метод
    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {     string result = Data.Statuses.Users.Status.GetStringStatus((int)value);     return result; } 

Таким образом, и значения статусов, и их текстовые объяснения хранятся в одном месте, и легко меняются при необходимости.
Ну вот и всё. Буду рад, если эти советы кому-то принесут пользу.

ссылка на оригинал статьи http://habrahabr.ru/post/182132/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *