Как я превратил легаси-проект в конфетку за полгода. Том 1

September 13, 2023

Введение

Недавно мне удалось за шесть месяцев превратить полный боли, багов и сорванных дедлайнов легаси-проект на микросервисной архитектуре в полный фана проект на монолите, в котором мы стабильно попадаем в дедлайны, а существенные баги в коде бэка доходят до команды QA пару раз в квартал.

Эта эпопея не сильно уступает по объёму "Войне и Миру", поэтому я разбил её на четыре тома:

  1. Том первый. "Пациент скорее мёртв, чем жив" - описание проекта, история получения разрешения на реинжиниринг и планирование работ
  2. Том второй. "Доктор сказал в морг, значит в морг" - описание процесса работы, что пошло не по плану и как факапились в проде
  3. Том третий. "Анатомический атлас конфетки" - детали реализации "конфетки"
  4. Том четвёртый. "Это наследственное" - описание проблем, вызванных сменой архитектуры и стэка, а также необходимостью параллельной работы со старым бэком

Из этого тома вы узнаете, как не надо делать проекты, в каких случаях есть шанс получить разрешение на реинжиниринг и как эти шансы повысить, а так же способ планирования работ, который сработал в 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 у меня вызывали три штуки:

  1. Полное отсутствие каких бы то ни было тестов;
  2. Неуместное применение микросервисной архитектуры и её неумелая реализация;
  3. Использование вертикальной архитектуры на базе MediatR.

Разберу их чуть подробнее.

Отсутствие тестов

С тестами разбирать особо нечего - в кодовой базе не было ни одного теста. И если отсутствие функциональных и интеграционных тестов можно объяснить сложностью тестирования микросервисной архитектуры, то отсутствие даже юнит-тестов на моках - для меня загадка, проектов без тестов я не видел с 2014 года.

В итоге, как только мы начали пытаться вносить изменения в систему - на нас тут же широкой рекой хлынули баги.

Микросервисы

Изначально диаграмма контейнеров бэка проекта в нотации C4 выглядела так:

Диаграмма контейнеров бакэнда Проекта Э

Притом в проекте не было ни одной объективной причины, которая бы оправдывала сложности в разработке, привносимые микросервисной архитектурой.

Изначальная бек-команда состояла из одного бэк-разработчика, соотвественно банально не было людей, между которыми надо было минимизировать коммуникации и конфликты.

Различных требований к масштабированию частей системы тоже не было и не предвидится. На тот момент, когда я принял проект, он ещё не был зарелизан, и, соответственно, нагрузка на него была 0 rps, и все сервисы деплоились в единственном экземпляре. Сейчас, спустя 8 месяцев опытной эксплуатации, у нас пиковая нагрузка составляет 0.5 rps. А после выхода на расчётное количество пользователей, планируемая средняя нагрузка будет порядка 10-20 rps, а пиковая не превысит 100 rps. Соответственно, никаких проблем с масштабированием не предвиделось и не предвидится - пара инстансов, созданных из соображений высокой доступности, с лёгкостью будет закрывать весь входящий поток запросов.

Единственное место, где ожидается сильный перекос - это база данных дневников. Там мы ожидаем сотни миллионов-миллиарды строк, против десятков-сотен тысяч строк в остальных БД. Но это будет в далёком и прекрасном будущем, а пока что их 300К с ростом на 3К в день.

Инфраструктура тоже была типовая и общая для большинства сервисов - PostgreSQL, RabbitMQ, и всё. Только у модуля email-нотификаций была уникальная зависимость на smtp-транспорт.

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

Но неуместное использование микросервисов - было только половиной беды. Исполнение также хромало на обе ноги.

Во-первых, в проекте использовался разделяемый модуль shared - антипаттерн разработки МСов. Который среди прочего включал DTO API сервисов. Соответственно, разработка фичи, которая затрагивала несколько сервисов (а таких фич было большинство — подробности далее) зачастую состояла из следующих шагов:

  1. Обновить модуль shared;
  2. Собрать и опубликовать его;
  3. Попытаться обновить сервер, обнаружить проблему в интерфейсе и вернуться на шаг 1;
  4. Попытаться обновить клиент, обнаружить проблему в интерфейсе и вернуться на шаг 1;
  5. Задеплоить сервер;
  6. Задеплоить клиент.

Отдельную пикантность ситуации придавало наличие сервиса share, который отвечал за предоставление доступа к данным пациентов - я не сразу заучил кто из них кто.

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

Например, вот так выглядело дерево вызовов в юзкейсе предпросмотра группы пациентов:

Диаграмма контекста Проекта Э

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

А так выглядела генерация PDF-отчёта по наблюдаемому:

Диаграмма контекста Проекта Э

Знаю, что некоторые эксперты по МСА считают такие деревья сетевых вызовов нормой, но, на мой взгляд, это совершенно не эргономично и соответственно не должно быть нормой.

В результате у команды были все сложности, свойственные микросервисной архитектуре, и не было ни одной проблемы, которую бы она решала.

Вертикальная архитектура на базе MediatR

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

Если вкратце, то использованный подход можно охарактеризовать так:

  1. На каждую операцию в слое сервисов заводится отдельный класс-обработчик;
  2. Доступ к данным размазан между репозиториями (модификация через EntityFramework) и обработчиками (чтение через строковые константы с SQL в обработчиках);
  3. Контроллеры вместо прямого вызова сервисов отправляют команду в 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 же на этом фоне был мелким раздражителем, который приводил к:

  1. Усложнению навигации по коду - вместо прыжка через метод, приходилось выполнять поиск по команде;
  2. Необходимости на каждую операцию заводить по этой команде и её результату, даже если на вход подаётся один int, а на выход идёт один boolean;

После двух месяцев страданий у меня родилась гениальная идея:

the idea

* Эргономичный подход - этой мой гайдлайн разработки кодовых баз, которые хочется развивать, а не сжечь.

Генеральный план обретения счастья был следующий:

  1. Переписываем на Kotlin. Не потому что .net плох, а потому что я не смог найти вменяемого .net-разработчика ни в штат, ни на аутстафе, а на Kotlin у меня было два крутых юниора;
  2. Собираем всё в монолит. Это уберёт лишние сложности разработки в моменте и, что важнее, упростит нам рефакторинг архитектуры;
  3. На первом этапе сохраняем изначальную структуру модулей внутри монолита. Для того чтобы переход на новый бэк был плавный, бесшовный и с минимальными сроками и рисками;
  4. Покрываем всё функциональными тестами. Это решит нам проблемы с багами в моменте и развяжет руки для рефакторинга архитектуры;
  5. Реализацию модулей организуем в соответствии с функциональной/неизменяемой архитектурой. Это упростит нам тестирование бизнес-логики и чтение кода в будущем;
  6. После того как всё соберём в монолит, покрытий тестами не сцепленными с его реализацией - перепроектируем дизайн на базе эффектов и постепенно отрефакторим код. Это снизит сцепленность и повысит связанность системы и позволит нам быстрее реализовывать новые требования.

История получения разрешения на реинжиниринг

На самом деле, идея переписать всё по ЭП появилась у меня на второй день изучения проекта. Но, очевидно, затея просто так прийти к РП или заказчику и предложить всё переписать к чёртовой матери была обречена на провал. Поэтому свой генеральный план я вынашивал, старясь не привлекать внимание санитаров.

Благо состояние исходной кодовой базы было настолько плачевно, что за два месяца активных работ (точнее, попыток активной работы) это стало очевидным и для РП (а как выяснилось позже - и для заказчика). И 5 августа в треде о том, что уже второй дотнетчик делает задачи слишком долго, она написала:

А как crazy idea - Леш, а переписать все на джава это сколько долго?

Я ушёл на 15 минут, посчитал количество таблиц и эндпоинтов, просуммировал их, получил ~120, добавил +/- 50% и ответил: 60 - 180 человеко/дней.

Затем, 11 августа я написал РП такое сообщение:

Чёт не спится:) Мне идея переписать на Котлине кажется всё более разумной и реальной. Из оценки в 100 дней - 50% это покрытие автоматическими тестами, что надо делать в любом случае, чтобы не помереть под регрессиями. <…​> ну и у нас ещё есть переезд на свежий дотнет, который XXX оценил в 8 дней, и без тестов это скорее всего оптимистичная оценка. Короч давай продвигать эту авантюру Михаилу - будет страшно интересно :troll: но всё закончится хорошо и если начнём в августе - к НГ уже будут видны результаты в скорости и качестве работы

После этого, 14 августа РП написала, что заказчик готов выслушать наше предложение и мы назначили встречу.

К встрече я подготовил презентацию, которая содержала:

  1. "Погоны" - мой опыт, три успешных кейса реинжиниринга сопоставимого масштаба, работу над Эргономичным подходом;
  2. Вышеописанные проблемы проекта. Притом проблемы я приземлил на конкретные цифры - сколько заняли конкретные задачи и сколько обычно занимаю аналогичные задачи, к каким конкретным багам привела каждая из проблем, в целом статистику по багам в Проекте Э и других моих проектах;
  3. Описанный выше генеральный план (без смены стека);
  4. Предложение сменить стек, аргументированное тем, что разница в трудозатратах не такая большая, а в сроках и цене на самом деле будет выигрыш за счёт наличия хороших и проверенных кадров внутри компании;
  5. Детальное описание процесса реинжиниринга.

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

