ASP.NET Core вышел летом 2016 года как платформа, переписанная с нуля: кроссплатформенная, открытая, с новым конвейером обработки и без груза System.Web. Для нового проекта выбор очевиден. Для системы, которая десять лет росла на классическом ASP.NET Framework, набрала сотни контроллеров, обросла Web Forms, WCF службами и десятками HttpModule, переезд выглядит не как обновление версии, а как смена фундамента под живым домом. Прямого апгрейда не существует: загрузчик другой, конвейер другой, половина типов System.Web просто отсутствует. Последняя классическая версия 4.8 вышла в 2019 году и дальше развиваться не будет, она живёт в ритме поддержки самой Windows. Поэтому крупные проекты переезжают не скачком, а слоями, и доходят до конца именно те, кто заранее строит механизмы совместимости.
Крупный монолит на классическом стеке и цена переписывания с нуля
Типичный монолит, доросший до потребности в миграции, весит неприятно. Сотни контроллеров и представлений, годы бизнес логики внутри файлов с кодом, интеграционные модули в web.config, пакеты, которые давно не обновлялись. Идея переписать всё сразу выглядит соблазнительно ровно до первой оценки сроков: пока команда два года пишет новый мир, старый продолжает обрастать функциями, которые потом нужно переносить ещё раз. Ловушка больших переписываний в том, что продукт не останавливается на время переезда. Функции пишутся в двух местах, баги правятся в двух местах, релизный цикл замедляется, и в какой то момент владелец бюджета спрашивает, куда ушли деньги.
Инкрементальный подход снимает часть рисков: старое и новое приложения работают одновременно, трафик делится по маршрутам, каждый шаг откатывается отдельно. Плата за безопасность: нужны инфраструктура совместимости, общий слой логики и общий вход для трафика.
Полезная оценка для спонсора проекта: при монолите в четыреста контроллеров и темпе переноса в десять модулей за месяц переезд займёт три с лишним года. Числа дисциплинируют: разговор превращается из "перепишем за квартал" в план с приоритетами, где первыми едут модули с наибольшей болью.
Общий слой бизнес логики на netstandard для двух миров сразу
Первый слой совместимости это код, не зависящий от платформы. Из монолита выносят расчёты, правила, валидацию, работу с базой и внешними системами в библиотеки под netstandard 2.0: их подключают и классическое приложение на net48, и новый сервис на net8. Файл проекта выглядит скромно:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
</PropertyGroup>
</Project>
Правило гигиены простое: в общих библиотеках не должно быть типов System.Web и HttpContext. Слой логики принимает и возвращает простые объекты, а всё, что связано с запросом, живёт на верхнем уровне. Проверка быстрая: проект, который не собирается на netstandard, всё ещё держит в себе платформу. Слой доступа к данным переезжает часто первым, потому что Entity Framework 6 начиная с версии 6.3 собирается под .NET Core, а современный EF Core подключается рядом без конфликтов. Незаменимый помощник этой фазы, пакет System.Configuration.ConfigurationManager: он возвращает привычное чтение app.config на новой платформе, пока файлы конфигурации не переведены на appsettings.json.
Иногда платформенную разницу не убрать абстракцией, и тогда выручает двойная сборка с условной компиляцией:
#if NETFRAMEWORK
var dataPath = System.Web.Hosting.HostingEnvironment.MapPath("~/App_Data");
#else
var dataPath = Path.Combine(env.ContentRootPath, "App_Data");
#endif
Файл собирается под обе платформы, а различия заперты внутри пары строк. Когда условий становится больше пары на класс, правильнее развести код через интерфейс.
Шлюз YARP как диспетчер маршрутов между старым и новым
Второй слой стоит на границе сети. Шлюз YARP, выросший из внутренних задач Microsoft, работает на ASP.NET Core и расщепляет трафик по правилам маршрутов. Сначала весь трафик идёт через YARP на старое приложение, как через прозрачную трубу. Новые и перенесённые маршруты переключаются на новое приложение по одному, без переустановки адресов и без простоя. Настройка живёт в обычном файле конфигурации: у маршрута есть имя, правило сопоставления пути и кластер назначения, у кластера адрес приложения. Старое приложение получает собственный кластер с перехватывающим правилом, и любой маршрут переезжает на новый кластер правкой одной строки без пересборки.
Входная точка шлюза собирается в несколько строк: один вызов подключает сервис к коллекции, второй читает секцию настроек, третий привязывает шлюз к конвейеру запросов. Доля трафика на новое приложение поднимается плавно: сначала маршруты-подопытные, потом самые спокойные, и метрики за равные окна времени сравниваются честно.
Дальше работает простой ритм: перенести маршрут, неделю смотреть на логи и метрики, перенести следующий. Ошибку откатывают правкой конфига за минуты, а не релизом. Прижившийся маршрут удаляют из монолита, и тот медленно худеет. Дисциплина важнее скорости: соблазн перенести сразу половину системы заканчивается грустно и предсказуемо.
Чувствительный момент переезда, состояние сессии: пользователь, проскочивший за один визит между старым и новым приложением, не должен терять корзину. YARP умеет закреплять маршруты за экземплярами по сходству сессий, а общий бэкенд строят на внешнем хранилище: новое приложение подключает распределённый кеш, старое ставит соответствующего провайдера сессий, и оба видят одни и те же значения.
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = "redis-node:6379");
Единая cookie аутентификации для старого и нового приложений
Пользователь не должен видеть границу между старым и новым. Если слева от маршрута его помнят по одной cookie, а справа выпрашивают повторный вход, миграция мгновенно становится темой для жалоб. Механизм совместного входа строят через одну общую cookie: оба приложения читают и выписывают один и тот же билет, а ключи шифрования живут в общем хранилище. На стороне ASP.NET Core настраивается защита данных и имя cookie:
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(@"\\fileserver\shared-keys"))
.SetApplicationName("shared-app");
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options => options.Cookie.Name = ".AspNet.SharedCookie");
Классическое приложение ставит пакет Microsoft.AspNetCore.DataProtection.SystemWeb, который встраивает ту же защиту данных в конвейер OWIN, плюс в web.config прописывается тот же ключ машины. После этого билет, выписанный одной стороной, читается другой. Настройка известна дотошностью: несовпадение имени cookie, версии билета или ключей не падает с ошибкой, а выглядит как постоянно вылетающий вход. Именно поэтому связку аутентификации проверяют на первых маршрутах, а не на последних.
Похожая проверка касается утверждений в билете: если стороны пишут имя пользователя в разные поля, люди будут узнаны, но их корзины и права не найдутся. Схему утверждений фиксируют до первого маршрута.
Перевод HttpModule в middleware и Global asax в Program cs
Третий слой это конвейер запроса. В классическом приложении сквозную обработку делают HttpModule, зарегистрированные в web.config и подписанные на события BeginRequest и EndRequest. Типичный модуль замера времени ответа:
public class TimingModule : IHttpModule
{
public void Init(HttpApplication context)
{
context.BeginRequest += (s, e) =>
context.Context.Items["start"] = DateTime.UtcNow;
context.EndRequest += (s, e) =>
{
var start = (DateTime)context.Context.Items["start"];
Logger.Write((DateTime.UtcNow - start).TotalMilliseconds);
};
}
public void Dispose() { }
}
В ASP.NET Core тот же смысл выражается middleware, короткими звеньями с одним вызовом следующего звена:
app.Use(async (context, next) =>
{
var watch = Stopwatch.StartNew();
await next();
watch.Stop();
logger.LogInformation("Запрос {Path} выполнен за {Ms} мс",
context.Request.Path, watch.ElapsedMilliseconds);
});
Разница не только в синтаксисе. Порядок звеньев задаётся кодом в Program.cs, а не деревом конфигурации, и каждое звено само решает, звать ли следующее. Global.asax с его Application_Start уходит вместе с событиями жизненного цикла, стартовая логика перетекает в Program.cs до строки app.Run(). При переносе модулей полезно помнить, что классический конвейер знал события уровня сеанса, которых в новом мире нет.
Судьба Web Forms и WCF служб в большом переезде
Часть монолита не переезжает в принципе. Web Forms держится на модели событий серверных элементов и ViewState, которых в ASP.NET Core не существует, и прямого конвертора нет. Разумных путей два: держать раздел в классическом приложении, пока миграция обходит его по кругу, или переносить экраны на Blazor, чья компонентная модель ближе всего к привычной, а состояние живёт на сервере. Blazor Server входит в платформу с 2019 года.
С WCF службами ситуация лучше. Проект CoreWCF, выпустивший версию 1.0 в начале 2022 года, возвращает серверный хостинг основных привязок, включая basicHttpBinding, wsHttpBinding и netTcpBinding, а клиентская часть покрывается официальными пакетами System.ServiceModel. Сотни контроллеров ASP.NET Web API 2 переводятся почти механически: атрибуты маршрутов, фильтры и модели в основном совместимы, основные правки касаются конфигурации и кода запуска.
Хостинг службы в новом приложении собирается из нескольких строк:
builder.Services.AddServiceModelServices();
builder.Services.AddServiceModelMetadata();
var app = builder.Build();
app.UseServiceModel(services =>
{
services.AddService<OrderService>();
services.AddServiceEndpoint<OrderService, IOrderService>(
new BasicHttpBinding(), "/orders/service.svc");
});
Контракт и реализация берутся из общей библиотеки на netstandard, поэтому клиентская и серверная стороны переезжают раздельно.
Подводные камни совместимости и порядок переезда по шагам
Совместимость между мирами не бесплатная. Загрузка старой сборки .NET Framework напрямую в ASP.NET Core технически возможна со времён .NET Core 2.0, но официально такой механизм не поддерживается и ломается на рефлексии, сериализации и типах конфигурации. Отдельные Windows API возвращает пакет Microsoft.Windows.Compatibility: реестр, журналы событий, WMI и ODBC продолжают работать на новой платформе. Каждую такую зависимость стоит проверять изолированным тестом до переноса маршрута.
Отдельные категории ломаются предсказуемо. Двоичная сериализация BinaryFormatter в новых версиях .NET отключена, старые двоичные кеши переписывают на явные форматы. Домены приложений и ремоутинг не вернутся ни в каком виде. COM interop остаётся доступен только на Windows, так что сервисы, завязанные на офисные библиотеки, либо остаются на Windows серверах, либо отвязываются от конкретной технологии. Практичный порядок переезда выглядит так:
- вынести бизнес логику в библиотеки на netstandard и покрыть их тестами;
- развернуть YARP и направить весь трафик на старое приложение;
- настроить общую cookie аутентификации и проверить её на тестовом маршруте;
- переносить маршруты группами от простых к сложным, неделю наблюдая каждый;
- удалять перенесённые обработчики из монолита и снижать ресурсы старого пула.
Отдельная строка бюджета уходит на наблюдаемость: пока два приложения работают рядом, логи и метрики собирают в одном месте, иначе сравнение поведения ведётся вслепую.
Миграция крупного проекта на ASP.NET Core не бывает технически тривиальной, зато бывает управляемой. Интеграционные слои снимают главный риск переписываний, необходимость остановить развитие продукта. Общая библиотека переносит смыслы, YARP делит трафик, общая cookie скрывает границу от пользователя, а middleware и CoreWCF переводят остатки конвейера и служб. Старое приложение не отключают в один день: оно тихо теряет маршруты, пока не остаётся без работы. Такая стратегия превращает переезд из скачка через пропасть в спуск по лестнице, где на каждой ступени можно передохнуть.