В этой статье я попытаюсь поделиться своим опытом в проектировании пользовательской бизнес-логики. Это явно не претендует на полноценный ликбез, т.к. я всего лишь вспоминаю то, через что прошёл лично я, какие ошибки я допустил, и как мне их удалось (или не удалось) исправить в будущем. Наверняка, опытные системные архитекторы уже все проходили и знают, однако надеюсь, что некоторые советы таки будут полезны.
Мы использовали (и используем) клиентскую часть на 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. Используйте статусы сущностей вместе с конвертерами для интерфейса
Наверняка прозвучит по-капитански совет не иметь в коде никаких цифр, а выносить их в константы. Этот пункт немножко развивает этот совет, предлагая ипользовать удобное хранилище статусов сущностей:
- Создаётся класс, где над каждым статусом помечается атрибутом его текстовое значение (родительский класс – название таблицы, вложенный – название колонки)
public class Users { public class Status : StatusBase<Status> { /// <summary> /// Активен (может логиниться) /// </summary> [StringStatus("Активный")] public const int Active = 1; /// <summary> /// Неактивен (не может логиниться) /// </summary> [StringStatus("Неактивный")] public const int Inactive = 0; } }
- Класс 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); } } }
- Теперь, в любом конвертере, который будет в интерфейсе приводить цифровое значение в текстовое, достаточно просто вызвать метод
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) { string result = Data.Statuses.Users.Status.GetStringStatus((int)value); return result; }
Таким образом, и значения статусов, и их текстовые объяснения хранятся в одном месте, и легко меняются при необходимости.
Ну вот и всё. Буду рад, если эти советы кому-то принесут пользу.
Автор: fuazi