Разработчики склонны влюбляться в свой продукт. Да, мы знаем, что в нём есть проблемы и каждый день имеем дело с последствиями не самых удачных решений. Для того, кого любим, мы всегда желаем самого лучшего. Хотим, чтобы он был современным, классным и чтобы его ждало только самое светлое будущее. Достичь этого бывает совсем нелегко, и в сегодняшней статье я хочу поделиться историей того, как простое, на первый взгляд, обновление веб-приложения с .NET Core 3.1 на .NET 6 вылилось в масштабный рефакторинг, которому, казалось, не было конца.

Как хорошо мы плохо жили с .NET Core 3.1

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

Приложение уже довольно долго использует .NET Core 3.1 и очевидно, что все остальные библиотеки тоже, мягко говоря, не самые современные. Например, для работы с БД используем EF Core 3, а значит для запросов, где необходимо подтащить много связанных сущностей, нельзя воспользоваться .AsSplitQuery (эта фича появилась только в EF Core 5, которую поверх .NET Core 3.1 установить не получится). Вместо этого приходится писать многословные запросы, вручную сшивать их и выплёвывать на фронт уже изрядно поистрепавшуюся сущность.

Вот если бы у нас было что-то поновее! Просто посмотрите, как переход на новую версию позволяет избавиться от полотна кода.
Вот если бы у нас было что-то поновее! Просто посмотрите, как переход на новую версию позволяет избавиться от полотна кода.

Поэтому для команды переход на новую версию был обусловлен не только желанием идти в ногу со временем, но и потребностью обновить остальные библиотеки, и особенно EF Core. Отдельно стоит упомянуть, что по сравнению с третьей версией даже .NET 5 имеет 20% прирост в производительности для веб-приложений, шестая ещё больше увеличивает этот разрыв. Всё это делало переход на более новую версию очень желанным.

Бац-бац — и в продакшен?

«Что ж, эта задачка не займёт много времени, — наивно думал я. — Просто обновляем TargetFramework для всех проектов, обновляем nuget и смело возвращаемся к бизнесовым таскам».

Проблемы начались сразу же.

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

Но ведь странно, что приложение работает, а тесты — нет. Чтобы разобраться, пришлось перерыть немало информации в интернете и даже залезть в исходный код EF Core 6. Усилия были не напрасны — у нас появилась гипотеза.

Я вот думаю, что сила в тестах. У кого тесты — тот и сильней

Тесты мы гоняем на выполняющейся в памяти базе данных EF Core (они очень быстрые, хотя их основным недостатком является то, что это всё-таки не настоящая база данных). После исследования причин мы поняли, что дело в баге внутри библиотеки EF Core 6 (появился ещё в пятой версии и планируется к починке только в седьмой). Он заключается в том, что при использовании строго типизированных первичных ключей (например, если в качестве айдишника используется класс) перестаёт правильно работать механизм JOIN’ов.

Для управления базами данных в проекте мы используем MySQL. Все первичные ключи в таблицах — это UUID (подробнее о работе MySQL с GUID/UUID можно почитать здесь). В коде UUID представлены классом, что и приводит к проблеме.

Значит, если заменить все UUID в коде на что-то другое, то мы сможем обновиться на новую версию .NET. Осталось найти, на что другое.

Как маленькая структура большому проекту помогла

С этого поиска мы и начали. У нас есть несколько своих библиотек, одна из них — публично доступный nuget Dodo.Primitives.Uuid, из которого родился небольшой фикс рантайма. Библиотека подойдёт всем, кто хочет использовать в своём приложении GUID-like первичные ключи, но по тем или иным причинам до сих пор стесняется.

В отличие от System.Guid, эта реализация UUID не перетасовывает строковое и бинарное представление структуры, а клиентский код может создать новый UUIDv1 практически таким же образом, как если бы он генерировался на стороне базы данных. При этом та часть байт, которая привязана ко времени, оказывается развёрнута. За счёт этого мы получаем монотонно возрастающую последовательность первичных ключей, что сильно увеличивает производительность.

Порядок в байтах отражает порядок в мыслях
Порядок в байтах отражает порядок в мыслях

Меня она заинтересовала в первую очередь тем, что вместо класса объект UUID представлен в ней структурой и в теории это могло помочь победить баг.

Проводим эксперимент в лабораторных условиях

Итак, мы нашли библиотеку, которая может решить нашу проблему. Что ж, начинаем заменять старые UUID на новые? Нет! Сначала нужно проверить гипотезу.

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

Проверить гипотезу лучше всего можно на простом консольном приложении — такой подход позволит исключить влияние других элементов системы. Для полной уверенности необходимо воссоздать весь предстоящий процесс обновления. Для этого подключаем библиотеки, которые позволят сымитировать текущую версию приложения (.NET Core 3.1 + EF Core 3.1.21 и нашу внутреннюю библиотеку, отвечающую за UUID) и напишем запрос в базу данных, который, как мы считаем, должен сломаться после обновления:

