Ошибки в архитектуре ПО и как их избежать. Часть 1
По просьбе DOU IT-специалисты поделились ошибками, с которыми приходилось сталкиваться, в построении архитектуры ПО, выборе технологий, их использовании. Всего мы собрали 11 кейсов. В первой части рассмотрим случаи о несоответствии шаблона проектирования требованиям, об Event driven state machine, неправильной настройке ORM и прочем.
Иллюстрация Алины Самолюк
Орхан Гасымов, Digital Transformation Architect в GlobalLogic
Выбор технологий на основе личных преференций
Небольшой, но по итогу успешный случай произошел на одном из внутренних проектов. На начальном этапе его запуска нужно было определиться с командой и технологиями. Мы выбрали тимлида, который хотел стать архитектором. Цель — дать ему свободу решений на внутреннем проекте и менторить при необходимости.
Одна из задач проекта — выбрать базу данных. Так как проект не был большим, планировали использовать одну БД. Команда озвучила несколько вариантов, включая SQL и NoSQL. Тимлид очень хотел использовать именно NoSQL, с которой ему было интересней работать — MongoDB, чтобы не нормализовать данные и хранить JSON-массивы. При этом его аргументы «за» основывались на информации из интернета, без отсылки к конкретным задачам проекта.
Обсуждая проект, команда предлагала более подходящие решения, поэтому мы организовали несколько one-to-one сессий для обсуждения мотивов и побуждений тимлида. В итоге он получил ценный опыт по анализу требований и выбрал правильную технологию с наставником. С помощью разных подходов остановил свой выбор на совсем другой базе данных — MySQL с табличным представлением данных, так как она давала дополнительные организационные преимущества. При использовании со Spring Data JPA стало возможным получать подсказки о несовместимости на этапах сборки и тестирования проекта, даже если разработчик только пришел в команду или не сильно знаком с выбранными технологиями. Поскольку проект был небольшой и ресурсов было выделено немного, решили, что управление качеством и ошибками важнее, чем масштабирование.
Так случается, когда для специалиста важнее освоить новые знания и навыки, а не найти лучшее решение для проекта и запроса клиента. Не бывает плохих или хороших технологий. Это всего лишь инструмент для достижения успеха в деле. И нужно выбирать подходящий, иначе может пострадать весь проект.
Вывод. Важно правильно оценивать цели и задачи конкретного проекта. Например, чтобы открутить гайку, вы не выбираете ключ, который нравится, а тот, что подходит. Наша работа — это не только писать код или настраивать процессы, надо еще и правильно анализировать требования, уметь слушать и, самое главное, слышать клиента. Очень важно принимать решения на основе требований, а не личных преференций. Это показатель профессионализма. Причем это актуально и для PoC, как в этом случае, поскольку такие проекты часто становятся MVP и уходят в production.
Несоответствие шаблона проектирования требованиям
В одном из проектов оказалось, что решение не соответствовало запросам клиента: оно имело ограничение и не могло выдерживать большие нагрузки.
Изначальная задача заключалась в разработке приложения под небольшую клиентскую базу — до 10 000 пользователей. Решили создать его с микросервисной архитектурой. Так как масштаб был невелик, команда использовала с множеством сервисов всего две базы данных: SQL Server для отчетности и итоговых таблиц, доступных нескольким сервисам, и MongoDB для работы с исходными данными, поступающими в большом объеме.
Решение разрабатывали для индустриальной отрасли и много данных собирали с датчиков разного типа. Команда ориентировалась на то, что MongoDB хорошо масштабируется. Так как клиент не называл четких сроков, все расчеты были сделаны на основе первичных требований — один год.
Через полтора года разработок решение хорошо продавалось и количество активных пользователей выросло в 10 раз. Тут и начались сложности. Получалось, что микросервисная архитектура с точки зрения разбиения приложения на множество сервисов была не столь важна, важнее было правильно продумать архитектуру данных. В итоге performance приложения не устраивал ни заказчика, ни его клиентов.
При техническом аудите выяснилось, что детальные метрики всей системы и каждой компоненты по отдельности не собирали. Команда экспертов подробно изучила архитектуру и процессы, настроила сбор нужных метрик и выявила список самых проблемных, узких мест системы.
Как выяснилось, система использовала обе базы данных параллельно, где SQL Server однозначно требовал большего внимания к performance tuning & optimization, а MongoDB вообще не чувствовал проблем, даже можно было урезать количество ресурсов, выделенных на него.
Шаблон микросервисов был выбран правильно, но некорректно реализован. Основная проблема заключалась именно в БД, а не монолитах, так как микросервисы масштабируются за счет независимости, в том числе на уровне БД. При этом у каждого микросервиса должна быть своя БД (одна или более), но к ней не должны иметь доступ другие микросервисы. В этой ситуации это правило было нарушено.
Сами микросервисы требовали частичного переделывания, но это было нестрашно. А вот процессы тестирования, сбор метрик и автоматические алерты настроили почти с нуля. Так, 80% проблем решили с минимальными изменениями в коде, оставшиеся 20% распределили на последующие спринты и решали параллельно с внедрением новых фич.
Вывод. Основной проблемой оказалось несоответствие архитектурного стиля требованиям клиента. Клиент заранее знал про грядущие продажи и имел четкое представление о цифрах, а команда решила использовать подход, который был рассчитан на исходные требования. Таким образом, выбранная архитектура не только не соответствовала требованиям клиента, но и технически не соответствовала правильной микросервисной архитектуре. По сути, можно было бы разработать и монолит, но с более гибкой архитектурой данных. Ведь в итоге это и стало основным решением.
Поэтому важно правильно указывать требования с расчетом на будущее бизнеса, это может стать решающим фактором. А также точно оценить задачу, учитывать перспективу развития проекта через 2, 5, 10 лет.
Виталий Корж, Java Developer в Luxoft
Event driven state machine — лучший способ потеряться в своем решении
Что такое конечный автомат? Все — от простых поведенческих шаблонов до распределенных систем — может соответствовать данному критерию. В основном под этим понятием подразумеваются сложные схемы работы с данными. Для того чтобы работа в таких системах была максимально комфортной, схема должна быть понятной для пользователя и легко модифицируемой. Добавление новых процессоров не должно влиять на существующие сценарии. Цепочка вызовов для прошедшего сценария не должна без ведома изменяться со временем.
Исходя из своего опыта, я вывел несколько правил, которых стараюсь придерживаться.
Главное правило — не занимайтесь созданием таких систем с нуля. За годы развития индустрии было создано множество хороших продуктов/фреймворков, способных удовлетворить любые потребности. Чем больше пользователей, тем выше качество и стабильность продукта.
Следующее правило: определившись с фреймворком, следует избегать его модификаций. Если без модификаций не обойтись, выберите другой фреймворк.
Стоит отметить некоторую склонность к усложнению и раздуванию возможностей системы (сейчас у нас десять пользователей, а вдруг завтра будет миллион) на некоторых проектах.
Если так сложилось, что подходящего инструмента не нашлось, история в тему.
Нужно создать простой сценарий для интеграции загрузки медиафайла (картинки, видео, документы). Сценарий состоит из следующих операций: вычитать метаданные, сгенерировать превью, конвертировать в различные форматы, сохранить файлы на диск и в базу, отправить на индексацию. Для каждой операции есть свои сервисы. Требования ясны, последовательность действий тоже, осталось только определиться с механизмом.
Так как ничего сложного в этом нет, следуя принципу простоты, делаем сервис, который подписывает различные процессы на события. Каждый процесс обзаводится своим хендлером и генерирует событие при выполнении. Поскольку время на весь цикл загрузки стоит минимизировать, к тому же сама система должна масштабироваться, неплохим решением выглядит организация сигнальной системы, где каждый хендлер подписан на какое-то событие. Данный подход поможет распределить выполнение некоторых операций, что еще больше ускорит обработку.
В итоге получаем:
Под сервис выделяются несколько инстансов. Для обеспечения устойчивости добавляется сохранение состояния после каждого действия. Большие контексты (метаданные) также будут храниться в базе. Поскольку каждый шаг независимый, такое изменение не составит труда. Модель, основанная на событиях, по умолчанию принимает состояние извне. Как следствие, функционал по распределению задач внутри кластера идеально ложится на сервис очередей. Этот подход отлично решает поставленную перед ним задачу, и проект уходит в прод как MVP.
Развитие проекта не стоит на месте, и в схему требуется внести небольшие изменения. Добавляются вычисляемые поля сразу после этапа извлечения метаданных. Появляется возможность обновлять записи, необходимость синхронизации значений в разных таблицах, сбросить кеш основного приложения после обновления метаданных. Чтобы не плодить события, задачи реализуются как часть существующих хендлеров.
Появляются условия, индексация должна происходить после того, как метаданные сохранены и сгенерировано превью. Представленный подход не совсем способен обрабатывать условия, но есть возможность хранить контекст и гибкая реализация классов. Добавление условия проверки на запуск хендлера в нужном месте, и система продолжает работать.
На этом этапе в системе накопилось достаточно компромиссных решений, требующих пересмотра, но зачем трогать то, что и так работает.
Дальше нужно создать новый сценарий по обработке файлов, а так как сервис уже содержит множество наработок, то почему бы его и не использовать. В этот момент все накопленные компромиссы становятся несущественными. Задача выполнима, но выбранный подход породит еще больше спорных решений.
Выводы. Выбирая event driven модель, стоит использовать ее сильные стороны, такие как гибкость настройки и простота реализации. Чтобы этого придерживаться, избейте усложнения логики хендлеров и в случае надобности выносите функционал в отдельную реализацию. Если в сценарии со временем возникает потребность синхронизации нескольких событий, то его стоит пересобрать, не внося изменений в код. Так как поддержка нескольких сценариев не соответствует самой сути подхода, нужно продублировать сервис, это избавит от множества проблем.
P. S. Облачные платформы позволяют с легкостью решать подобные проблемы.
Владимир Иванченко, Product Owner в Poster
Встраивать интеграцию со сторонними сервисами прямо в ядро продукта
Poster — это облачная система автоматизации для кафе, баров и ресторанов. Такая система автоматизации (ее еще называют POS-система, или «касса») делает много важных и полезных вещей: ведет учет остатков на складе, печатает чеки, рассчитывает затраты на приготовление блюда, выручку и так далее.
Система автоматизации — это ядро ресторанного бизнеса, именно с ней интегрируются дополнительные сервисы (системы лояльности, видеонаблюдение, чат-боты). Несколько лет назад мы договорились с компанией InCust, разработчиком системы лояльности, об интеграции их продукта в наш. Это была наша первая интеграция со сторонним сервисом.
Poster написан на JS. Мы обложили коллбеками окно чекаута, и все завелось. Но вскоре обнаружили несколько проблем в таком подходе.
Зависимость от другой компании
Код периодически ломался из-за изменений со стороны партнера. По сути, мы добавили себе в продукт неконтролируемую зависимость от другой компании, которая в любой момент могла сломать прием заказов.
Лишний код
Интеграция была встроена в ядро и работала абсолютно для всех клиентов. Но оказалось, что не так уж много рестораторов пользовалось новым сервисом. Мы тянули лишний код клиентам, которым он был не нужен.
Неспособность масштабироваться
Чуть позже мы поняли, что просто сделать интеграцию недостаточно: ее нужно развивать и поддерживать, иначе продуктом никто не будет пользоваться. Одновременно с этим клиенты стали просить интеграции с другими системами, но, если бы мы начали подключать их самостоятельно, на это ушли бы ресурсы всего отдела разработки.
Чтобы исправить ошибку, мы проанализировали, какие методы понадобились для интеграции с InCust, и вынесли их в отдельный API. Весь код интеграции переехал в iFrame внутри кассы. Решение с iFrame позволило изолировать логику стороннего приложения от основного и, в случае каких-то проблем виджета, не ломать основной процесс кассы.
Сейчас iFrame с интеграцией мы подгружаем только тогда, когда клиент подключил ее. Лишний раз не нагружаем кассу.
Мы полностью выпилили из ядра сторонний код и постепенно передали его InCust на поддержку. Дальше партнеры смогли самостоятельно развивать интеграцию.
Выводы. Теперь мы не встраиваем сторонние интеграции в ядро продукта. Если решаем интегрироваться с каким-то сервисом, то делаем это через собственный API в отдельном проекте.
Андрей Губский, Software Architect, video intelligence AG
К чему может привести смешивание слоев приложения и неправильно настроенная ORM
В далёком 2008 году, когда моя карьера только начиналась, случилась забавная ситуация на одном из проектов. Ребята из соседней команды разрабатывали системы для компании, которая продавала подержанные автомобили в США. Одной из частей этой системы был интернет-каталог машин, где о любой модели можно было узнать множество деталей.
В процессе разработки все шло бодро. Поставленные клиентом задачи выполнялись, проект тестировался — все было отлично. Было проведено несколько демо, на которых проект одобрили. И решили его запустить.
Примерно через пару минут после того, как код проекта был залит на сервер и посетители стали заходить на новый сайт, вся система резко перестала работать. Судя по логам, проблема была на стороне базы данных. Сервер явно не справлялся с запросами, причем даже на небольшом количестве пользователей. Система переставала отвечать на запросы, когда пользователей было чуть более нескольких десятков. Решив посмотреть, что же там происходит, команда подключилась с помощью SQL Server Profiler к серверу базы данных и была довольно сильно удивлена.
Оказалось, что проблема заключалась в самописной ORM-системе, которая использовалась на тот момент в продукте. Через автоматическое заполнение связанных сущностей при создании объекта автомобиля ORM заполняла все поля и этого объекта. А затем поля этих полей и так далее. В итоге при создании каждого объекта на сторону приложения вытягивалась добрая половина базы данных.
И возможность ORM, которая должна была облегчить разработчику жизнь, избавив его от ручного указания, какие поля и когда должны быть заполнены, превратилась в причину серьезной проблемы производительности.
Как решали проблему? На уровне ORM добавили настройки, позволяющие более гибко настраивать автоматическое заполнение полей объекта. Сегодня же практически любая современная ORM имеет подобные настройки из коробки. Главное — не забывать ими пользоваться.
Эта и подобные ей проблемы часто возникают при неправильном проектировании приложения, когда смешиваются слой бизнес-логики и слой доступа к данным. Современные ORM сильно располагают к этому, особенно если технология предоставляет инструменты кодогенерации (как, например, Entity Framework). Однако всегда стоит четко понимать, где находится абстракция для реализации бизнес-логики, а где реализация сохранения и выгрузки данных. Разбиение системы на разные слои и раздельное применение классов BLL и DAL позволяет более четко и ясно понимать, где и какие данные система использует.
В целом разделение систем на слои DAL (Data Access Layer) и BLL (Business Logic Layer) уже стало одним из классических подходов при проектировании программных систем.
Преимущества, которые дает разделение:
- Слой доступа данных относительно легко изменить, не затрагивая бизнес-логику. Такое может потребоваться при смене СУБД (миграция с одной СУБД на другую) или при введении дополнительных СУБД (например, если к реляционной СУБД в какой-то момент необходимо добавить документоориентированную СУБД и если бизнес-сущности будут «собираться» из нескольких источников).
- Тестирование бизнес-логики становится гораздо проще.
- Упрощается повторное использование кода, например, когда единые правила валидации каких-либо сущностей могут быть применены в различных проектах одной компании или в различных продуктах.
Также стоит отметить еще один промах команды, который не относится напрямую к архитектуре проекта и программному коду, но относится к процессу — перед запуском проекта в лайв протестировали функциональность системы, но ни разу не провели нагрузочное тестирование, которое наверняка позволило бы выявить проблему гораздо раньше.
Вывод. Не забывайте грамотно разбивать свою систему на слои. Так сможете не только избежать некоторых чисто технических багов, но и упростить понимание системы для всех участников разработки. Старайтесь аккуратно использовать возможности ORM. Хоть это и прекрасный инструмент, он эффективен, только если вы хорошо знаете, что и как происходит с запросами внутри и с какими данными работаете.
Про сайд-эффекты и то, как их предотвратить
Однажды мне пришлось потратить примерно час своего времени на то, чтобы разобраться, почему некорректно работает запрос к API системы, которой я сейчас занимаюсь. При этом все юнит-тесты, отвечающие за логику различных отдельных сервисов и менеджеров, проходили на ура. Но вот когда было необходимо проверить весь цикл совместной отработки всех модулей системы, начиналась «магия». Как оказалось позже, проблема была в одном из промежуточных сервисов, который менял поле у входящего параметра.
Сам по себе сервис был простым — он преобразовывал один объект во второй. Поэтому этот сервис даже не стали покрывать тестами (что стоило бы сделать, и это было первой ошибкой). Однако по какой-то причине в него была внесена всего одна строчка кода, которая и привела в дальнейшем к целому часу почёсывания затылка и изучения истории коммитов. Выглядело это примерно так:
public async Task<SomeType> CreateSomeType(Request request) { if (SomeCheck(request)) { request.Url = url.GetDomainUrl(); } return new SomeType { Id = request.Id, ... Url = request.Url }; }
Нетрудно догадаться, что строка request.Url = url.GetDomainUrl();
и стала причиной будущих проблем. Тут мы видим сразу две логические проблемы. Первая — изменение уже сформированного объекта запроса. Вторая — изменение объекта, который пришел в качестве параметра. Не делайте так.
Чтобы исправить это, мы вынесли логику, отвечающую за переопределение домена, в отдельный метод, который явно возвращал новый экземпляр класса Request. А на уровне системы было принято соглашение о том, что передаваемые параметры должны быть иммутабельными.
Вывод. Такие проблемы характерны для проектов, которые уже существуют некоторое время, когда вдруг приходит запрос на изменение какой-то функциональности. Когда сроки горят, а задачу нужно сделать «на вчера», и появляются подобные ситуации. Чтобы их избежать, желательно согласовать несколько моментов.
Во-первых, одним из требований к реализации архитектуры в нашем проекте стало использование иммутабельных (неизменяемых) объектов везде, где это возможно и оправдано. Тут остановлюсь чуть детальнее на преимуществах иммутабельных объектов в сложных системах:
- Уменьшение вероятности появления сайд-эффектов в системе — иммутабельный объект не может быть «случайно» изменен на каком-то этапе своего жизненного цикла.
- Код становится более простым для понимания: уже ознакомившись с сигнатурой класса, новый разработчик, который пока еще не знаком с проектом, сразу будет понимать имеющиеся ограничения и особенности работы с объектами этого класса.
- Иммутабельные объекты и коллекции потокобезопасны: если объект или коллекцию нельзя изменить, то и проблем с синхронизацией между различными потоками у вас не будет.
Почему это важно именно для крупных проектов? Когда проект небольшой и над ним работает всего несколько человек, гораздо легче согласовывать изменения. Коммуникация не составляет проблем. Но если проект имеет долгую историю развития, состав команды меняется часто или количество участников команды постоянно растет, то нужны уже ограничения на уровне архитектуры самого приложения, которые помогут избежать непредусмотренных изменений объектов и сайд-эффектов в процессе обработки запросов. Стоит отметить, что в C# 9.0 был добавлен новый тип record, благодаря которому можно реализовывать требования неизменяемости более элегантно.
Во-вторых, мы ввели правило не менять значение входящих параметров метода, даже если язык программирования это позволяет. Так мы не создаем на крупном проекте двусмысленные ситуации, которые в будущем могут привести к неоднозначному пониманию работы системы.