Разработчики склонны влюбляться в свой продукт. Да, мы знаем, что в нём есть проблемы и каждый день имеем дело с последствиями не самых удачных решений. Для того, кого любим, мы всегда желаем самого лучшего. Хотим, чтобы он был современным, классным и чтобы его ждало только самое светлое будущее. Достичь этого бывает совсем нелегко, и в сегодняшней статье я хочу поделиться историей того, как простое, на первый взгляд, обновление веб-приложения с .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)
Matisumi
01.02.2022 12:44+2Может имело смысл рефакторить не код, а тесты? Ведь проблема была в in-memory db, которая у вас, как я понял, использовалась только для тестов
fallingsappy Автор
01.02.2022 12:53Это определенно помогло бы решить проблему.
Можно перейти на реальную базу данных, которая бы разворачивалась в каком-нибудь кубе каждый раз когда решение собирается на гитхабе и там бы мы прогоняли тесты.
Однако, нам нужно было быстрое решение. Не было возможности убедить всех, что нам нужно потратить N - количества времени на переделку тестов. С точки зрения non-IT это выглядит так: все работает отлично, ребята программисты почему то решили все сломать и теперь говорят, что им нужно сделать тесты по-другому, хотя раньше они отлично работали.ilya42
02.02.2022 12:09А почему нельзя для тестов взять обычный 100% совместимый MySQL сервер в контейнере и просто к /var/lib/mysql подмонтировать tmpfs? Мы так делали на оном проекте. Тесты стали проходить втрое быстрее.
fallingsappy Автор
04.02.2022 12:23Я думаю, что мы рано или поздно придем к чему то подобному. Возможность тестировать на настоящей базе данных очень заманчивая. А если надо локально запустить тесты, то как делаете?
AlexDevFx
01.02.2022 15:31+1Красавцы, мигрируют с 3.1 на 6 за 20 минут. Я тут медленно ползу от 4.8 к 6 уже месяц как))
t13s
01.02.2022 17:02+4Ну так 3.1 и 6 - слегка разные версии того же Core.
А вот 4.8 и 6 - это суть вообще разные фреймворки. Мы так один проект среднего размера примерно полгода переезжали.
fallingsappy Автор
01.02.2022 17:35+2это да, но все равно спасибо! Удачи и скорейшего доползания, я думаю все получится.
mvv-rus
01.02.2022 20:08Что же их отличает? Отметаем в сторону наследование (иерархическую структуру первичных ключей в проекте мы пока не завели), остаётся то, что структуры не могут быть null, а вот классы ещё как могут.
А ещё класс передается по ссылке всегда (потому что ссылочный тип), а сутруктура — только если специально об этом попросить (указать ref). В результате те делегаты (например, типа Action<>), которые работают через побочный эффект — меняют свойства переданных в них экземплярах классов, сломаются, если заменить класс на структуру. А в .NET Core таких делегатов испольуетсядомного — начиная от задания значений в Options, которые и заканчивая всякими настройками всяких опций в ASP.NET.
Так что раз вы серьезно не нарвались — вам повезло.lostmsu
02.02.2022 03:37Качественный код, особенно на уровне БД, обычно использует только read only типы. С ними разница между классами и структурами не влияет на семантику.
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 такого ограничения нет, и в него несложно передать после рефакторинга и струкутуру…
pavelsc
02.02.2022 12:35+1Зачем бизнесу продавать рефакторинг? Айти компании сами понимают необходимость, не-айти компании заплатят позже нефте- или банко- долларами полуторные или двойные рейты за уникальную возможность в дружном коллективе рефакторить легаси лапшу с зависимостями из 2010 года ))
kawena54
03.02.2022 11:06это хорошо вы в монгу не залезли там вообще 7 вариаций. Причем обычный C# считается Legacy (3тий сверху)
fallingsappy Автор
03.02.2022 11:08Я, кстати, обожаю монгу, но да, у нас ее в проекте нет. Черт, наш код становится легаси уже на стадии написания!!!)))
pil0t
Помню были времена, когда в додо идеологически отказывались от ORM.
Кстати, а Price от Money всё ещё наследуется?
fallingsappy Автор
Это все один большой пранк ;)