Миграция API с Framework на .NET6 — скандалы, интриги, расследования и немного выводов

Предыстория

Взгляд в прошлое...

Взгляд в прошлое…

Давным-давно, во времена, когда по Земле бродили цифровые динозавры, а разработчики .NET ещё помнили, зачем нужна технология WebForms (и какие у неё были проблемы с производительностью), в Контуре появился продукт под названием Фокус, предназначенный для проверки контрагентов. И у этого продукта довольно быстро появился API, ориентированный на крупных клиентов.

ASP.NET MVC был ещё в новинку, до появления WebAPI оставались годы, и отцы-основатели проекта приняли вполне актуальное, с учётом реалий того времени, решение: делать API на базе ashx-хендлеров, чтобы максимально повысить скорость работы.

Шли годы, .NET Framework сперва меняла версии как ветреная красавица перчатки, а потом и вовсе перешла в разряд «для поддержки жизнедеятельности требуется опытный некромант», .NET Core сперва появился, а потом благополучно переименовался в просто .NET, дорос до 6-й, а потом и 7-й версии…, а API Фокуса всё ещё жил по старому, доброму принципу «работает — не трогай». И вот, наконец небосвод провернулся, и звёзды сошлись в нужной позиции. Мы поехали на .NET 6.

Оговорюсь сразу, что сам переезд произошёл примерно полгода назад, когда .NET 8 ещё находилась в стадии альфы. Именно поэтому в качестве целевой версии .NET была выбрана именно стабильная 6-я. Тем не менее большинство проблем будут актуальны и при миграции на 8-ю версию.

Ключевые решения

На самом деле, если проект не очень большой и не очень старый, то совсем уж крупных проблем с переводом нет. В интернете можно найти много гайдов, описывающих, как можно перетащить проект вручную или с помощью автоматики. Неидеально, но получить хорошую основу для дальнейшей работы можно. Увы, но для нас этот вариант был закрыт. Предстояло не просто поменять тип проекта, но ещё и заменить ashx-хендлеры на контроллеры WebAPI. Иными словами: много, много ручной работы, так как общее количество хендлеров уверенно перевалило за сотню. Это привело к первому обсуждению:

Ручной перевод или кодогенерация?

Ты что-то имеешь против кодогенерации, кожаный мешок?

Ты что-то имеешь против кодогенерации, кожаный мешок?

Так как в целом у контроллеров ASP.NET MVC и ashx-хендлеров отличия более-менее однотипные, то можно было бы попробовать написать кодогенератор на базе Roslyn, который сделал бы перенос в полуавтоматическом режиме. Но они (отличия) всё же были, баги и разночтения периодически всплывали, и на каждые 3–4 контроллера, переведённых «на автомате» за 5–10 минут, приходился один, на который уходило минимум несколько часов, а то и полдня.

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

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

В общем, однозначного мнения по этому вопросу сформировать не получилось. Для больших по размеру проектов с меньшим числом подводных камней кодогенератор явно представляется хорошим вариантом. У нас оказался пограничный случай.

Архитектурные решения при переводе

e09dae2c92b8fbaec5a1432f84b1413f.jpeg

Исторической спецификой Фокуса является большой монорепозиторий на более чем 600 проектов, которые, зачастую в той или иной степени используют код друг друга. Мы постепенно уходим от этого подхода, но стартовать с переводом надо было уже сейчас. При этом API — это только один из продуктов и только одна из нескольких подкоманд «большого» Фокуса. Исторической спецификой Фокуса является большой монорепозиторий на более чем 600 проектов, которые, зачастую в той или иной степени используют код друг друга. Мы постепенно уходим от этого подхода, но стартовать с переводом надо было уже сейчас. При этом API — это только один из продуктов и только одна из нескольких подкоманд «большого» Фокуса.

Сразу было понимание, что пытаться одновременно перевести проект на .NET6 и распутать все кодовые зависимости — задача, мягко говоря, непосильная. При этом с кодовой базой всего решения одновременно работало достаточно большое число людей из разных подкоманд, и останавливать всю работу на пару месяцев никто бы, естественно, не дал.

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

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

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

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

Подводные камни. Очевидные и не очень

Обработка запросов

Вполне логично, что за годы, прошедшие с момента появления самых первых версий ASP.NET, технологии шагнули вперёд. Какие-то решения были признаны не очень удачными, а для каких-то, напротив, были найдены классные новые подходы. Переходя с Framework на Core и потом на NET, Microsoft решила разрубить в этом месте гордиев узел обратной совместимости и частично поменять базовые классы.

В силу исторических причин и для обеспечения обратной совместимости у нас не было возможности переключиться на стандартные для NET 6 атрибуты параметров [FromQuery], [FromBody] и т. д. Плюс параметры запроса использовались для получения вспомогательной информации о методе с целью его последующего логирования. Пришлось продолжить парсить строку запроса по сложным условиям, обращаясь к базовым объектам.