Заказчик сказал, что очень интересно и надо подумать. И ушёл. На месяц с лишним.

А 23 сентября РП и аккаунт на встрече с топ-менеджментом заказчика договорились о старте работ по реинжинирингу. Мне же осталось только не обос…​ облажаться.

Планирование реинжиниринга

В первую очередь хочу предупредить: я не профессиональный менеджер и при планировании реинжиниринга импровизировал на ходу. В моём случае это сработало и - если у вас нет другого варианта - вы можете пойти по тому же пути. Если же вы сами эксперт в управлении - лучше придерживайтесь своего мнения:) А если вы не эксперт, но можете делегировать эту работу эксперту - я бы на вашем месте так и сделал.

Импровизацию я начал с того, что попросил одного из разработчиков построить граф зависимостей оригинальной системы:

dependency graph

По факту это просто перечень REST-эндпоинтов (зелёные прямоугольники), RPC-эндпоинтов (синие) и обработчиков событий (красные) с обозначением вызовов, которые выполняются в процессе их исполнения. Затем я пробежался по ним беглым взглядом, оценил в "майках" - XS (4 часа), S (8 часов), M (24 часа), L (40 часов), XL (80 часов) и визуализировал "размерный ряд" насыщенностью цвета прямоугольника.

"Линейка" при этом была следующая:

  1. XS - Один тривиальный SQL-запрос или RPC-вызов;
  2. S - Два-три тривиальных SQL-запроса и/или обращения к другому сервису;
  3. M - Бизнес-логика не влазит на один экран;
  4. L - Применялся в двух случаях, если:
    1. Это был первый эндпоинт сервиса;
    2. Я не мог с ходу понять структуру и/или детали поведения эндпонита (понимая, при этом его эффекты);
  5. XL - у меня был только один. Это был метод добавления событий, их было семь видов, каждый из которых мапился на таблицу с PostgreSQL-наследованием и имел не совпадающую по структуре входящую DTO-шку.

Всего получилось работ на 354 xs или 177 человеко/дней. Это соответствует верхней границе первоначальной оценки в 60-180 дней, однако включает в себя несколько новых фич на ~60 человеко/дней, которые мы успели сделать к моменту выполнения детальной оценки.

После этого я нарезал все прямоугольники на спринты. Задачи в спринты я заталкивал довольно оптимистично, поэтому их получилось восемь штук по 160 человеко/часов в каждом — то есть всего 160 человеко/дней. Но решил, что пускай мы лучше будем целиться в срок с запасом и первый план оставил таким.

Нарезку я делал интуитивно, руководствуясь следующими принципами (и балансируя между ними):

  1. Набираем эндпоинты в спринты так, чтобы оценка задач в спринте примерно соответствовала суммарной мощности команды. Тут мотивация очевидна, я думаю;
  2. Идём снаружи внутрь - реинжинирим код только после того, как он перестаёт использоваться в оригинальной системе. Это позволило нам, во-первых, не делать RPC-сервер в своей версии (который после перехода на монолит нам не понадобится), а, во-вторых, исключило вероятность того, что мы сломаем старый код, не покрытый тестами;
  3. Фокусируемся на том, чтобы максимально быстро заканчивать каждый микросервис. То есть лучше за одну неделю сделать полностью один МС и за вторую полностью второй, чем за неделю сделать два МСа на 50% и за вторую неделю доделать их полностью. Это позволило нам минимизировать сложность роутинга в каждый момент времени, быстрее освобождать ресурсы кластера и, главное, минимизировать время, когда с БД одновременно работает старый и новый бэк, что могло привести к неприятным неожиданностям.
  4. Стараемся все эндпоинты на одном URL сделать за один спринт. Для упрощения роутинга и минимизации времени, когда с одними и теми же данными работают оба бэка;
  5. Эндпоинты на одном URL стараемся делать в таком порядке - GET, DELETE, PUT, POST. Это позволило снизить вероятность поломки старого бэка, какой-то "не такой" записью;
  6. Стараемся, чтобы над одним МСом (хотя бы в рамках спринта) работал только один человек. Это позволило нам минимизировать конфликты слияния.

После того как у нас появился план, нам оставалось только лишь его придерживаться:)

Заключение

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