Как я превратил легаси-проект в конфетку за полгода. Том 1
September 13, 2023
Введение
Недавно мне удалось за шесть месяцев превратить полный боли, багов и сорванных дедлайнов легаси-проект на микросервисной архитектуре в полный фана проект на монолите, в котором мы стабильно попадаем в дедлайны, а существенные баги в коде бэка доходят до команды QA пару раз в квартал.
Эта эпопея не сильно уступает по объёму "Войне и Миру", поэтому я разбил её на четыре тома:
- Том первый. "Пациент скорее мёртв, чем жив" - описание проекта, история получения разрешения на реинжиниринг и планирование работ
- Том второй. "Доктор сказал в морг, значит в морг" - описание процесса работы, что пошло не по плану и как факапились в проде
- Том третий. "Анатомический атлас конфетки" - детали реализации "конфетки"
- Том четвёртый. "Это наследственное" - описание проблем, вызванных сменой архитектуры и стэка, а также необходимостью параллельной работы со старым бэком
Из этого тома вы узнаете, как не надо делать проекты, в каких случаях есть шанс получить разрешение на реинжиниринг и как эти шансы повысить, а так же способ планирования работ, который сработал в 1 из 1 случаев:)
Проект Э
В начале января 2019 года российский производитель медоборудования заказал разработку проекта в аутсорсинговой компании из ближнего зарубежья. Далее я буду называть этот проект Проектом Э.
Очень упрощённо проект можно считать медицинским дневником. Пользователи (пациенты) собирают с помощью устройства заказчика определённые показатели организма, которые сохраняются в БД на бэке. В дополнение к этому, пользователи могут вносить в систему дополнительную информацию, такую как приём лекарств, пищи, занятия спортом и т.п. А врачи, по приглашению пациентов, получают доступ к их дневникам.
Так выглядела диаграмма контекста модели C4 Проекта Э в конце 2019 года (картинка кликабельна):
Изначальная команда реализовала ~90% проекта к октябрю 2019 года и заказчик занялся сертификацией своего устройства, заморозив разработку. Сертификация продолжалась до 2022 года и к тому моменту, когда заказчик закончил сертификацию устройства и был готов возобновить работы, подрядчик отказался продолжать работы по проекту.
После этого заказчик начал искать нового подрядчика и в апреле 2022 года выбрал компанию Сибериан.Про.
В апреле-мае были преимущественно организационные работы и работы по подготовке мобильных приложений к публикации в Google Play и Apple Store, а я подключился к проекту в июне в качестве техлида проекта. И сразу понял, что я серьёзно вляпался.
Проблемы проекта
На второй день ознакомления с кодовой базой я в Slack-канал проекта написал такое сообщение:
Я нашёл. Оно никогда не перестанет быть смешным и не потеряет актуальность https://thecodinglove.com/content/039/gez23qJ.webm
Вот сразу мемасик из ссылки:
В тот же день я завёл в слаке канал project-e-wtf - куда сливал свой яд от всевозможных находок в коде.
Находок было очень много, но наибольший WTF у меня вызывали три штуки:
- Полное отсутствие каких бы то ни было тестов;
- Неуместное применение микросервисной архитектуры и её неумелая реализация;
- Использование вертикальной архитектуры на базе MediatR.
Разберу их чуть подробнее.
Отсутствие тестов
С тестами разбирать особо нечего - в кодовой базе не было ни одного теста. И если отсутствие функциональных и интеграционных тестов можно объяснить сложностью тестирования микросервисной архитектуры, то отсутствие даже юнит-тестов на моках - для меня загадка, проектов без тестов я не видел с 2014 года.
В итоге, как только мы начали пытаться вносить изменения в систему - на нас тут же широкой рекой хлынули баги.
Микросервисы
Изначально диаграмма контейнеров бэка проекта в нотации C4 выглядела так:
Притом в проекте не было ни одной объективной причины, которая бы оправдывала сложности в разработке, привносимые микросервисной архитектурой.
Изначальная бек-команда состояла из одного бэк-разработчика, соотвественно банально не было людей, между которыми надо было минимизировать коммуникации и конфликты.
Различных требований к масштабированию частей системы тоже не было и не предвидится. На тот момент, когда я принял проект, он ещё не был зарелизан, и, соответственно, нагрузка на него была 0 rps, и все сервисы деплоились в единственном экземпляре. Сейчас, спустя 8 месяцев опытной эксплуатации, у нас пиковая нагрузка составляет 0.5 rps. А после выхода на расчётное количество пользователей, планируемая средняя нагрузка будет порядка 10-20 rps, а пиковая не превысит 100 rps. Соответственно, никаких проблем с масштабированием не предвиделось и не предвидится - пара инстансов, созданных из соображений высокой доступности, с лёгкостью будет закрывать весь входящий поток запросов.
Единственное место, где ожидается сильный перекос - это база данных дневников. Там мы ожидаем сотни миллионов-миллиарды строк, против десятков-сотен тысяч строк в остальных БД. Но это будет в далёком и прекрасном будущем, а пока что их 300К с ростом на 3К в день.
Инфраструктура тоже была типовая и общая для большинства сервисов - PostgreSQL, RabbitMQ, и всё. Только у модуля email-нотификаций была уникальная зависимость на smtp-транспорт.
Никаких других факторов, требующих такого уровня изоляции частей системы, тоже не было.
Но неуместное использование микросервисов - было только половиной беды. Исполнение также хромало на обе ноги.
Во-первых, в проекте использовался разделяемый модуль shared - антипаттерн разработки МСов. Который среди прочего включал DTO API сервисов. Соответственно, разработка фичи, которая затрагивала несколько сервисов (а таких фич было большинство — подробности далее) зачастую состояла из следующих шагов:
- Обновить модуль shared;
- Собрать и опубликовать его;
- Попытаться обновить сервер, обнаружить проблему в интерфейсе и вернуться на шаг 1;
- Попытаться обновить клиент, обнаружить проблему в интерфейсе и вернуться на шаг 1;
- Задеплоить сервер;
- Задеплоить клиент.
Отдельную пикантность ситуации придавало наличие сервиса share, который отвечал за предоставление доступа к данным пациентов - я не сразу заучил кто из них кто.
Во-вторых, микросервисы, опять же вопреки основополагающему принципу их дизайна, обладали высокой степенью сцепленности - практически каждая операция включала в себя синхронные обращения к другим микросервисам, которые в процессе обработки запросов снова шли в следующие микросервисы.
Например, вот так выглядело дерево вызовов в юзкейсе предпросмотра группы пациентов:
В системе администраторы могут создавать группы из пациентов, наблюдаемых определёнными врачами. И в юзкейсе создания новой группы на первом этапе (синие стрелки) выполняется выбор врачей с поиском по емейлу, а потом отображается состав группы для предпросмотра (зелёные стрелки).
А так выглядела генерация PDF-отчёта по наблюдаемому:
Знаю, что некоторые эксперты по МСА считают такие деревья сетевых вызовов нормой, но, на мой взгляд, это совершенно не эргономично и соответственно не должно быть нормой.
В результате у команды были все сложности, свойственные микросервисной архитектуре, и не было ни одной проблемы, которую бы она решала.
Вертикальная архитектура на базе MediatR
Это спорная тема и знаю, что такой подход популярен в .net-сообществе, однако мне он не нравится. Для вертикальной архитектуры не существует единого определения и можно нагуглить множество разных вариаций её реализации. Вариант, который был использован в Проекте Э, довольно подробно описан в этом посте.
Если вкратце, то использованный подход можно охарактеризовать так:
- На каждую операцию в слое сервисов заводится отдельный класс-обработчик;
- Доступ к данным размазан между репозиториями (модификация через EntityFramework) и обработчиками (чтение через строковые константы с SQL в обработчиках);
- Контроллеры вместо прямого вызова сервисов отправляют команду в MediatR и он сам как-то определяет в какой класс-обработчик её передать.
Как выглядел типичный код
namespace ProjectE.Share.Api.Controllers.Queries.GetObservables
{
public class GetObservablesQueryHandler : IRequestHandler<GetObservablesQuery, GetObservablesQueryResult>
{
// Поля и конструктор
public async Task<GetObservablesQueryResult> Handle(GetObservablesQuery request, CancellationToken cancellationToken)
{
var startIndex = request.PageSize * (request.PageIndex - 1);
const string sql = @"select count(*)
from observers o
where o.user_id = @userId and not o.is_deleted;
select o.observable_id, obs.user_id
from observers o
inner join observables obs on obs.id = o.observable_id
where o.user_id = @userId and not o.is_deleted
limit @pageSize offset @startIndex";
var result = new ObservablesQueryResultDto {Meta = new MetaDataDto {CurrentPage = request.PageIndex, PageSize = request.PageSize}};
using (var connection = new NpgsqlConnection(_options.Value.ConnectionString))
{
await connection.OpenAsync(cancellationToken);
using (var multi = await connection.QueryMultipleAsync(sql,
new
{
userId = request.UserId,
pageSize = request.PageSize,
startIndex
}))
{
result.Meta.TotalItems = await multi.ReadFirstAsync<long>();
result.Items = await ParseObservables(await multi.ReadAsync<dynamic>());
}
}
return new GetObservablesQueryResult(result);
}
// Вспомогательные методы маппинга данных
}
}
А а в соседней директории был какой-нибудь такой код:
// Аналогичный "заголовок"
public async Task<GetObservablesBySearchQueryResult> Handle(GetObservablesBySearchQuery request,
CancellationToken cancellationToken)
{
var startIndex = request.PageSize * (request.PageIndex - 1);
const string sql = @"select o.observable_id, obs.user_id
from observers o
inner join observables obs on obs.id = o.observable_id
where o.user_id = @userId and not is_deleted
limit @pageSize offset @startIndex";
var result = new ObservablesQueryResultDto { Meta = new MetaDataDto { CurrentPage = request.PageIndex, PageSize = request.PageSize } };
using (var connection = new NpgsqlConnection(_options.Value.ConnectionString))
{
await connection.OpenAsync(cancellationToken);
using (var multi = await connection.QueryMultipleAsync(sql,
new
{
userId = request.UserId,
pageSize = 100,
startIndex
}))
{
result.Items = await ParseObservables(await multi.ReadAsync<dynamic>(), request.Search);
result.Meta.TotalItems = result.Items.Length;
}
}
return new GetObservablesBySearchQueryResult(result);
}
// Аналогичный "футер"
А в "двоюродной" директории был такой код:
namespace ProjectE.Share.Api.Controllers.Commands.UpdateObserverCustomData
{
public class UpdateObserverCustomDataCommandHandler : IRequestHandler<UpdateObserverCustomDataCommand, UpdateObserverCustomDataCommandResult>
{
// Аналогичный "заголовок"
public async Task<UpdateObserverCustomDataCommandResult> Handle(UpdateObserverCustomDataCommand command, CancellationToken cancellationToken)
{
var observable = await _unitOfWork.ObservableRepository.GetObservableByUserId(command.UserId);
if (observable == null) return new UpdateObserverCustomDataCommandResult(CustomStatusCodes.NotFoundUserAccount, new[] { "Not found user observable account." });
var result = await ChangeObserverCustomName(observable, command.CustomName, command.InviteId, cancellationToken);
if (!result)
_logger.LogError($"Can't change observer #{command.InviteId} custom name");
return new UpdateObserverCustomDataCommandResult(result);
}
// Аналогичный "футер"
}
}
namespace ProjectE.Share.Db.Repositories
{
public class ObservableRepository : IObservableRepository
{
public async Task<Observable> GetObservableByUserId(int userId)
{
return await _context.Set<Observable>()
.Include(o => o.Invites)
.ThenInclude(o=>o.Status)
.Include(o => o.Observers)
.SingleOrDefaultAsync(o => o.UserId == userId);
}
}
}
Тут надо обратить внимание на то, что доступ к данным в двух классах содержался в строковых константах с SQL-ем, а в одном - в LINQ-выражении.
И из-за этой размазанности логики доступа к данным и отсутствия тестов у нас практически в каждом изменении были баги из серии "забыли поправить SQL в одном из слайсов".
MediatR же на этом фоне был мелким раздражителем, который приводил к:
- Усложнению навигации по коду - вместо прыжка через метод, приходилось выполнять поиск по команде;
- Необходимости на каждую операцию заводить по этой команде и её результату, даже если на вход подаётся один int, а на выход идёт один boolean;
После двух месяцев страданий у меня родилась гениальная идея:
* Эргономичный подход - этой мой гайдлайн разработки кодовых баз, которые хочется развивать, а не сжечь.
Генеральный план обретения счастья был следующий:
- Переписываем на Kotlin. Не потому что .net плох, а потому что я не смог найти вменяемого .net-разработчика ни в штат, ни на аутстафе, а на Kotlin у меня было два крутых юниора;
- Собираем всё в монолит. Это уберёт лишние сложности разработки в моменте и, что важнее, упростит нам рефакторинг архитектуры;
- На первом этапе сохраняем изначальную структуру модулей внутри монолита. Для того чтобы переход на новый бэк был плавный, бесшовный и с минимальными сроками и рисками;
- Покрываем всё функциональными тестами. Это решит нам проблемы с багами в моменте и развяжет руки для рефакторинга архитектуры;
- Реализацию модулей организуем в соответствии с функциональной/неизменяемой архитектурой. Это упростит нам тестирование бизнес-логики и чтение кода в будущем;
- После того как всё соберём в монолит, покрытий тестами не сцепленными с его реализацией - перепроектируем дизайн на базе эффектов и постепенно отрефакторим код. Это снизит сцепленность и повысит связанность системы и позволит нам быстрее реализовывать новые требования.
История получения разрешения на реинжиниринг
На самом деле, идея переписать всё по ЭП появилась у меня на второй день изучения проекта. Но, очевидно, затея просто так прийти к РП или заказчику и предложить всё переписать к чёртовой матери была обречена на провал. Поэтому свой генеральный план я вынашивал, старясь не привлекать внимание санитаров.
Благо состояние исходной кодовой базы было настолько плачевно, что за два месяца активных работ (точнее, попыток активной работы) это стало очевидным и для РП (а как выяснилось позже - и для заказчика). И 5 августа в треде о том, что уже второй дотнетчик делает задачи слишком долго, она написала:
А как crazy idea - Леш, а переписать все на джава это сколько долго?
Я ушёл на 15 минут, посчитал количество таблиц и эндпоинтов, просуммировал их, получил ~120, добавил +/- 50% и ответил: 60 - 180 человеко/дней.
Затем, 11 августа я написал РП такое сообщение:
Чёт не спится:) Мне идея переписать на Котлине кажется всё более разумной и реальной. Из оценки в 100 дней - 50% это покрытие автоматическими тестами, что надо делать в любом случае, чтобы не помереть под регрессиями. <…> ну и у нас ещё есть переезд на свежий дотнет, который XXX оценил в 8 дней, и без тестов это скорее всего оптимистичная оценка. Короч давай продвигать эту авантюру Михаилу - будет страшно интересно :troll: но всё закончится хорошо и если начнём в августе - к НГ уже будут видны результаты в скорости и качестве работы
После этого, 14 августа РП написала, что заказчик готов выслушать наше предложение и мы назначили встречу.
К встрече я подготовил презентацию, которая содержала:
- "Погоны" - мой опыт, три успешных кейса реинжиниринга сопоставимого масштаба, работу над Эргономичным подходом;
- Вышеописанные проблемы проекта. Притом проблемы я приземлил на конкретные цифры - сколько заняли конкретные задачи и сколько обычно занимаю аналогичные задачи, к каким конкретным багам привела каждая из проблем, в целом статистику по багам в Проекте Э и других моих проектах;
- Описанный выше генеральный план (без смены стека);
- Предложение сменить стек, аргументированное тем, что разница в трудозатратах не такая большая, а в сроках и цене на самом деле будет выигрыш за счёт наличия хороших и проверенных кадров внутри компании;
- Детальное описание процесса реинжиниринга.
Так же в презентации я явно проговорил, что все оценки и сроки действительны только при заморозке работ по оригинальному бэку и реализации всех новых фич только в новом бэке.
Заказчик сказал, что очень интересно и надо подумать. И ушёл. На месяц с лишним.
А 23 сентября РП и аккаунт на встрече с топ-менеджментом заказчика договорились о старте работ по реинжинирингу. Мне же осталось только не обос… облажаться.
Планирование реинжиниринга
В первую очередь хочу предупредить: я не профессиональный менеджер и при планировании реинжиниринга импровизировал на ходу. В моём случае это сработало и - если у вас нет другого варианта - вы можете пойти по тому же пути. Если же вы сами эксперт в управлении - лучше придерживайтесь своего мнения:) А если вы не эксперт, но можете делегировать эту работу эксперту - я бы на вашем месте так и сделал.
Импровизацию я начал с того, что попросил одного из разработчиков построить граф зависимостей оригинальной системы:
По факту это просто перечень REST-эндпоинтов (зелёные прямоугольники), RPC-эндпоинтов (синие) и обработчиков событий (красные) с обозначением вызовов, которые выполняются в процессе их исполнения. Затем я пробежался по ним беглым взглядом, оценил в "майках" - XS (4 часа), S (8 часов), M (24 часа), L (40 часов), XL (80 часов) и визуализировал "размерный ряд" насыщенностью цвета прямоугольника.
"Линейка" при этом была следующая:
- XS - Один тривиальный SQL-запрос или RPC-вызов;
- S - Два-три тривиальных SQL-запроса и/или обращения к другому сервису;
- M - Бизнес-логика не влазит на один экран;
- L - Применялся в двух случаях, если:
- Это был первый эндпоинт сервиса;
- Я не мог с ходу понять структуру и/или детали поведения эндпонита (понимая, при этом его эффекты);
- XL - у меня был только один. Это был метод добавления событий, их было семь видов, каждый из которых мапился на таблицу с PostgreSQL-наследованием и имел не совпадающую по структуре входящую DTO-шку.
Всего получилось работ на 354 xs или 177 человеко/дней. Это соответствует верхней границе первоначальной оценки в 60-180 дней, однако включает в себя несколько новых фич на ~60 человеко/дней, которые мы успели сделать к моменту выполнения детальной оценки.
После этого я нарезал все прямоугольники на спринты. Задачи в спринты я заталкивал довольно оптимистично, поэтому их получилось восемь штук по 160 человеко/часов в каждом — то есть всего 160 человеко/дней. Но решил, что пускай мы лучше будем целиться в срок с запасом и первый план оставил таким.
Нарезку я делал интуитивно, руководствуясь следующими принципами (и балансируя между ними):
- Набираем эндпоинты в спринты так, чтобы оценка задач в спринте примерно соответствовала суммарной мощности команды. Тут мотивация очевидна, я думаю;
- Идём снаружи внутрь - реинжинирим код только после того, как он перестаёт использоваться в оригинальной системе. Это позволило нам, во-первых, не делать RPC-сервер в своей версии (который после перехода на монолит нам не понадобится), а, во-вторых, исключило вероятность того, что мы сломаем старый код, не покрытый тестами;
- Фокусируемся на том, чтобы максимально быстро заканчивать каждый микросервис. То есть лучше за одну неделю сделать полностью один МС и за вторую полностью второй, чем за неделю сделать два МСа на 50% и за вторую неделю доделать их полностью. Это позволило нам минимизировать сложность роутинга в каждый момент времени, быстрее освобождать ресурсы кластера и, главное, минимизировать время, когда с БД одновременно работает старый и новый бэк, что могло привести к неприятным неожиданностям.
- Стараемся все эндпоинты на одном URL сделать за один спринт. Для упрощения роутинга и минимизации времени, когда с одними и теми же данными работают оба бэка;
- Эндпоинты на одном URL стараемся делать в таком порядке - GET, DELETE, PUT, POST. Это позволило снизить вероятность поломки старого бэка, какой-то "не такой" записью;
- Стараемся, чтобы над одним МСом (хотя бы в рамках спринта) работал только один человек. Это позволило нам минимизировать конфликты слияния.
После того как у нас появился план, нам оставалось только лишь его придерживаться:)
Заключение
В следующем посте я расскажу, как мы организовали процесс работы команды, что пошло не по плану и как мы факапились в проде.