Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Всем привет! Давно хотел написать эту статью, но материал всё никак не собирался: всё-таки рефакторинг - не на каждый день развлечение. Особенно если крупный. Речь пойдёт о том, как можно использовать Roslyn для лечения разной боли в шарповом коде. Если по центру вашего проекта возвышается огромная куча неудобного легаси и вам страшно на неё смотреть - добро пожаловать под кат. Возможно, мой материал позволит взглянуть на проблему с другого ракурса и понять, что не всё так печально. По идее сам подход может быть применим к любому языку, экосистема которого включает в себя тулинг для компилятора. Но это не точно. Однако, я расскажу всё, что знаю на двух примерах из реальной жизни.
Предыстория
Что вообще такое легаси? Дословно это переводится "наследие". В контексте - куча кода, которая написана давно, на старых технологиях, с использованием старых, глупых или попросту странноватых подходов. Все понимают что легаси-код - это плохо, но никто ничего не решается с ним сделать: с одной стороны вроде как всё работает, а работает - не трожь. А с другой стороны объём работы будет такой, что парализует всю разработку. Кстати тот редкий случай, когда разработчики с менеджерами могут обняться и заплакать. Первые не хотят вкладываться в разгребание тех. долга, а вторым не хочется ковыряться в вековых залежах сомнительных субстанций. Вот и висят такие куски как бельмо на глазу. Бесят всех, разработку замедляют, развитие продукта блокируют.
Осмысленная переработка легаси - это больно. Создаётся примерный план как от всего этого избавляться, легаси-часть проекта консервируется (лучше - если в отдельном репозитории), новая функциональность начинает дописываться как бы "сбоку", а легаси-часть используется через API. В лучшем случае этот API стараются обложить unit-тестами, после чего весь легаси записывают в технический долг, который потихоньку начинают сортировать под пристальным наблюдением архитекта. Архитект же, отвлекаясь от построения дата-пайплайнов, носится вокруг возникающих пулл-реквестов, следит за грануляцией и циклическими зависимостям. Ну это в лучшем случае. В худшем же, легаси-код продолжает жить своей жизнью до тепловой смерти вселенной, абстрагированный от новых фич.
В общем, избавление от легаси-кода - долгий процесс, требующий выдержки руководителя, политической воли владельца продукта и координации работы нескольких десятков человек. Я сам расчётов не видел, но полагаю что на ФОТ и эффективность в пересчёте на фича-километры такие изменения влияют не лучшим образом.
И такая ситуация, по моим наблюдениям - чуть ли не в КАЖДОМ проекте. Ладно, преувеличиваю, конечно, но частенько случается. Кто виноват и как так вышло - не предмет этой статьи. А важно вот что: в определённых случаях можно очень хорошо "срезать углы" и раскидаться с застарелым легаси-техдолгом, используя достижения теории трансляции и компиляции. Рассказываю как это сделать в уютном мире C# и .NET.
Roslyn
Моё знакомство с Roslyn случилось совершенно случайно. Мы сошлись на почве ненависти к JavaScript-у. Случилось это ещё в доwebpack-овскую эпоху, когда веб-приложения уже начали усложняться, а тулинга толком ещё не было. В свете софитов тогда были extJS, незамедлительно превращающийся в страх и ужас на масштабных проектах, GWT, который работал, кажется, только внутри гугла, а приложухи по-проще благоразумно сидели на jQuery. TypeScript тогда не было и в проекте, так что мы выживали как могли. Писать на чистом JS мне категорически не нравилось, потому как медленно и многословно. А вот C# я знал превосходно.
Да, я знал о существовании Bridge.NET, DuoCode и Script#. На первые два у меня не было денег, а Script# не нравился своей монументальностью. Перегружать mscorlib из коробки - это же фу как грубо. Ну и ReSharper с ним не дружил.
Будучи юношей бледным со взором горящим, я решился на авантюру. Сделать велосипед. Свой транслятор из C# в JS с блекджеком и визитёрами. Если так посудить, то мне много не надо - так... Генерить портянки бойлерплейта, переводя шарповые классы в прототипы. Ну и переписывать на JS мелкие методы с основными управляющими конструкциями. Циклы-условия, вот это всё. О трансляции-компиляции я тогда знал мало (в активе - НГУшный курс МТК), а свободное время жгло карман. Чего б не попрактиковаться? И я нырнул в это болото.
Парсить C# руками оказалось непросто. Нужна была грамматика. Я сделал что-то подобное на CoCo/R для Javascript (да, мои вкусы специфичны и я больной ублюдок), но повторять этот номер для C# или адаптировать готовую грамматику, создавать сотню классов для нод и токенов решительно не хотелось. Нужно было готовое решение. Я безрезультатно поковырял ANTLR и уже было загрустил, как тут на моё счастье вышла одна из первых RC-версий Roslyn.
Сейчас Roslyn разросся и больше известен как .NET Compiler Platform. Если кратко, то это набор библиотек, содержащий лексеры-парсеры, превращающие шарповый (и не только) код в поток токенов и в AST. Это дерево можно обходить визитёрами и переписывать. Другие части Roslyn умеют строить семантическую модель кода и связывать её с AST. Можно даже грабить корованы собирать код в исполняемый файл. Прямо в рантайме!
Вообще, если заморочиться, то с помощью Roslyn можно сделать собственный диалект C# с бочкой синтаксичекого варенья и compile-time печенья. Но это - удовольствие для истинных ценителей. Даже я при всей своей отшибленности и авантюрности не рекомендую так делать.
А чем закончилась история с моим транслятором? Да ничем. Забросил. Стало напряжно корректно обрабатывать замыкания, а потом появился TypeScript и потребность в подобных разработках отпала как класс. Поэтому, давайте к кейсам.
Кейс №1: прощаемся с VB.NET
Вводные: небольшая компания, работающая со складом. Система складского учёта объёмом примерно 200-300 тысяч строк кода. Она была невесть у кого куплена, потом криво проаутсоршена и, в общем, в самом центре системы находится сборка с кодом на Visual Basic .NET. Сотни три небольших классов: DTO-шки, сущности и, как водится, статические классы с логикой.
Что болит: VB.NET-код и болит. В нём довольно много логики в масштабах проекта и за фиксом каждого бага приходится лазить туда. На проекте всего 3 разработчика и своими силами перелопатить такую кучу кода не представляется возможным. Функциональность пилится медленно, онбордить новых людей в проект сложно (кандидаты разбегаются, услышав про VB.NET). Хочется съехать на EntityFramework с LINQ2SQL, но все планы на радужное будущее упираются в этот чёртов кусок VB.NET-а.
Как решаем: основная идея состоит в том, чтобы набросать на коленке транслятор из VB.NET в C#. Без претензий на универсальность, сугубо для конкретного случая. Технически это обычный визитёр, наследующий VisualBasicSyntaxVisitor<CSharpSyntaxNode>
, которому скармливаются все .vb
-файлы, разложенные в AST средствами самого Roslyn. Каркас транслятора был сделан за несколько часов, после чего в течение недели он допиливался напильником по bleeding-edge методологии "запускаем - смотрим результат - правим - запускаем".
Такой рефакторинг на уровне AST хорош тем, что не производит логических ошибок (если у вас руки и голова на месте). Все ошибки видны на этапе компиляции, но процессор перманентно загружен сборкой кода. Как gentoo собирать, только почему-то в Visual Studio.
Челленджи: Visual Basic.NET довольно сильно отличается от C#. Например, он производит просто тонны неявных приведений типов в самых неожиданных местах (например, в LINQ-запросах). Чтобы как-то жить с этим адом пришлось построить семантическую модель, указать вручную все используемые dll-ки и слёзно просить Roslyn выводить тип во всех подозрительных местах. В результирующем C#-коде транслятор заботливо расставлял касты к требуемым типам. В основном это были преобразование численных типов данных, но ощущения неприятные.
Второй челлендж - сами LINQ-запросы. Тут даже я с удивлением обнаружил, что синтаксис LINQ в VB.NET довольно сильно отличается от оного в C#. Как ни странно, не в пользу последнего. Для трансляции LINQ-запросов пришлось заводить отдельный маленький визитёр, со своим контекстом трансляции, который корректно вытянет все range variables на свет б-жий, а так же развернёт конструкции вроде Distinct/Take/While в вызовы шарповых методов. И всё равно некоторые group join-ы в итоге пришлось править руками, благо их было немного.
Результат: трансляция прошла успешно. Примерно неделя ушла на настройку транслятора, после чего пару дней я почистил код руками - и вуаля. Структура кода позволяла тупо убрать модификатор static
и получить на выходе вполне себе сервисы логики, которые были тут же запихнуты в IoC. Итого около двух недель занял полный перевод VB.NET-кода на C#. Провернуть что-то подобное мечтало и руководство и немногочисленные разработчики, но никто не знал как. Через некоторое время мы съехали на EntityFramework, наняли ещё разработчиков и продолжили пилить фичи с удвоенной скоростью. Успех достигнут. На радостях менеджмент одарил меня деньгами, пригласил на корпоратив в Европу, оплатив билеты, а на месте одарил душевными презентами, которые были привезены обратно в Новосибирск и там выпиты. Мы долго и плодотворно сотрудничали, но это уже совсем другая история.
Кейс №2: геноцид статики
Вводные: добрых размеров компания, в разработке 5 команд по 5 человек, разнородный стек технологий, но преимущественно C#/.NET. Продукт с гигантским количеством функциональности. 700 тысяч строк кода. Состоит это добро из нескольких WebAPI-проектов, 5 штук виндовых сервисов, делающих разные вычисления и трёх сотен dll-ок только в основном солюшене. Сложная мультиарендная инфраструктура с базами-кэшами-очередями, плюс облачная часть-админка. Исполнено всё на .NET 4.8.
Что болит: покоится эта махина на нескольких огромных сборках, содержащих - увы и ах - статические классы. В них - старая, поросшая паутиной статическая бизнес-логика, а так же доступ к БД. Да, все обращения к базе прибиты гвоздями к нескольким статическим методам, которые вычитывают строку подключения из конфига (база разная для каждого клиента) и создают подключения. Из-за этого сервисы невозможно отодрать от базы, а следовательно никакой тебе мультиарендности и динамического скейлинга. По техническим причинам само подключение к базе данных прибито гвоздями к .NET 4.8 из-за чего любая попытка перевести продукт на .NET Core влоб терпит неудачу. Любому, кто начнёт этот поход - уже через пару дней становится очевидно что тут работы на полгода, не меньше. Не говоря уже о том, что покрыть это unit-тестами не представляется возможным без убойных костылей. О том, чтобы раздробить этот монолит никто даже и не думает.
Как решаем: первый шаг навстречу прекрасному будущему - убить статические классы, к которым приклеена система. Решать задачу будем в несколько этапов.
Подготавливаем код вручную.
Вычищаем мелкий мусор: 5-6 классов ведущих себя как ActiveRecord, какие-то невнятные вложенные классы, файлы с несколькими классами и прочая чушь. Пара часов максимум на это уходит;
Вся монструозность держится на трёх вспомогательных классах. Ну вы знаете -
Util
,Common
иAux
. Читаем их код, обнаруживаем кучу статической работы с инфраструктурой. Сеть там, файловая система, база. Особенно мне запомнился extension-метод для выполнения SQL-команд. Он используется как"DELETE FROM table".ExecuteSql()
. Ковырять такое синтаксическим анализом неудобно, поэтому просим Rider сделать нам Convert - Extension Method to Plain Static. Идём пить чай, пока Rider думает над этой задачей (10-15 минут). Допивая чай, делаем то же самое ещё на дюжине методов;Сортируем методы вспомогательных классов руками, вычленяя чистые функции. Это всё пока без Roslyn, чисто Rider-овским Move-ом. По итогу ручной сортировки все чистые функции уходят в отдельный класс, а во вспомогательных классах остаётся не более 50 методов. Перетасовываем их по семантике - и вуаля: вместо невнятных
Util
,Common
иAux
имеем вполне логичныеSqlService
,FileSystemService
,NetworkService
. Они всё ещё статические, поэтому я их копирую, переименовываю вRefactored*Service
, убираю модификаторstatic
и делаю extract interface. Таким образом, для каждого статического класса, на которые опирается система - имеем вполне себе интерфейс/реализацию. Прогресс! Дело за малым - заменить одно на другое;
В дело вступает Roslyn. В стороннем проекте с подключённым Roslyn-ом загружаю все C#-файлы из солюшена продукта. Задача проста: пройтись по каждому файлу, найти где используются наши статические классы, сделать локальное поле, добавить параметр в конструктор (для IoC), поменять все места использования.
3. Вручную убираем всякую чушь вроде написанного на коленке ServiceLocator-а и синглтонов. Занятие довольно скучное, в пояснениях не нуждается. Удаляем код - собираем - смотрим где упало - чиним. Можно было написать рефакторинг и для этих действий, но таких мест в коде было не более 30 - вполне можно разрулить руками, не включая головной мозг.
4. Чиним IoC-контейнер. Все статические классы превратились в сервисы и их надо прописать в IoC-е.
Челленджи: Беда пришла откуда не ждали: оказывается, в системе есть и другие статические классы, зависящие от изначальных. Ну кто бы мог подумать! (sarcasm) Статические классы пирамидкой нагромождены друг на друга, а на их основе уже построены обычные классы-сервисы. И я только что выбил у этой пирамидки фундамент. Произошло примерно следующее:
Что делать? Опускаю промежуточные рассуждения и сразу перехожу к решению: заводим реестр, в который добавляем информацию о том, какие статические классы затронуты в ходе преобразования. Само же преобразование проводим в несколько проходов, пользуясь на каждом шагу новой и новой информацией из реестра. Короче, жмакаем F5 до тех пор, пока реестр не перестанет пополняться, что ознаменует смерть всех статических классов:
Второй челлендж - починить IoC-контейнер. Тут я начал выпендриваться: построил модель связей между классами. Покрутил её туда-сюда, даже попробовал отрисовать через graphviz (такая фигня получилась). По этой модели связей сгенерил довольно сносные регистрации для NInject, однако дело осложнялось наличием кастомных провайдеров для некоторых сервисов и лайфтаймовой магией, которую можно было воспроизвести только руками.
На красивую модель связей я в итоге забил и сделал проще: написал unit-тесты, проверяющие консистентность контейнера. Тут всё довольно прозаично: создаём IoC-контейнер, применяем регистрации, пытаемся достать из контейнера требуемые сущности. Тест падает, читаем описание NInject-а что пошло не так, добавляем в IoC нужные классы, запускаем снова. И так пока тест не станет зелёненьким. Container Consistency-тесты, кстати, остались. Увеличили покрытие кода ажно на 3% (около 20 тысяч строк в масштабе проекта).
Кстати, оффтоп: в Tecture вот проблемы с контейнером отсутствуют как класс из-за позднего связывания на типах. Но до Tecture тут далеко - нам бы статические классы выкинуть.
Результат: всё работает. Пулл-реквест на 1500 изменённых файлов вмерджен в мастер, релиз прошёл успешно. На написание и отладку всей Roslyn-овой обвязки ушла примерно пара недель. Плюс ещё пара недель на бюрократию, фикс упавших (по синтаксическим причинам) юнит-тестов и проваленных автотестов. Директор отдела признал что 15 поломанных автотестов (из 1500) после такого рефакторинга - это успех. Дружеские похлопывания по плечу, "you did something considered impossible", "Pavel did revolution". Уже на следующий день после мерджа в мастер другие команды побежали переводить веб-сервисы на .NET Core, настраивать мультиарендность и покрывать тестами всё, что плохо лежит. Мне подарили презент, который был незамедлительно распит и дали халявную неделю дейоффов. Непосредственный начальник признался что я - один из лучших, кого он когда-либо нанимал. Happy end.
Кейс №3: бонус (Tecture)
Я настолько люблю Roslyn что использую его в своих проектах. Да, это совсем не про рефакторинг, зато про Roslyn. А суть вот в чём.
Строковая шаблонизация плохо работает на таких задачах, так что я сразу взял Roslyn. Сделал несколько классов-прокладок для метаданных (чтобы не тащить Roslyn в само ядро Tecture) и написал соответствующие генераторы. Код, конечно, получился грязноват и лично мне в нём сложновато разобраться после долгого перерыва. Однако, свою задачу он выполняет на ура. Валидация и тестовые данные получаются опрятными и красивыми.
Выводы
Что надо сказать в первую очередь: для королевских рефакторингов лучше иметь тесты. Чем больше - тем лучше. Какие угодно тесты становятся важны, когда проводишь столь массивные изменения. Из моих двух кейсов первый был наиболее рисковым - тестирование в проекте отсутствовало как класс. Выручали только масштабы: система по современным меркам была довольно маленькая, да и изменения касались в основном служебного кода (а не логики). А основные кейсы вполне можно было прокликать руками.
Во втором кейсе у меня было довольно много тестов: около полутора тысяч unit-тестов и ещё примерно столько же автотестов UI. И они своим падением - таки да, помогли выловить несколько бажных мест, которые я упустил. Однако, главным другом и боевым товарищем в таких походах по-прежнему остаётся компилятор. Я давно топлю за максиму "всё, что может упасть на этапе сборки - должно упасть на этапе сборки", и в этом случае я в очередной раз на своей шкуре убедился как это важно.
Roslyn же, применённый к месту вполне может сэкономить кучу времени и ничего не поломать.
У меня всё. Ставьте плюсы, подписывайтесь на мой телеграм-канал.
Успехов.