12 ошибок при построении архитектуры ПО
В этой статье я хотел бы затронуть наиболее часто встречающиеся проблемы в построении архитектуры ПО. Я решил написать этот материал, очередной раз столкнувшись с тем, что люди, даже имеющие огромный опыт разработки ПО, продолжают путать модели, относящиеся к разным слоям приложения. Они не придают значения излишней связанности (костности) между частями приложения и прочим аспектам. В итоге эти аспекты оказываются, если не критичными, то существенно влияют на дальнейшую разработку программного продукта.
Почему возникают ошибки
Причина, по которой разработчики, имеющие за плечами довольно большой стаж разработки ПО и построения архитектуры, продолжают реализовывать жёстко связанные решения и решения с нечётко разделёнными моделями — отсутствие негативной «обратной связи» в краткосрочной перспективе. Часто последствия всего этого вылазят через время, после внесения множества других изменений. Те, кто построил эту архитектуру, либо уже не видят негативных результатов своих трудов, либо спихивают их на других людей (чаще всего на тех, кто непосредственно реализовывал).
Как говорит мой почти
Один из наиболее частых аргументов, который мне приходилось слышать относительно архитектуры: «Это просто разные подходы, можно сделать и так, и так, оба способа хороши». Аргумент в корне неправильный, но с которым спорить довольно тяжело. Очень сложно объяснить человеку, какие последствия в будущем принесёт его подход с хрупким, жёстко связанным кодом. И ещё тяжелее объяснить заказчику, для которого важна прежде всего «обёртка», функциональность, реализованная здесь и сейчас, а не какая-то там эфемерная «гибкость», которую он не чувствует, не осознаёт последствий, выражающихся в сложности модернизации и дальнейшего развития приложения, но за которую нужно заплатить «здесь и сейчас».
Парадокс в том, что часто архитекторы, строящие хрупкую, вязкую и жёстко связанную архитектуру, сами апеллируют к тому, что код не должен быть хрупким, вязким и жёстко связанным, но при этом не могут понять, что их код является как раз таким.
Что мы рассмотрим
В этой статье я хочу рассмотреть проблемы в архитектуре на основе реального проекта, с которыми мне пришлось столкнуться, когда заказчик решил, что через знакомых ему посоветовали хорошего архитектора с опытом более 40 лет и он занялся «ревизией» этого самого реального проекта.
Проект создан с помощью технологии .NET и языка C#, а также используется база данных MS SQL, но на самом деле это не имеет никакого значения. Точно также это может быть как другая реляционная БД, так и другая ООП технология, например Java.
Хочу сразу заметить, что в этом материале я не касался действительно таких непростых тем, как многопоточность, параллельное выполнение кода, построение систем реального времени и прочего. В статье я хочу упомянуть о тех, довольно элементарных вопросах, ответы на которые, казалось бы, должны быть очевидны. Но я с ними сталкиваюсь снова и снова.
В примерах кода я удалил все неактуальные моменты (например, различные проверки, если пример кода сфокусирован на другом) для повышения читабельности.
В этой статье я хочу рассмотреть следующие вопросы:
1. Использование одной и той же модели для базы данных и бизнес-логики.
2. Выборка записей из БД и их projection на DTO классы.
3. Использование enum или отдельной сущности (отдельной таблицы-списка в БД).
4. Нарушение SRP (Single Responsible Principle).
5. Использование Nullable types.
6. Проверка входных/выходных данных.
7. Использование Exceptions.
8. Вычисляемые поля и их хранение в БД.
9. «Исключительные случаи» и «дублирование» в архитектуре.
10. Интерфейсы и их реализация.
11. «Заглушки» и прочие способы подавления ошибок.
12. Неочевидность использования библиотечного кода.
Любые обсуждения, рекомендации и даже критика определённо приветствуются.
1. Использование одной и той же модели для базы данных и бизнес-логики
Один из самых распространённых подходов, с которым мне приходилось сталкиваться и который в итоге часто приводит к бардаку во всём проекте, — это «перемешивание» слоя базы данных и классов слоя бизнес-логики, которые отвечают за передачу данных (DTO классы). Почему они должны быть разделены? Как минимум потому, что, как мы знаем из азов программирования, классические БД представляют собой реляционную модель, а классы бизнес-логики оперируют объектами! И объектная модель — не тоже самое, что реляционная модель для представления одних и тех же данных. Одного этого аспекта должно быть достаточно, чтобы задуматься о том, что эти модели должны быть разными. И все попытки натянуть одно на другое ведут либо к избыточным и дублирующим полям в БД, либо к запросам, которые вытягивают целые сущности ради одного поля в каждой сущности.
Я могу привести множество аргументов о вреде избыточных и дублирующих полей. Это целая тема для отдельной статьи. Избыточные, а особенно дублирующие поля — это зло, которое создаёт чуть ли не более половины всех проблем в проекте. Но если сказать вкратце, то дублирующие поля создают неопределённость и 2 (или более) «точек» для изменения, вместо одной. Например, если сущность User имеет поля FirstName и LastName, а сущность Driver является User, то если у Driver тоже будут поля FirstName/LastName — это создаст неоднозначность.
public partial class AspNetUser { public string Id { get; set; } public string UserName { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public virtual Driver Driver { get; set; } //..... } public partial class Driver { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string Nickname { get; set; } //..... } this.HasOptional(c => c.Driver) .WithOptionalPrincipal(a => a.AspNetUser) .Map(m => m.MapKey("AspNetUserId"));
То есть если мы обновляем драйверу имя, то должны также обновить его и у пользователя. Если обновляем у пользователя, то должны также обновить и у драйвера. Причём, если мы забудем это сделать для какой-то из сущностей — никакой ошибки мы не получим! Ошибку мы получим тогда, когда одна часть приложения будет возвращать имя, взятое у пользователя, а вторая — взятое у драйвера! Причём ошибку получит клиент. С точки зрения разработчика всё будет компилироваться и вообще будет всё феншуй. А с клиентской точки зрения будет вообще неясно, какие данные валидны и цена этого «незнания» может быть очень высокой. Если развить тему, то пользователь может вообще увидеть данные, которые после момента редактирования не должны быть известны.
Почему я привёл этот пример? Потому что недавно столкнулся с этой ситуацией. Новый архитектор из Сербии, который по возрасту годится мне в отцы и который первые шаги в программировании сделал в начале
Аналогичным образом влияют избыточные поля, например, вычисляемое поле. Если есть поля A, B и C и по ним можно вычислить поле D, то без особой необходимости поля D не должно быть в БД! Иначе это всё влечёт за собой то же самое — это поле нужно сопровождать, менять при изменении одного из полей и прочие радости жизни.
Что же заставило этого «архитектора со стажем» добавить дублирующие поля? Оказывается, причина этого была банальной — он напрочь забраковал DTO классы! То есть роль DTO классов у него играют те же классы, которые участвуют в построении БД. И он реально не понимает, почему нужны ещё какие-то DTO классы, которые часто похожи на его классы и которые нужно поддерживать. Но вот когда клиентская часть запросила данные с
На самом деле эта проблема элементарно решается с помощью projection (не знаю, как сказать по-русски), когда механизм запросов к БД оптимизирует так, чтобы это был один запрос вместо двух (хоть и с INNER JOIN; в .NET это Linq-to-Entities, например). Результат сохраняется в DTO и далее пробрасывается уже в клиентскую часть. Ситуация становится совсем катастрофической, если клиентская часть хочет видеть список, данные которого формируются из данных из 5 таблиц, причём в каждой из них нас интересует только одно поле. Если у нас в результате будет 100 записей, то вместо одного (пусть и массивного запроса) мы вынуждены будем сделать 5 * 100 + 1 запрос.
Помимо всего прочего, добавление дублирующих полей, как практикует этот мой сербский друг, «решает» только данный конкретный случай. Завтра клиентской части потребуется ещё одно поле и его подход с добавлением дублирующего поля потребует внесения изменений в архитектуру БД (или же плодить дополнительные запросы к БД). Но БД никак не должна зависеть от перипетий на клиентской части. БД — это хранение данных, она вообще ничего не должна знать о клиентах, её использующих. Она только изменяет, хранит и отдаёт данные. Во что их преобразовать и как — задача клиента, её использующего.
Поэтому чётко разделяем классы, работающие с БД, и классы DTO. Классы БД имеют реляционную структуру, классы DTO привязаны к бизнес-модели (и часто очень похожи на конечную клиентскую часть). За это придётся заплатить тем, что нужно будет создать классы DTO, которые, возможно, будут иметь много общих полей с классами, используемыми для построения БД. Но у нас будет гибкая, независимая архитектура, чётко разграниченная по слоям.
Если классы БД должны работать с остальным приложением только через посредника (то есть через классы DTO), то, по идее, их хорошо было бы вообще сделать «невидимыми» для всех остальных частей приложения, кроме как слоя, работающего непосредственно с БД. Я в принципе так и предпочитаю делать. Эти классы изолированы в отдельной сборке вместе со всей инфраструктурой для работы с конкретной БД, и методы этой сборки возвращают уже DTO классы, а вся кухня работы с БД происходит внутри этой сборки.
Однако есть один аспект: часто бывает необходимым просто обновить объект. То есть клиентская часть передаёт новый объект, и нам нужно обновить текущий объект в БД. Мы не знаем, какие поля конкретно были изменены. Или же клиентская часть передаёт только те поля, которые были обновлены. Или же операция представляет собой только добавление объекта, без затрагивания других сущностей в БД. В этих случаях удобно напрямую работать с классами, работающими с БД и не плодить DTO классы, не отличающиеся от БД классов и заниматься их маппингом. То есть мы просто получаем запись из БД (которая отражается на классе, работающим с БД), вносим изменения в этот класс и сохраняем этот объект снова. Либо при добавлении объекта мы сразу заполняем класс БД и сохраняем его, минуя DTO.
[HttpPost] private HttpResponseMessage CreatePrivate(int tenantId, LocationFormCreateAPI model) // LocationFormCreateAPI – класс слоя представления { var location = mapper.Map<Location>(model); // Location – класс, используемый в том числе и для генерации таблицы в БД, в данном случае рассматривается в том числе и как класс бизнес-логики _locationServiceEntity.InsertLocation(location); //передаём экземпляр этого класса и метод сервиса напрямую его добавляет к DbContext: public void InsertLocation(Location location) { _context.Locations.Add(location); _context.SaveChanges(); }
В принципе такой подход имеет право на существование и не несёт за собой особых последствий при аккуратном использовании (в таком случае классы БД рассматриваются как часть бизнес-модели). В этом подходе есть плюс — отсутствие дублирующих DTO классов для простых операций. Но есть и жирные минусы — бизнес-модель имеет, по сути, реляционные классы, которые к ней не относятся. А также то, что открытые для бизнес-модели реляционные классы дают предпосылки для их использования «напрямую» даже в случаях, когда этого делать не стоит С изолированными классами в отдельной сборке такой номер вообще не провернёшь, не добавив ссылку на эту сборку. Исходя из сказанного, мы получаем, по сути, нарушение принципа SRP (Single Responsible Principle), о котором поговорим в
Стоит ли «открывать» эти классы для использования в простейших CRUD операциях или нет — решать вам. Это зависит от множества других факторов: размера приложения, количества таких операций, частоты изменения и модификации приложения и т.д. Но как только клиентская часть запрашивает данные, которые отличаются от тех, которые хранятся в одной таблице реляционной БД — то не нужно натягивать сову на глобус. Создавайте дополнительный слой с DTO классами и формируйте запрос к БД именно так, чтобы он был оптимизирован и не содержал лишних данных.
В интернете ведётся много дискуссий по поводу того, что классы DTO дублируют многие поля из классов других слоёв. Я не вижу в этом особой проблемы и не считаю, что это создаёт какую-либо избыточность. Это, скорее, наоборот, изолирует каждый слой от других слоёв и убирает ненужные зависимости. А для мэппинга классов между слоями есть различные тулзы, например Automapper (как в примере выше).
Резюме. Не стоит смешивать реляционную модель и доменную модель приложения. По возможности, стоит вообще изолировать каждую из моделей в своём слое.
2. Выборка записей из БД и их projection на DTO классы
Использование projection (не знаю, как правильно перевести на русский) вытекает из моментов, рассмотренных нами ранее. А точнее из того, что реляционная модель данных отличается от объектной бизнес-модели. На практике чаще всего получается так, что в реляционной модели данные, касающиеся одной сущности, разбросаны по нескольким таблицам. Например, у нас есть сущность Employee, и у каждого Employee есть Marital Status. Список статусов часто имеет смысл хранить в отдельной таблице, так как это позволит использовать их повторно, гарантировать их уникальность и избегать дублирования (вторая нормальная форма БД):
public partial class MaritalStatus { public int Id { get; set; } public string Status { get; set; } } public partial class Employee { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public int MaritalStatusId { get; set; } public virtual MaritalStatus MaritalStatus { get; set; } //.... }
Клиентская часть приложения требует того, чтобы мы вернули имя, фамилию и семейное положение. Как я писал выше, DTO модель часто напрямую зависит от модели представления. В нашем случае они будут идентичны:
public partial class EmployeeDto { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string MaritalStatus { get; set; } //.... }
Теперь для того, чтобы выбрать эти данные, мы можем использовать projection в Linq-to-Entities:
public List<EmployeeDto> EmployeeList() { var list = (from i in Employee select new EmployeeDto() { Id = i.Id, FirstName = i.FirstName, LastName = i.LastName, MaritalStatus = i.MaritalStatus.Status, }).ToList(); return list; }
Есть разные инструменты, которые умеют маппить это автоматически, например AutoMapper.EF6. Но суть остаётся той же — нам не нужно выбирать 2 сущности вместо одной, нам не нужно добавлять какие-то дублирующие поля и прочие извращения, о которых мы говорили выше. В нашем случае Linq-to-Entities (в вашем это может быть что угодно, хоть ручное составление SQL-запроса) помогает одним запросом получить необходимые данные, которые соответствуют нашей бизнес-модели.
Резюме. Старайтесь использовать projection при выборе данных из нескольких таблиц в одном запросе. В таком случае ваш запрос к БД будет оптимизирован, что позволит избежать множества запросов к БД и выбора ненужных данных.
3. Использование enum или отдельной сущности (отдельной таблицы-списка в БД)
Довольно часто мы сталкиваемся с ситуацией, когда какая-то сущность представляет собой список. Это может быть список стран/штатов, список допустимых значений, список статусов, которые может иметь другая сущность. Для решения этой задачи можно использовать отдельную таблицу с ключом-значением, где каждая запись имеет уникальное значение, а полный набор записей представляет собой список всех допустимых значений. Либо создать отдельный enum и также задать список всех допустимых значений. Какой из подходов правильный? Правильные на самом деле оба, просто всё зависит от условий!
Давайте рассмотрим на примере. В текущем проекте используется список штатов. Мною он был спроектировал как отдельная сущность-таблица, первичный ключ записи которой используется как внешний ключ в таблицах, где необходима связь с каким-либо штатом. Моему сербскому другу это не понравилось, он всё забраковал, снёс эту таблицу, создал enum и везде, где был внешний ключ на эту таблицу, заменил на обычное значение типа int для хранения значения enum. Правильно ли он сделал? Нет!
Но почему? Потому что теперь тяжело проконтролировать допустимое значение? Нет, это допустимое значение можно проконтролировать на уровне приложения при валидации модели. С внешним ключом это более изящно, но это не главная причина. Главный минус при использовании enum в том, что значения, по сути, захардкодены. При их изменении (добавлении/удалении) нужно изменять код, а это значит, что нужно дёргать разработчиков, а ведь проект может быть давным давно закончен и весь штат разработчиков распущен. Нужно перекомпилировать проект, а он может не компилироваться, так как чуть другой компилятор или какие-то версии связанных сборок уже недоступны, либо работать чуть не так, как ожидается. И потом его нужно опубликовать.
Представьте, что проект последний раз изменялся год назад, заказчик уже потерял все связи с разработчиками, которые его делали. Как развернуть проект на хостинге скудно описано (если описано) где-то там в документации. И вообще это тоже делал кто-то из разработчиков тоже год назад. Заказчику, по сути, нужно изменить то, что относится к данным, но он вынужден изменять код из-за данных. Код не должен зависеть от данных. Если мы вынуждены изменять код из-за данных, значит что-то не так в нашей архитектуре!
И пусть даже список штатов — довольно статичная вещь, она необязательно отражает список всех штатов. В этом списке могут быть только штаты, которые актуальны для нашего приложения. Завтра может потребоваться работать со штатом, с которым приложение не работало ранее, либо добавить штаты соседней страны.
Можно сделать вывод, что вообще не стоит использовать enum’ы для отображения списка сущностей. Но это не так. Давайте рассмотрим случай, когда как раз стоит использовать enum.
Допустим, у нас есть маршрут, который должен проехать водитель. У маршрута есть состояние. Это могут быть значения «Waiting», «Started», «In Progress», «Stopped», «Cancelled», «Completed». И у нас в зависимости от состояния выполняется разная логика! Например, если маршрут завершён успешно — то водитель должен загрузить счёт-фактуру. Если остановлен — то мы должны обработать другую логику и посмотреть, что произошло и т. д. То есть у нас логика в коде зависит от состояния маршрута.
switch (route.Status) { case (RouteStatus.Waiting): // код, выполняющийся, когда статус у маршрута Waiting break; case (RouteStatus.Started): // код, выполняющийся, когда статус у маршрута Started break; // ...... }
Да, я знаю про принцип подстановки Лисков. Но, по-моему, здесь как раз тот случай, когда лучше использовать switch, а не плодить дочерние классы.
Если использовать для этого таблицу, то здесь мы наоборот вынуждены добавлять захардкоденые значения, соответствующие записям в таблице. Теперь значения в таблице нельзя трогать, нельзя удалять, переименовывать, что накладывает дополнительные ограничения и вводит исключительную ситуацию, которая где-то должна быть описана. Этот документ должен быть must have для чтения каждому по 3 раза на неделю, чтобы, не дай бог, он не забыл о том, что эту таблицу трогать нельзя и изменения в ней могут привести к непредсказуемым последствиям, которые могут вылезти только спустя время!
Резюмируя сказанное: если логика приложения не зависит от выбранных значений — то используем таблицу (или другой источник данных). Если зависит и в коде мы должны упоминать какое-то из значений — тогда создаём enum.
Резюме. Что стоит использовать — enum или отдельную сущность — зависит от конкретного случая. Если логика приложения зависит от выбранного значения, то, скорее всего, стоит использовать enum. Если нет, а также этот список может меняться «на ходу» — то всё говорит о том, что нужно посмотреть в сторону использования отдельной сущности.
4. Нарушение SRP (Single Responsible Principle)
Как говорит мой опыт, наиболее часто встречаемое нарушение принципов SOLID в реальных проектах — это нарушение принципа SRP (Single Responsible Principle). И использование реляционной модели как модели для передачи данных между уровнями (вместо специального класса DTO) — одно из проявлений нарушения этого принципа. Даже если бизнес-модель полностью идентична реляционной, всё равно мы имеем 2 причины для изменения этой модели. Когда меняется модель представления, которая тянет за собой бизнес-модель, и когда меняется структура реляционной модели. Например, у нас был следующий класс, используемый для генерации БД:
public partial class Employee { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string MaritalStatus { get; set; } //.... }
То есть его семейное положение было записано просто строкой в БД, со всеми вытекающими негативными последствиями. В результате было решено сделать рефакторинг и привести БД ко
public partial class MaritalStatus { public int Id { get; set; } public string Status { get; set; } } public partial class Employee { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public int MaritalStatusId { get; set; } public virtual MaritalStatus MaritalStatus { get; set; } //.... }
Но ведь этот же класс использовался для передачи данных в слой представления и теперь всё поломалось! Нужно переписывать выборку (в идеале, добавлять класс DTO).
Аналогичным образом мы будем вынуждены изменить этот класс, если изменится слой представления. Это и есть нарушение принципа Single Responsible Principle, который вытекает из использования одного класса для разных слоёв модели. Старайтесь, без особой на это необходимости, не делать так, даже если модели разных слоёв на данный момент идентичны.
Резюме. Если есть несколько причин для изменения класса — то нарушен принцип SRP. Стоит посмотреть в сторону выделения ещё (как минимум) одного класса, чтобы разделить ответственность на каждого из них.
5. Использование Nullable types
Ещё одна распространенная проблема, встречающаяся в построении архитектуры — неправильное использование nullable типов в полях сущностей. Правило для их использования очень простое: если мы знаем значение по умолчанию, то мы используем это значение. Если же мы не знаем это значение и сущность может быть инициирована без значения этого поля — используем nullable!
Например, в проекте есть сущность Trip, в котором есть поле CalculatedDistance. Это расстояние вычисляется сторонним компонентом, который иногда может быть недоступен или по каким-то внутренним причинам не может посчитать это расстояние, но в целом сущность Trip должна быть создана даже в этом случае (такое бизнес-правило в проекте). Это поле было объявлено как nullable (decimal?).
public class Trip { public int Id { get; set; } public decimal? CalculatedDistance { get; set; } //.... }
Мой сербский друг переделал его на not nullable:
public class Trip { public int Id { get; set; } public decimal CalculatedDistance { get; set; } //.... }
И оно стало инициироваться значением по умолчанию, то есть нулём. Я спросил его, почему он так сделал. Он мне ответил, что инициировать нулём — правильно и привёл мне пример из жизни, что когда мы покупаем новую машину — то на одометре мы имеем ноль, и потом, в процессе эксплуатации, это значение увеличивается в зависимости от пройдённых автомобилем километров.
Его аргумент относительно новой машины правильный, но это совсем другая ситуация. В новом автомобиле действительно это значение должно быть ноль, потому что машина проехала 0 километров! А вот если мы покупаем машину на еврономерах где-нибудь в Риге у русскоязычного продавца, который продаёт «Опель» без одометра середины девяностых с почему-то переваренной рамой и кузовом и мамой клянётся, что на машине почти не ездили и точно знает, что машина прошла не более нескольких десятков тысяч км и вообще «мы, гусские, никогда своих не обманываем» ©... Это явно тот случай, когда мы должны были бы инициировать значение одометра как null — неизвестно, отсутствие значения!
Ноль — это тоже число! Например, если мы пришли в магазин купить себе пиво, достали кошелёк и вспомнили, что мы только вчера его купили, и вообще он ещё в упаковочной плёнке, то мы можем определённо сказать, что там нет денег, там ноль! А если мы вчера положили в кошелёк 1000 долларов, сегодня достали его в пивном магазине и вспомнили, что сегодня утром наш кошелёк брала с собой жена на шоппинг, то, не открыв его, мы не знаем, сколько там денег, и не знаем, хватит ли нам на пиво. Там null — неизвестно! 0 — точно означает, что денег на пиво не хватит, и мы не можем сделать транзакцию, с null — это неизвестно. Там может быть как 1 доллар, так и 10, 100, 1000 долларов и даже больше (но это вряд ли). Инициализация нулём неизвестных значений — такое же magic string, как и любое захардкоденное стринговое значение! Мало того, оно может создавать неопределённость, как в примере с кошельком.
Ещё один распространённый пример, когда используется дефолтное значение вместо null. Сущность имеет 2 поля: дату (время) создания и последнюю дату изменения. При этом дата изменения устанавливается вместе с датой создания, например, в конструкторе:
public class Trip { public Trip() { CreatedOnUtc = DateTime.UtcNow; UpdatedOnUtc = DateTime.UtcNow; } // .... public DateTime CreatedOnUtc { get; set; } public DateTime UpdatedOnUtc { get; set; } }
Я также встречал варианты, когда устанавливают для этого поля не текущую дату, а, например, 1 января 1970 года, не в том суть. Суть в том, что изменений ещё не было, а дата последнего изменения уже есть, что противоречит логике! И для того, чтобы понять, было ли реально редактирование, нам нужно сравнить дату изменения с датой создания (или с 01.01.1970, или ещё с какой-то «магической» датой). Но ведь можно просто хранить в этом поле null, что будет правильно с логической и удобно с технической точки зрения!
Если вы всё же ещё не уверены, стоит ли использовать null вместо значений по умолчанию там, где логически должен быть null, и вам до сих пор кажется, что это просто «вопрос удобства» и не может нести каких-либо негативных последствий, то подумайте о том, что логика приложения (даже не сейчас, в будущем) может подразумевать деление на это значение, и если там будет 0 — то мы можем получить ошибку, связанную с делением на 0. Если же там будет какое-то другое «магическое число», то мы просто получим неправильное значение. Это, кстати, ещё хуже, так как ошибку с делением на ноль мы поймаем и оттестируем, а вот арифметическую ошибку — вряд ли.
Резюмируя: nullable поля используем для значений, которые могут быть неизвестны (а могут быть и известны, зависит от бизнес-логики), но сущность может быть инициализирована без этих значений, и в таких случаях используем только nullable, а не 0, −1, −100500, int.MaxValue и прочий изврат. Аналогично и для стринговых значений: если бизнес-логика говорит, что может быть пустая строка, тогда пустая строка означает пустую строку, а null означает отсутствие установленного значения!
Резюме. Если бизнес-логика разрешает нам иметь неинициализированное поле класса после создания объекта этого класса — то нужно использовать nullable-тип, а не придумывать различные невероятные значения, которые в нашем представлении должны выполнять ту же функцию, что и null.
6. Проверка входных/выходных данных
Правило, о котором многие архитекторы забывают или не придают ему значения — валидация данных. Причём в идеале валидация должна быть на входе каждого уровня, потому как компоненты могут использоваться разными приложениями. В БД могут также писать те приложения, которые вы не можете проконтролировать.
Если какой-то уровень (например, слой бизнес-логики) получает данные из внешнего источника в широком понимании (это может быть и слой представления, передающий введённые пользователем данные с формы, и внешний веб-сервис, данные с которого также попадают в слой бизнес-логики через слой представления, и база данных, из которой выбираются данные), то этот слой должен быть уверен, что данные валидны. Если же он не может быть уверен, что они валидны, он должен провести их валидацию «на входе» (которую можно выделить в отдельный «подслой») и только «на входе». Никакой валидации внутри самих методов, которые обрабатывают бизнес-логику, быть не должно! Если метод производит вычисления, то он должен делать только это и априори считать, что входные данные валидны и там не будет, скажем, деления на 0 из-за неправильных входных данных. А если будет брошено такое исключение — то это должно означать, что ошибка в самой логике метода.
Конечно, нет особого смысла добавлять валидацию в каждый уровень, если мы уверены, что наш компонент получает данные только от другого, «доверенного» компонента, где пройдена вся валидация и мы можем проконтролировать эту валидацию. Но как только возникает ситуация, что наш компонент должен получать данные откуда-то извне — мы должны задуматься о его «личной» валидации.
Как только в нашу БД может писать ещё одно приложение, которое мы не контролируем — мы должны задуматься о валидации данных, полученных из этой БД. Хорошо, если мы можем написать правила валидации на уровне БД, но, честно сказать, MS SQL не позволяет легко и гибко писать сложные правила. Если правило чуть сложнее примитивной логики (которая контролируется ключами и constraints) — нужно писать триггер, который необходимо уже писать «в довесок», если мы используем Code-First подход, со всеми вытекающими сложностями.
Нужно взять за правило: как только есть вероятность, что данные на входе пришли от источника, минуя нашу систему валидации, то мы должны их валидировать ещё раз. Пусть это будет избыточная, повторная валидация, но «битые» данные успешно валят проекты, которые на стадии разработки были тщательно оттестированы и покрыты юнит-тестами с ног до головы. Причём валят именно на продакшене и нужно потратить определённое время, чтобы понять причину и чаще ещё больше времени, чтобы эти данные как-то привести в божеский вид. При этом бывает, что их привести в божеский вид не получается по причине того, что даже сам заказчик не может сказать, что в этих данных не так, откуда они появились, но удалять их категорически нельзя!
Резюме. Все данные, пришедшие от «ненадёжного» источника (будь то пользовательский интерфейс или БД, доступ к которой имеют другие приложения), всегда должны быть проверены настолько тщательно, насколько это возможно. Это позволит в будущем избежать трудноуловимых и тяжело исправляемых ошибок, связанных с поврежденными данными.
7. Использование Exceptions
Ещё один момент, с которым я часто сталкиваюсь, рассматривая чужие проекты или обсуждая их с архитекторами, — это использование Exceptions. Честно сказать, чаще встречается неправильное его использование, нежели правильное :)
Самый «тяжёлый» и один из самых распространённых случаев — это нечто подобное этому:
А. Вариант «заглушки»
try { // наша логика } catch { }
Или
try { // наша логика } catch (Exception exp) { }
Смысл этого действия — подавить исключение. Плюсы: возможно, «пронесёт» и ошибка не будет замечена на клиенте. Минусы: очень тяжело отлавливаемые ошибки и, возможно, порча данных, которые будут либо записаны в хранилище, либо возвращены клиенту.
Б. Вариант бессмысленного исключения
try { // наша логика } catch (Exception exp) { throw new TmsException(exp.Message); }
Это то, что сделал мой сербский друг © в проекте — ввёл какое-то своё исключение, причём общее для всех случаев. И он с гордым видом, что он правильно, как ему кажется, использует исключения, везде и всюду начал бросать это исключение с сообщением из исходного исключения. Какой смысл в этом подходе? Смысла нет никакого. Это его общее исключение TmsException абсолютно ничего нам не даёт, и этот префикс Tms для обозначения принадлежности к проекту TMS абсолютно ни о чём. Как всё же правильно использовать исключения?
Прежде всего, нужно понять смысл разных типов исключений: смысл в том, чтобы донести приложению, которое вызвало наш метод и поймало исключение, как правильно обработать это исключение! Если разницы в обработке
У нас есть метод
await geoService.GetPlaceByCoordinatesAsync(lat, lng);
который бросает exception:
public class InvalidGeoCoordinatesException : Exception { public double Lat { get; set; } public double Lng { get; set; } public InvalidGeoCoordinatesException(double lat, double lng) : base() { Lat = lat; Lng = lng; } }
Если переданы неправильные значения lat или(и) lng. Это необязательно должны быть значения, не попадающие в диапазон −90..90 и −180..180. Это может быть случай, когда мы должны проверить через какой-то внешний источник, что данная точка не является, например, водной поверхностью.
Бросает exception:
public class GeoCoordinatesNotResolvedException : Exception { }
Если внешний сервис для того, чтобы понять, суша там или вода, недоступен.
И бросает exception:
public class GeoCoordinatesDisabledException : Exception { }
Если такие координаты запрещено использовать нашему пользователю.
Теперь рассмотрим клиентов, которые используют наш метод GetPlaceByCoordinatesAsync. Это может быть Web API. Тогда мы должны вернуть конечному клиенту в первом случае ошибку 400 (Bad Request), во втором случае — 500 (Internal Server error), а в третьем — 403 (Forbidden):
try { await geoService.GetPlaceByCoordinatesAsync(lat, lng); //... } catch (InvalidGeoCoordinatesException geo_ex) { return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ...); } catch (GeoCoordinatesNotResolvedException notResolved_ex) { return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, ...); } catch GeoCoordinatesDisabledException disabled_ex) { return Request.CreateErrorResponse(HttpStatusCode.Forbidden, ...); }
То есть каждый тип исключения мы анализируем в своём блоке catch и результат обрабатывается по-разному. Если у нас это будет MVC приложения, то в первом случае мы можем записать какую-то ошибку в ModelState и вернуть страницу с этой ошибкой. Во втором случае — сделать редирект на какую-то особую страницу, предназначенную именно для этого, а в третьем — ещё как-то обработать. Никаких magic strings, записанных в сообщении общего исключения, которые нужно анализировать, парсить (а если есть локализация — то вообще ужас), никаких общих исключений. Каждому случаю, который может быть обработан по-особенному — своё исключение!
Резюме. Всегда уделяйте внимание правильной обработке ошибок. Почти что никогда (только в редких случаях) не «подавляйте» исключения. Подавление исключений довольно быстро превратит поддержку и дальнейшую разработку приложения в сущий ад. Каждая потенциальная ошибка должна быть перехвачена, обработана соответствующим образом и не должна остаться незаметной для разработчика.
8. Вычисляемые поля и их хранение в БД
Ещё одна любимая фишка горе-архитекторов — хранение вычисляемых полей в БД (или другом хранилище) без острой на то необходимости. Например, есть какое-то вычисляемое поле (по одной записи, по множеству или даже по нескольким таблицам, неважно), и его зачем-то обязательно пытаются записать в БД. Но запись этого поля в БД — дублирование данных и нарушение SRP (Single Responsible Principle).
Если поле вычисляемое, оно и должно, без крайней необходимости, вычисляться. Это может быть специальный класс-сервис с методами, занимающийся этими вычислениями или это может быть сделано средствами БД. Но в случае изменений каких-то данных ни у кого не должна болеть голова о том, что нужно где-то там, какое-то там поле пересчитать и перезаписать! Потому что, во-первых, это обязательно забудется и клиент рано или поздно получит устаревшие данные, которые будут расходиться с текущими данными. Во-вторых, должна быть одна и только одна причина для изменения.
Но что же за острая необходимость иногда хранить эти вычисляемые поля в БД, о которых я обмолвился ранее? Это те случаи, когда вычисления либо слишком сложные и, соответственно, слишком затратные по ресурсам, либо слишком затратные по времени, а результат должен быть возвращён здесь и сейчас. Этакая форма кэша.
Например, у нас есть сущность Trip, общее расстояние которой вычисляется внешним сервисом. Сама сущность меняется довольно редко, но её расстояние запрашивается часто. Было бы нерационально каждый раз делать запрос к внешнему сервису (который, к тому же, может быть ещё и платным), чтобы снова, в сотый раз, получить то же самое значение, потому что сама сущность не менялась и будет ли меняться — неизвестно. Но в таком случае у нас есть момент, который мы не должны упустить. В случае изменения записи, мы обязательно должны пересчитать и переписать её расстояние, иначе у нас просто будут неправильные данные. Это как раз тот случай с острой необходимостью. А хранить в отдельном поле каждой записи общее количество записей, относящихся к данному пользователю, не то, что бессмысленно, но и вредно!
Вот один из примеров, как не нужно делать.
Есть сущность Trip:
public partial class Trip { //.... private ICollection<TripStop> _tripStops; public decimal CalculatedDistance { get; set; } public virtual ICollection<TripStop> TripStops { get => _tripStops ?? (_tripStops = new List<TripStop>()); protected set => _tripStops = value; } }
Где CalculatedDistance — расстояние всего маршрута, от начальной до конечной точки.
И сущность TripStop:
public partial class TripStop { public int TripId { get; set; } public decimal DistanceFromPreviousStop { get; set; } public virtual Trip Trip { get; set; } }
Где DistanceFromPreviousStop — расстояние от предыдущей точки. Так как весь маршрут составляется из таких вот контрольних точек-стопов и у каждой задано расстояние от предыдущей, то нет никакого смысла иметь общее расстояние в сущности Trip, потому что общее расстояние высчитывается без особых ресурсозатрат. Мало того, это ещё и вредно, так как необходимо следить за тем, чтобы значение всегда было обновлено при каких-то изменениях, касающихся расстояния (по сути, это тоже нарушение SRP (Single Responsible Principle), о котором мы говорили ранее).
А вот если бы в этой модели мы вынуждены были бы по тем или иным причинам регулярно получать эти промежуточные расстояния через внешний сервис для одних и тех же промежуточных точек (постоянно меняется маршрут), особенно если это стоит какую-то копеечку, то тогда стоило бы задуматься и о таком поле.
Резюме. Без необходимости не храните в БД данные, которые можно получить путём вычисления или выборки других данных.
9. «Исключительные случаи» и «дублирование» в архитектуре
Ещё одно зло, постепенно вносящее бардак в проект — это нарушение принятой архитектуры проекта, «исключительные случаи». Это бывает, либо когда проще обойти архитектуру, нежели ей следовать, либо когда новый человек на проекте не хочет вникать в архитектуру или ломать свой привычный стиль разработки и начинает самодельничать — создавать свои сборки в тех случаях, когда стоит расширять существующие, переименовывать классы/интерфейсы/неймспейсы на свой лад и т. д. Большинство тимлидов и архитекторов закрывают глаза на это, не придавая должного значения. Но в первом случае такой подход (когда проще обойти архитектуру, нежели ей следовать) часто говорит о том, что архитектура слишком сложна или неправильна. А во втором случае может привести к тому, что в дальнейшем проект будет состоять из этаких лоскутков разноцветной ткани, сшитой воедино, с кодом, расбросанным по десяткам сборок, который должен быть в одном классе и конфликтами имён.
Поначалу может казаться, будто бы мы разделяем код между
Например, если разработчик «А» создал интерфейс в своей сборке:
public partial interface IDriverService { void InsertDriver(Driver driver); void UpdateDriver(Driver driver); //... }
А разработчик «Б» создал интерфейс с точно таким же именем в своей сборке:
public partial interface IDriverService { DriverDto GetDriver(int driverId); //... }
То рано или поздно оба интерфейса будут использованы в одном и том же классе, удваивая количество зависимостей. Они будут делать похожие действия и конфликтовать друг с другом.
Выглядит заманчиво изолировать каждого из разработчиков в своей песочнице, чтобы в случае чего ясно было, чья ошибка, а кто молодец, иметь меньше проблем со слиянием в системе контроля версий и чтобы они друг другу не мешали. Но в итоге цена за это оказывается более высокой, нежели плюшки поначалу. Чаще всего всё равно приходится сливать эти классы/сборки в один, разруливая конфликты и вычищая дублирующие классы.
Резюме. Избегайте дублирования классов, ответственных за одно и то же, даже если они имеют различные методы. Избегайте «исключительных случаев», когда мы отходим от общей архитектуры приложения, чтобы реализовать какой-то новый функционал.
10. Интерфейсы и их реализация
Как мы знаем из теории, интерфейсы представляют собой этакую абстракцию, описывающую методы и члены, которые должен иметь конкретный класс. Эта абстракция не должна быть привязана ни к какой конкретной реализации. Это как каркас для крыши дома, которую потом можно покрыть шифером, черепицей, металлочерепицей или ещё чем-нибудь. И я никогда не понимал, зачем класть саму реализацию в ту же сборку, где и интерфейс? В чём тогда вообще смысл интерфейса, если его реализация там же? Ведь интерфейс в таком случае косвенно имеет те же зависимости, что и какая-то конкретная реализация, о которой он ничего не должен знать!
Вот что я увидел в реальном проекте:
Обратите внимание, что интерфейсы и их реализации лежат в одной сборке. Это тот случай, как делать не стоит!
При написании юнит-тестов для самого интерфейса придётся тащить все зависимости конкретной реализации. Например, если интерфейс описывает методы для сохранения данных в хранилище, то сборка с юнит-тестами будет зависеть, например, от Entity Framework, если «дефолтная» реализация интерфейса лежит «рядом» с интерфейсом. Интерфейсы — это часть бизнес-логики и они должны быть в слое бизнес-логики. А каждая конкретная реализация интерфейса должна быть в своей «личной» сборке.
11. «Заглушки» и прочие способы подавления ошибок
В этом разделе я хочу поговорить о столь любимых некоторыми архитекторами и разработчиками различных «заглушках», которые позволяют «подавить» ошибки и создать иллюзию, что «всё хорошо». Одной из самых распространённых мы уже касались ранее — подавление каких-либо исключений:
try { // наша логика } catch { }
То есть код, обёрнутый в try, выполнился с ошибкой. Ну и ладно, сделаем вид, что всё хорошо. Клиент не увидит на веб-странице противной жёлтой ошибки с куском стека, а в настольном приложении не вылетит раздражающее окошко с последующим закрытием самого приложения. Но, начиная с этого момента, мы не можем гарантировать, что, если мы что-то писали в БД, наши данные не повреждены, или что клиент получил правильные данные, если мы выбирали данные из источника или проводили вычисления. Мы не можем быть уверены, что все дальнейшие вызовы по цепочке имеют правильные значения параметров. Кроме того, при таком подходе мы никогда даже не узнаем, что ошибка была. За такой код (за очень редкими исключениями) стоит разжаловать сеньора или архитектора в джуны или, что лучше всего, уволить сразу без права работать на этом проекте в будущем!
Аналогичный эффект могут иметь «значения по умолчанию» вместо значений null (о чём мы также говорили ранее), если они участвуют в каких-либо математических вычислениях.
Почему лучше получить крах приложения «здесь и сейчас», нежели подавление ошибки каким-либо способом? Потому что отловленная ошибка — это уже больше, нежели половина дела. Это также какая-то гарантия того, что приложение работает или правильно, или никак! Это гарантия того, что приложение не продолжит работать с заведомо неправильными данными! Это гарантия того, что ошибка не прошла незамеченной!
Каждый, кто хоть раз сталкивался с ситуацией, когда подобный код маскировал ошибку и «портил» данные, а всплывало это только спустя полгода. Причём для установления причины приходилось потратить неделю, а большинство данных в итоге были безвозвратно испорчены за эти полгода по причине этой одной замаскированной, а не правильно обработанной ошибки. Тот, кто с этим сталкивался, осознаёт серьёзность подобного подавления потенциальных ошибок в угоду пожеланий заказчика, который хочет, чтобы его приложение никогда не заканчивало работу аварийно :)
Резюме. По возможности не смешивайте абстракции (интерфейсы или базовые классы) с их реализацией. Обычно это не приводит к каким-то непоправимым последствиям, но в таком случае часто приходится тянуть ненужные зависимости при использовании только абстракций. Например, при модульном тестировании.
12. Неочевидность использования библиотечного кода
Ещё один довольно распространённый подход, потенциально создающий проблемы в будущем: разработчик добавляет поле в класс, которое может быть заполнено, а может быть и нет. Чаще всего это происходит тогда, когда разработчики или не считают нужным создавать специализированный класс для данного конкретного случая. Или даже наоборот, боятся плодить похожие классы. Но, во-первых, проблема «дублирования» полей в схожих классах вполне решается абстрактным базовым классом на уровне одного слоя. А во-вторых, создание общего класса для нескольких случаев нарушает принцип SRP.
Давайте посмотрим, что может произойти, если мы создаём класс с полями, которые не обязательно заполняются данными (когда эти данные есть).
Например, в Entity Framework (да и наверняка и в других ORM системах) для доступа к данным есть Lazy Loading подход. То есть данные загружаются «по требованию», когда к ним идёт непосредственно обращение. Это опция может быть как включена, так и отключена. Но если вы хотите сделать доступными извне (что уже не очень хорошо) некоторые из классов, имеющие подобные виртуальные свойства с загрузкой «по запросу», то уже делайте так, чтобы эта опция была включена!
Например, у вас есть класс:
public partial class User { private IList<UserRole> _userRoles; //.... public virtual IList<UserRole> UserRoles { get => _userRoles ?? (_userRoles = UserUserRoleMappings.Select(mapping => mapping.UserRole).ToList()); } }
Есть интерфейс со следующим методом:
public partial interface IUserService { //.... User GetUserByEmail(string email); }
Реализация представляет собой что-то вроде этого (весь дополнительный код и проверки откинуты) и находится в отдельной сборке, как и полагается реализации:
public partial class UserService : IUserService { public virtual User GetUserByUsername(string username) { var user = _dbContext.Users .Where(u => u.Username == username) .FirstOrDefault(); return user; } }
Теперь я хочу использовать этот метод в своём проекте:
var user = _userService.GetUserByUsername(model.Username); foreach (var role in user.UserRoles) { claims.Add(new Claim(ClaimsIdentity.DefaultRoleClaimType, role.SystemName)); }
Всё компилируется и даже, может быть, всё работает. А может и нет. Точнее работает, но неправильно. Зависит от того, включили ли в «настройках» эту lazy loading. И она (эта lazy loading) может быть даже и была включена, причём случайно. Это, кстати, худший вариант. Потому что точно также ещё «случайно» и выключат, и никто сразу даже и не поймёт, что случилось. Всё будет также компилироваться, никаких эксепшенов бросаться не будет. Только не будут установлены необходимые claims, что, вероятно, будет замечено уже на продакшене (потому что, вроде как, ничего и не сломалось). Причём может быть с непоправимыми последствиями, если из-за этих неустановленных claims кто-то не получил доступ, куда нужно, или наоборот получил, куда не стоило.
Получается, что корректность работы библиотеки зависит от настроек, которые включаются на стороне клиента. Но библиотека не должна зависеть от клиента и его прихотей. Она должна или работать корректно, или валиться с исключением, в котором должно быть чётко указано, что не так (например, включите lazy loading, хотя в таком случае уже лучше включить на уровне библиотеки). Это и есть та «неочевидность использования», когда библиотека корректно работает тогда и только тогда, когда на клиенте выполняются условия A, B и C. В остальных же случаях она просто работает некорректно.
Резюме. Выполнение кода не должно зависеть от каких-либо настроек, либо эта разница в выполнении должна быть очевидна. Код должен либо выполняться правильно, либо не выполняться вообще.
Выводы
В заключение хочу сказать, что разработчики довольно часто не придают должного значения архитектуре, концентрируясь на реализации конкретной задачи. Понятное дело, что заказчик хочет видеть конечный результат, который он может наглядно оценить, а не какую-то там эфемерную архитектуру. Задача же разработчика — убедить заказчика в том, что архитектура не менее, а скорее даже более важна, нежели реализация конкретной задачи.
Программное обеспечение с архитектурой, нарушающей базовые вещи и здравый смысл, со временем становится всё более и более тяжело модифицируемым. Количество ошибок начинает расти в геометрической прогрессии, а их исправление становится всё более сложным. Любое, даже незначительное исправление начинает влиять на другую функциональность приложения, а цена за внесение даже небольших изменений может стать настолько большой, что появляется ощущение, будто бы разработчики ничего не делают. И в итоге приложение без стройной архитектуры превращается в эдакого монстра, внесение изменений в которого становится просто нерациональным, а с ошибками проще смириться, нежели пытаться их исправить.