Ошибки в архитектуре ПО и как их избежать

Thursday, February 18, 2021

К чему может привести смешивание слоев приложения и неправильно настроенная ORM

В далёком 2008 году, когда моя карьера только начиналась, случилась забавная ситуация на одном из проектов. Ребята из соседней команды разрабатывали системы для компании, которая продавала подержанные автомобили в США. Одной из частей этой системы был интернет-каталог машин, где о любой модели можно было узнать множество деталей.

В процессе разработки все шло бодро. Поставленные клиентом задачи выполнялись, проект тестировался — все было отлично. Было проведено несколько демо, на которых проект одобрили. И решили его запустить.

Примерно через пару минут после того, как код проекта был залит на сервер и посетители стали заходить на новый сайт, вся система резко перестала работать. Судя по логам, проблема была на стороне базы данных. Сервер явно не справлялся с запросами, причем даже на небольшом количестве пользователей. Система переставала отвечать на запросы, когда пользователей было чуть более нескольких десятков. Решив посмотреть, что же там происходит, команда подключилась с помощью SQL Server Profiler к серверу базы данных и была довольно сильно удивлена.

Оказалось, что проблема заключалась в самописной ORM-системе, которая использовалась на тот момент в продукте. Через автоматическое заполнение связанных сущностей при создании объекта автомобиля ORM заполняла все поля и этого объекта. А затем поля этих полей и так далее. В итоге при создании каждого объекта на сторону приложения вытягивалась добрая половина базы данных.

И возможность ORM, которая должна была облегчить разработчику жизнь, избавив его от ручного указания, какие поля и когда должны быть заполнены, превратилась в причину серьезной проблемы производительности.

Как решали проблему? На уровне ORM добавили настройки, позволяющие более гибко настраивать автоматическое заполнение полей объекта. Сегодня же практически любая современная ORM имеет подобные настройки из коробки. Главное — не забывать ими пользоваться.

Эта и подобные ей проблемы часто возникают при неправильном проектировании приложения, когда смешиваются слой бизнес-логики и слой доступа к данным. Современные ORM сильно располагают к этому, особенно если технология предоставляет инструменты кодогенерации (как, например, Entity Framework). Однако всегда стоит четко понимать, где находится абстракция для реализации бизнес-логики, а где реализация сохранения и выгрузки данных. Разбиение системы на разные слои и раздельное применение классов BLL и DAL позволяет более четко и ясно понимать, где и какие данные система использует.

В целом разделение систем на слои DAL (Data Access Layer) и BLL (Business Logic Layer) уже стало одним из классических подходов при проектировании программных систем.

Преимущества, которые дает разделение:

  1. Слой доступа данных относительно легко изменить, не затрагивая бизнес-логику. Такое может потребоваться при смене СУБД (миграция с одной СУБД на другую) или при введении дополнительных СУБД (например, если к реляционной СУБД в какой-то момент необходимо добавить документоориентированную СУБД и если бизнес-сущности будут «собираться» из нескольких источников).
  2. Тестирование бизнес-логики становится гораздо проще.
  3. Упрощается повторное использование кода, например, когда единые правила валидации каких-либо сущностей могут быть применены в различных проектах одной компании или в различных продуктах.

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

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

Про сайд-эффекты и то, как их предотвратить

Однажды мне пришлось потратить примерно час своего времени на то, чтобы разобраться, почему некорректно работает запрос к API системы, которой я сейчас занимаюсь. При этом все юнит-тесты, отвечающие за логику различных отдельных сервисов и менеджеров, проходили на ура. Но вот когда было необходимо проверить весь цикл совместной отработки всех модулей системы, начиналась «магия». Как оказалось позже, проблема была в одном из промежуточных сервисов, который менял поле у входящего параметра.

Сам по себе сервис был простым — он преобразовывал один объект во второй. Поэтому этот сервис даже не стали покрывать тестами (что стоило бы сделать, и это было первой ошибкой). Однако по какой-то причине в него была внесена всего одна строчка кода, которая и привела в дальнейшем к целому часу почёсывания затылка и изучения истории коммитов. Выглядело это примерно так:

public async Task<SomeTypeCreateSomeType(Request request)
{
	if (SomeCheck(request))
	{
		request.Url = url.GetDomainUrl();
	}
	
	return new SomeType
	{
		Id = request.Id,
		...
		Url = request.Url
	};
}

Нетрудно догадаться, что строка request.Url = url.GetDomainUrl(); и стала причиной будущих проблем. Тут мы видим сразу две логические проблемы. Первая — изменение уже сформированного объекта запроса. Вторая — изменение объекта, который пришел в качестве параметра. Не делайте так.

Чтобы исправить это, мы вынесли логику, отвечающую за переопределение домена, в отдельный метод, который явно возвращал новый экземпляр класса Request. А на уровне системы было принято соглашение о том, что передаваемые параметры должны быть иммутабельными.

Вывод. Такие проблемы характерны для проектов, которые уже существуют некоторое время, когда вдруг приходит запрос на изменение какой-то функциональности. Когда сроки горят, а задачу нужно сделать «на вчера», и появляются подобные ситуации. Чтобы их избежать, желательно согласовать несколько моментов.

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

  1. Уменьшение вероятности появления сайд-эффектов в системе — иммутабельный объект не может быть «случайно» изменен на каком-то этапе своего жизненного цикла.
  2. Код становится более простым для понимания: уже ознакомившись с сигнатурой класса, новый разработчик, который пока еще не знаком с проектом, сразу будет понимать имеющиеся ограничения и особенности работы с объектами этого класса.
  3. Иммутабельные объекты и коллекции потокобезопасны: если объект или коллекцию нельзя изменить, то и проблем с синхронизацией между различными потоками у вас не будет.

Почему это важно именно для крупных проектов? Когда проект небольшой и над ним работает всего несколько человек, гораздо легче согласовывать изменения. Коммуникация не составляет проблем. Но если проект имеет долгую историю развития, состав команды меняется часто или количество участников команды постоянно растет, то нужны уже ограничения на уровне архитектуры самого приложения, которые помогут избежать непредусмотренных изменений объектов и сайд-эффектов в процессе обработки запросов. Стоит отметить, что в C# 9.0 был добавлен новый тип record, благодаря которому можно реализовывать требования неизменяемости более элегантно.

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


Перейти к материалу