Ниже — табличка с некоторыми типовыми различиями, с которыми пришлось столкнуться на практике:

Действие

Framework

.NET 6

Получение параметра запроса

Request .QueryString[paramName]

Request .Query[paramName] .FirstOrDefault()

URL

Request .Unvalidated .RawUrl

Request .GetEncodedUrl()

и немного картинок на ту же тему:

Пример объекта HttpContext для .NET Framework

Пример объекта HttpContext для .NET Framework

Пример объекта HttpContext для .NET 6

Пример объекта HttpContext для .NET 6

Данная табличка и изображения не являются какими-либо руководствами к действию, а просто призваны показать, что отличий в, казалось бы, базовых вещах, может быть довольно много. Если видите у себя в коде активную работу с HttpContext и его производными — смело накидывайте день-другой на «разобраться с нюансами».

Ещё одна проблема косвенно также относилась к HttpContext, а точнее, к тому, что HttpContext и его производные HttpRequest и HttpResponse в System.Web из .NET Framework и в Microsoft.AspNetCore.Http из .NET 6 — это два разных набора классов.

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

Мы классы сериализовывали, сериализовывали, да не высериализовали

Так как банки структуры довольно инертные, многие из них продолжают активно поддерживать передачу данных в формате XML и явно будут это делать ещё долгое время. Соответственно, у нас работает отправка клиенту данных в 2-х форматах: JSON и XML, а также возможность получить описания схемы данных для обоих этих форматов.

