Разработчики склонны влюбляться в свой продукт. Да, мы знаем, что в нём есть проблемы и каждый день имеем дело с последствиями не самых удачных решений. Для того, кого любим, мы всегда желаем самого лучшего. Хотим, чтобы он был современным, классным и чтобы его ждало только самое светлое будущее. Достичь этого бывает совсем нелегко, и в сегодняшней статье я хочу поделиться историей того, как простое, на первый взгляд, обновление веб-приложения с .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 и проверьте, чтобы не пришлось долго и мучительно переделывать весь проект.
Основной идеей статьи было не только лишний раз уберечь вас от избыточной самоуверенности, но и показать несколько довольно простых и эффективных приёмов рефакторинга, которые помогут найти подход к, казалось бы, большой проблеме. Если решать задачу постепенно и с умом, то нет ничего невозможного. Можно провернуть любую смелую идею, обойти или исправить страшный баг, провести успешный эксперимент, который убедит команду, что ваше решение — самое-самое.