После запуска программа отрабатывает корректно. Обновляемся до .NET 6 и поднимаем версию EF Core до шестой. Запускаем приложение — ошибка воспроизводится. Теперь самое интересное: заменяем UUId на Dodo.Primitives.Uuid и снова запускаем. Баг не воспроизводится. Наша гипотеза подтвердилась!

Короткий период ликования сменяется нелёгкими мыслями о том, что необходимо заменить все UUId в приложении на новые, причём они не полностью совместимы. Напомню, что старые были классами, а новые — структурами. Что же их отличает? Отметаем в сторону наследование (иерархическую структуру первичных ключей в проекте мы пока не завели), остаётся то, что структуры не могут быть null, а вот классы ещё как могут. Это означает, что нам придётся разрулить в коде кучу случаев, где мы обNULLяем наши айдишники.

С новыми силами приступаем к рефакторингу

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

Всегда стоит помнить, что изменения должны быть контролируемыми, особенно если мы меняем логику приложения. Чем дольше наша программа находится в «разобранном» виде, тем больше шанс запутаться и облажаться на рефакторинге. Мы логику менять не собираемся, а значит (учитывая необходимое количество изменений) можно сделать рефакторинг более «ковровым» методом — меняем всё поиском и заменой текста.

Поэтому подключаем Dodo.Primitives.Uuid и соответственно заменяем using Dodo.Tools.Types (старая библиотека) на using Dodo.Primitives во всём решении, UUId на Uuid и правим другие такие же мелочи (например, старый UUId создавался как NewUUId, а новый — NewMySqlOptimized). По мере исправления сначала будет много ошибок, но после всех шагов их станет куда меньше. Остались только те случаи, которые нам придётся разрулить руками:

В такой ситуации мы всегда можем сделать Nullable Uuid, однако не стоит подходить к этому опрометчиво. Такое изменение очень быстро начинает «всплывать» и может привести к ситуации, когда все Uuid в приложении обрастают знаками вопроса. База данных — лучший источник истины в такой ситуации.

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

Без регресса нет прогресса

Мы добились зелёных тестов, но без тщательного регрессионного тестирования такую большую правку нельзя доставлять на прод. Запускаем приложение и проходимся по всем сценариям использования. Это очень нудная, но полезная работа: только так можно внезапно вспомнить, что твой фронтенд любит, чтобы все UUID были в верхнем регистре, и понять, что интуиция местами совсем не помогла выбрать правильно, где присвоить айдишнику null, а где оставить дефолтное значение.

Есть ли жизнь после рефакторинга?

Скорее всего, что-нибудь сломается. Мы сделали всё что могли, и если мы были умны и внимательны, если не потеряли мотивацию и не поленились, то с красными лицами к нам придёт совсем мало разработчиков, а проблемы удастся очень быстро поправить. Я считаю, что это отличная возможность выявить самые уязвимые и не покрытые тестами места в приложении и исправить.

Знаете, я многое узнал сегодня...

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

Чтобы рефакторинг прошёл успешно, стоит помнить о важных, на мой взгляд, вещах:

  • лучше обновляться постепенно, а не ждать, когда припечёт. Рефакторинг не должен быть обусловлен жёсткой потребностью, тогда будет больше шансов не собрать все возможные грабли;

  • будьте готовы к непредвиденным обстоятельствам. Задача может занять больше времени, чем вы рассчитывали;

  • не пренебрегайте тестами, даже если ваш проект собрался или вам кажется, что вы знаете всё про его слабости. Большие правки могут привести к ещё большим сюрпризам;

  • озвучивайте проблемы, обсуждайте их не только внутри своей команды. Если бы я не написал во внутренний чат, то и не узнал бы, что такая библиотека есть и она даже написана одним из наших ребят;

  • не бросайтесь сразу реализовывать свою идею, если она займёт много времени или её жизнеспособность вызывает большие сомнения. Соберите MVP и проверьте, чтобы не пришлось долго и мучительно переделывать весь проект.

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