Сама по себе сериализация в XML-формат проблем у .NET 6 не вызывает, тут всё запустилось из коробки, но вот схемы… В наших классах для имён полей и, собственно, имени типа используются атрибуты DataContract и DataMember. И тут, та-да-дам, вылезает официальный баг от MS, который говорит, что нет, ребятушки, мой стандартный генератор XSD больше не умеет работать с DataContract (https://github.com/dotnet/runtime/issues/1408).

Багу больше 4-х лет и вроде бы его наконец пофиксили в .NET 7, но это не точно, так как, как увидим ниже, код под 7 у нас тоже собирался, не помогло.

Нет, генерировать XSD .NET 6 умеет, у него есть для этого вполне рабочие инструменты. Вот только обратной совместимостью со старым XsdDataContractExporter там и не пахнет. Пришлось бы отдавать клиентам полностью новые схемы, что нас не устраивало, так как у них могли быть настроены уведомления на обновления.

Пришлось дополнительно потратить пару дней и написать собственный простенький сериалайзер, который генерирует схемы именно в том формате, который поддерживался в .NET Framework. Сериалайзер, понятно, не совсем универсальный, но так как дальнейшая его поддержка не планируется (для новых методов можно использовать новый генератор схем, там не нужна обратная совместимость), то это было «ок».

Велосипед? Да. Варианты… не особо.

Велосипед? Да. Варианты… не особо.

Компиляция убегает! Лови её!!!

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

В пятницу в обед упала сборка. Точнее, не упала, код на билд-сервере благополучно скомпилировался и развернулся на тестовом окружении, но так называемый «игровой стенд» (аналог сваггера из времён, когда сваггера ещё не было) внезапно и без видимых причин перестал работать и начал сообщать, что не может найти представление (View). Всё остальное работало, но вот именно ASP.NET MVC страница начала выдавать лютую дичь. Несколько итераций логирования и мелких правок не помогли прояснить картину, как вдруг… «игровой стенд» починился сам. Фух… облегчённо вздохнул я, потёр лишние логи, запустил пересборку… и через несколько минут «радостно» смотрел на всё то же сообщение об ошибке. Впору было заподозрить чёрную магию…

Никто не ждал испанскую инквизицию в посте про переезд на .NET 6. Но в тот момент мне очень хотелось её призвать.

Никто не ждал испанскую инквизицию в посте про переезд на .NET 6. Но в тот момент мне очень хотелось её призвать.

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

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

Как выяснилось, проблема появилась в .NET 7.0.302 и по состоянию на текущий момент всё ещё не исправлена (https://github.com/dotnet/aspnetcore/issues/49320).

Сама же причина внезапного возникновения проблемы заключалась в том, что в .NET при сборке автоматом берётся максимальная установленная на машине версия SDK. Указание TargetFramework в csproj, по крайней мере применительно к версиям .NET 6–7, игнорируется (на остальных не проверял). Решение заключалось в том, чтобы разместить вместе (в одной папке) с проектом файл global.json с примерно таким кодом:

   "sdk": { 

       "version": "6.0.404" 

   } 

}

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

Это не тот сертификат, который вы ищете

d3a22007a4e8b62782af473f450b8d74.jpeg

Ещё одна проблемы вылезла уже в самом конце, и связана она была с использованием сертификатов для двухфакторной аутентификации клиентов. Если вкратце, то клиенты с большими лимитами на запросы должны либо работать с фиксированных диапазонов IP, либо включать в запрос подписанное сообщение, которое мы проверяем и удостоверяем, что это действительно наш клиент. В .NET Framework для этой цели использовался класс X509Certificate2 из System.Security.Cryptography, у которого можно было без проблем получить ключ командой вида:

var key = cert.PublicKey.Key as RSACryptoServiceProvider;

В .NET 6 этот код вполне успешно компилировался, пространство имён сохранилось прежним, но в момент, когда шло обращение за ключом, оказывалось, что он пуст. После расследования выяснилось, что теперь там хранится не RSACryptoServiceProvider, а RSACng. При этом RSACryptoServiceProvider по-прежнему существует и является совместимым типом. Наверное, в момент осознания этого факта я чувствовал себя как тот штурмовик после воздействия на разум джедайских штучек. Вроде и понятно, что ты сейчас должен сделать, но есть какое-то смутное чувство, что что-то идёт не так…

Код, понятно, переписали, используя немного другой подход, но сам по себе факт того, что при смене платформы спокойно компилирующийся код перестаёт работать при запуске потому, что там хранится принципиально иной класс… ну такое себе.

Ура, релиз! Нет.

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

Как оказалось, не зря. Есть несколько проблем, которые вылезли в последний момент и никак не были обнаружены на этапе тестирования просто потому, что мы недооценили несколько вольготное отношение как пользователей, так и .NET Framework к стандартам HTTP.

Тут Core взял и подложил нам большую и красивую свинью — он решил поддерживать стандарты. Да, это звучит странно, но первую итерацию релиза пришлось спешно откатывать, когда выяснилось, что клиенты иногда шлют запросы вида: https://sitename//api3/req или https://sitename/api3//req — с двумя слешами вместо одного.

.NET Framework, глядя на это безобразие, говорил: ну и ладно, ну и что, что у вас 2 слеша подряд в запросе идут, вы же, наверное, один имели в виду, не вопрос, я поправлю. .NET 6 честно смотрел в спецификацию, видел между двумя слешами «пустой блок», радостно сообщал, что «так нельзя», и просто возвращал пользователю 404, страница не найдена.

11352682132b4b60e823c9e7f9d6a095.png

Так как проблема была зафиксирована у нескольких клиентов, мы решили её на своей стороне добавлением в маршрутизацию правил вида:

var options = new RewriteOptions()

   .AddRewrite("^/*([^/]*)$", "$1", false)

   .AddRewrite("^/*([^/]*)/+([^/]*)$", "$1/$2", false)

   .AddRewrite("^/*([^/]*)/+([^/]*)/+(.*)$", "$1/$2/$3", false);

app.UseRewriter(options);

Ещё несколько мелких проблем также обнаружили уже после релиза. Некоторые клиенты упорно пытались слать заголовки вида: «Content-type»: «multipart/form-data» в GET запросе или требовать «Accepts»: «text/plain» при том, что мы всегда возвращали «application/json» или «text/xml» в качестве ответа. Тут проблемы были точечные и с явными ошибками на стороне клиентов. Их обрабатывали на уровне техподдержки + разработчик фоном для быстрого определения причин.

Итоговые результаты в графиках и не только

Одними из самых больших потенциальных рисков, которые ожидались в процессе релиза, были риски, связанные с производительностью. Да, было известно, что WebAPI — штука довольно шустрая, и синтетические тесты производительности показывали, что она не особо отличается от тех же http-хендлеров, но те-то в своё время создавались с максимальным отсечением всего, что только можно, в угоду скорости. В общем, просадка по скорости работы ожидалась. Что получилось (данные брались на примере одной реплики), смотрите ниже.

CPU. Момент релиза показан вертикальной красной чертой.

CPU. Момент релиза показан вертикальной красной чертой.

Проблем по загрузке процессора не обнаружено. Есть ощущение, что графики нагрузки за дни после релиза (пятницу, понедельник и вторник) незначительно, на 3–5%, просели относительно показателей понедельника, вторника и среды. Это может быть никак не связано с релизом, но как минимум не наблюдается заметного роста.

Memory. Момент релиза показан вертикальной красной чертой.

Memory. Момент релиза показан вертикальной красной чертой.

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

Время ответа. Момент релиза показан вертикальной красной чертой.

Время ответа. Момент релиза показан вертикальной красной чертой.

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

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

Само перетаскивание проекта на .NET 6 потребовало написания 119 контроллеров вместо аналогичного числа http-handlers, частичной переделки проектной инфраструктуры, правки n-го числа багов, как посаженных собственными кривыми ручками, так и доставшихся «по наследству», создания отдельной сборочной конфигурации специально для .NET6 проектов и двух итераций релиза. Эта история началась примерно за 3 недели до майских праздников 2023 года и закончилась спустя 3,5 месяца. Общее время, затраченное непосредственно на эту задачу, можно примерно оценить как 2–2,5 человеко-месяца.

© Habrahabr.ru