Комментарии (20)


  1. pil0t
    01.02.2022 12:17

    Помню были времена, когда в додо идеологически отказывались от ORM.

    Кстати, а Price от Money всё ещё наследуется?


    1. fallingsappy Автор
      01.02.2022 12:24

      Это все один большой пранк ;)


  1. VolodjaT
    01.02.2022 12:43

    Del


  1. Matisumi
    01.02.2022 12:44
    +2

    Может имело смысл рефакторить не код, а тесты? Ведь проблема была в in-memory db, которая у вас, как я понял, использовалась только для тестов


    1. fallingsappy Автор
      01.02.2022 12:53

      Это определенно помогло бы решить проблему.

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

      Однако, нам нужно было быстрое решение. Не было возможности убедить всех, что нам нужно потратить N - количества времени на переделку тестов. С точки зрения non-IT это выглядит так: все работает отлично, ребята программисты почему то решили все сломать и теперь говорят, что им нужно сделать тесты по-другому, хотя раньше они отлично работали.


      1. ilya42
        02.02.2022 12:09

        А почему нельзя для тестов взять обычный 100% совместимый MySQL сервер в контейнере и просто к /var/lib/mysql подмонтировать tmpfs? Мы так делали на оном проекте. Тесты стали проходить втрое быстрее.


        1. fallingsappy Автор
          04.02.2022 12:23

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


  1. yurec_bond
    01.02.2022 12:49
    +7


    1. fallingsappy Автор
      01.02.2022 13:00
      +2

      Иногда в этой ванне еще и лава)))


  1. AlexDevFx
    01.02.2022 15:31
    +1

    Красавцы, мигрируют с 3.1 на 6 за 20 минут. Я тут медленно ползу от 4.8 к 6 уже месяц как))


    1. t13s
      01.02.2022 17:02
      +4

      Ну так 3.1 и 6 - слегка разные версии того же Core.

      А вот 4.8 и 6 - это суть вообще разные фреймворки. Мы так один проект среднего размера примерно полгода переезжали.


      1. fallingsappy Автор
        01.02.2022 17:35
        +2

        это да, но все равно спасибо! Удачи и скорейшего доползания, я думаю все получится.


      1. AlexDevFx
        01.02.2022 18:52

        Да там пляски с Identity, IoC плюс сам MVC принципиально отличается.


  1. mvv-rus
    01.02.2022 20:08

    Что же их отличает? Отметаем в сторону наследование (иерархическую структуру первичных ключей в проекте мы пока не завели), остаётся то, что структуры не могут быть null, а вот классы ещё как могут.

    А ещё класс передается по ссылке всегда (потому что ссылочный тип), а сутруктура — только если специально об этом попросить (указать ref). В результате те делегаты (например, типа Action<>), которые работают через побочный эффект — меняют свойства переданных в них экземплярах классов, сломаются, если заменить класс на структуру. А в .NET Core таких делегатов испольуется до много — начиная от задания значений в Options, которые и заканчивая всякими настройками всяких опций в ASP.NET.
    Так что раз вы серьезно не нарвались — вам повезло.


    1. lostmsu
      02.02.2022 03:37

      Качественный код, особенно на уровне БД, обычно использует только read only типы. С ними разница между классами и структурами не влияет на семантику.


      1. mvv-rus
        02.02.2022 07:31

        Качественный код, особенно на уровне БД, обычно использует только read only типы.

        Качественный код хорош в теории. А в реальной жизни приходится иметь дело с реальным кодом. И, насколько помню я тот же EF (EF 6 из Famework, к примеру), там сущности — они отнюдь не read only.
        Но я писал немнго не про это. Вот, допустим, захотелось вам при инициализации создать и передать внутрь программы на ASP.NET некое строго типизированное составное значние (обозначим его тип как T). Штатный способ это сделать — Options pattern: при инициализации указывается делегат, которым инициализируется это значение, а в процессе работы из DI-контейнера достается один из сервисов-интерфейсов IOptions/IOptionsSnapshot/IOptionsMonitor, который при инициализации реализующего его объекта вызовет этот делегат, а потом через интерфейс можно получить значение. Так вот, делегат этот имеет, в простейшем случае тип Action, т.е. значение он не возвращает, и что-то полезное он может сделать, только вызвав побочный эффект.
        Если у вас T — ссылочный тип (class) то все будет нормально, а если вы решите заменить его на struct — все сломается. И таких вот использований делегатов в .NET/ASP.NET не так уж и мало. Ну, допустим, для Options, на самом деле, ничего особо страшного не будет — код просто не скомпилится, т.к. у метода Configure есть ограничение where T:class, но ведь у самого типа делегата Action такого ограничения нет, и в него несложно передать после рефакторинга и струкутуру…


  1. mvv-rus
    02.02.2022 07:30

    Ой, не туда написал.


  1. pavelsc
    02.02.2022 12:35
    +1

    Зачем бизнесу продавать рефакторинг? Айти компании сами понимают необходимость, не-айти компании заплатят позже нефте- или банко- долларами полуторные или двойные рейты за уникальную возможность в дружном коллективе рефакторить легаси лапшу с зависимостями из 2010 года ))


  1. kawena54
    03.02.2022 11:06

    это хорошо вы в монгу не залезли там вообще 7 вариаций. Причем обычный C# считается Legacy (3тий сверху)


    1. fallingsappy Автор
      03.02.2022 11:08

      Я, кстати, обожаю монгу, но да, у нас ее в проекте нет. Черт, наш код становится легаси уже на стадии написания!!!)))