ASP.NET MVC 5'ten ASP.NET Core MVC'ye Geçiş: Global.asax, Web.config, HtmlHelper ve Bundling'in Karşılıkları
MVC 5'ten ASP.NET Core'a geçişte Global.asax Program.cs'e, Web.config Options'a, HttpModule middleware'e, child action view component'e, bundling MapStaticAssets'e dönüşür; büyük uygulama YARP fallback ile parça parça taşınır. @helper ve @Html.Action'ın gerçek derleme hatalarını, System.Web adapters'taki CS0104 tuzağını ve Upgrade Assistant'ın yerini gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402), Yarp.ReverseProxy 2.3.0 ve Microsoft.AspNetCore.SystemWebAdapters 2.3.0 ile derlenip denenmiştir. Bu makinede .NET Framework olmadığı için eski MVC (Model-View-Controller) 5 uygulamasının yerine, aynı URL'lere (Uniform Resource Locator) cevap veren küçük bir ASP.NET Core uygulaması kullandım.
Önce Tahmin Et
On yıllık bir ASP.NET MVC 5 uygulamasından fiyat biçimlendiren küçük bir helper class'ı alıyorsun. İçinde HttpContext.Current.Request.QueryString["currency"] satırı var. Yeni .NET 10 projesine Microsoft.AspNetCore.SystemWebAdapters paketini ekliyorsun; paket tam olarak bu tür kodun ASP.NET Core'da çalışması için var. Dosyayı olduğu gibi kopyalayıp using System.Web; ile derlediğinde ne olur? Bir de eski view'lardan birini, içinde @helper, @Styles.Render ve @Html.Action olan bir view'ı, yeni projeye koyduğunu düşün. Build kaç hata verir?
Neden Hâlâ Bu Konu?
Türkiye'de bankacılık, sigorta, kamu ve büyük perakende tarafında hâlâ çok sayıda ASP.NET MVC 5 uygulaması çalışıyor: .NET Framework 4.8 üzerinde, IIS (Internet Information Services) arkasında, Web.config'leri yüzlerce satır. .NET Framework 4.8 Windows'un bir parçası olarak destekleniyor, yani uygulama yarın durmayacak. Ama yeni C# sürümleri, Linux container'ları, MapStaticAssets, OpenTelemetry entegrasyonu, performans iyileştirmeleri ve bu serinin anlattığı her şey o tarafa gelmiyor.
Bu yazı iki soruya cevap veriyor: eski uygulamadaki kavramların ASP.NET Core karşılığı ne ve büyük bir uygulamayı tek seferde yeniden yazmadan nasıl taşırsın. Eski projeleri okuma konusunu Web API (Application Programming Interface) serisinin Startup.cs'ten geçiş yazısında .NET Core 3.1 açısından görmüştük; burada başlangıç noktası daha eski, .NET Framework.
Kavram Eşleştirme Tablosu
| ASP.NET MVC 5 (.NET Framework) | ASP.NET Core (.NET 10) |
|---|---|
Global.asax ve Application_Start | Program.cs: WebApplication.CreateBuilder, service kaydı, pipeline |
RouteConfig.RegisterRoutes | MapControllerRoute, attribute routing |
FilterConfig ve GlobalFilters | AddControllersWithViews(o => o.Filters.Add(...)) |
Web.config içindeki appSettings ve connectionStrings | appsettings.json, environment variable, IOptions<T> |
IHttpModule, IHttpHandler | Middleware, endpoint (MapGet, MapForwarder) |
System.Web.Optimization ve BundleConfig | MapStaticAssets, gerekirse ayrı bir bundler |
@Styles.Render, @Scripts.Render | <link> ve <script> tag helper'ları |
Child action (@Html.Action, [ChildActionOnly]) | View component (<vc:cart-summary>) |
@helper | Partial view, tag helper ya da Razor içinde local function |
HttpContext.Current | Constructor injection, IHttpContextAccessor ya da System.Web adapters |
Session["Cart"] (object saklar) | ISession (byte array saklar, AddSession ve UseSession gerekir) |
| Unity, Autofac, Ninject ile DI (Dependency Injection) | Yerleşik DI container'ı |
Tablonun sol sütunu tek tek okunduğunda korkutucu değil; çoğu satır bu serinin ya da Web API serisinin bir yazısına karşılık geliyor. Zor olan, bu kavramların on yıl boyunca kodun her yerine dağılmış olması.
Global.asax ve Web.config'ten Program.cs ve Options'a
MVC 5'te uygulama Application_Start içinde RouteConfig, BundleConfig ve FilterConfig'i çağırarak ayağa kalkar; ayarlar ConfigurationManager.AppSettings["PageSize"] ile string olarak okunur ve parse hatası ilk kullanımda çıkar. ASP.NET Core'da bu iki iş de Program.cs'e geliyor ve ayarlar tipli bir class'a bağlanıyor:
public class BookstoreOptions
{
public const string SectionName = "Bookstore";
[Range(1, 100)]
public int PageSize { get; set; } = 20;
[Required, EmailAddress]
public string SupportEmail { get; set; } = "";
}
builder.Services.AddOptions<BookstoreOptions>()
.BindConfiguration(BookstoreOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
Eski <appSettings> anahtarları bir JSON (JavaScript Object Notation) section'ına taşınıyor:
{
"Bookstore": {
"PageSize": 20,
"SupportEmail": "support@bookstore.example"
}
}
ValidateOnStart sayesinde yanlış bir değer uygulamanın açılmasını engelliyor. Environment variable ile bilerek bozuk değer verdiğimde (Bookstore__PageSize=500, Bookstore__SupportEmail=not-an-email) uygulama ilk istekten önce durdu:
Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'BookstoreOptions' members: 'PageSize' with the error: 'The field PageSize must be between 1 and 100.'.; DataAnnotation validation failed for 'BookstoreOptions' members: 'SupportEmail' with the error: 'The SupportEmail field is not a valid e-mail address.'.
Web.config'le aynı hata, ancak o ayarı okuyan sayfa ilk kez açıldığında bir FormatException olarak ortaya çıkabilirdi. Options pattern'inin geri kalanı (provider sırası, IOptionsSnapshot, secret'lar) Web API serisinin configuration yazısında. Web.config'in diğer yarısı, yani <system.webServer> altındaki IIS ayarları, ASP.NET Core'da büyük oranda koddan yapılıyor; IIS'te yayınlanan bir ASP.NET Core uygulamasının publish çıktısında hâlâ küçük bir web.config olması yalnızca ASP.NET Core Module'ü yapılandırmak için.
HttpModule'den Middleware'e
IHttpModule, MVC 5'te her isteğe dokunan kodun yeriydi: loglama, süre ölçümü, header ekleme, eski URL'leri redirect etme. BeginRequest ve EndRequest event'lerine abone olan bir modülün ASP.NET Core karşılığı bir middleware:
// eski RequestTimingModule (IHttpModule) artık bir middleware
app.Use(async (context, next) =>
{
long start = Stopwatch.GetTimestamp();
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Elapsed-Ms"] = Stopwatch.GetElapsedTime(start).TotalMilliseconds.ToString("0.0");
return Task.CompletedTask;
});
await next(context);
});
EndRequest'te header eklemek MVC 5'te çoğu zaman çalışırdı; ASP.NET Core'da yanıt başladıktan sonra header eklenemiyor, o yüzden OnStarting kullanıyoruz. Modüllerin web.config'teki sıralaması burada Program.cs'teki satır sırası; Web API serisinin middleware yazısındaki kurallar aynen geçerli. IHttpHandler'ların karşılığı ise doğrudan bir endpoint: .ashx ile dosya üreten bir handler, app.MapGet("/export/{id}", ...) olur.
View Tarafı: Ne Derlenmiyor?
Eski bir view'ı yeni projeye kopyaladım:
@helper PriceTag(decimal price)
{
<span class="price">@price.ToString("0.00")</span>
}
@Styles.Render("~/Content/css")
@Html.Action("CartSummary", "Cart")
@Html.Partial("_Header")
Build çıktısı (ikinci sorunun cevabı):
Views/Home/Legacy.cshtml(1,2): error CS0103: The name 'helper' does not exist in the current context
Views/Home/Legacy.cshtml(1,2): error RZ1002: The helper directive is not supported.
Views/Home/Legacy.cshtml(3,26): error CS0103: The name 'price' does not exist in the current context
Views/Home/Legacy.cshtml(5,2): error CS0103: The name 'Styles' does not exist in the current context
Views/Home/Legacy.cshtml(6,2): error CS1929: 'IHtmlHelper<dynamic>' does not contain a definition for 'Action' and the best extension method overload 'UrlHelperExtensions.Action(IUrlHelper, string?, object?)' requires a receiver of type 'Microsoft.AspNetCore.Mvc.IUrlHelper'
Views/Home/Legacy.cshtml(7,2): warning MVC1000: Use of IHtmlHelper.Partial may result in application deadlocks. Consider using <partial> Tag Helper or IHtmlHelper.PartialAsync.
Dört satırdan beş hata ve bir uyarı çıktı. @helper directive'i ASP.NET Core Razor'da yok (RZ1002). Küçük bir HTML (HyperText Markup Language) parçası için partial view ya da tag helper, tek view'a özel bir şey için @functions içinde HTML dönen bir local function yeterli. @Styles.Render ve @Scripts.Render System.Web.Optimization'ın parçasıydı; yerine static asset yazısında gördüğümüz <link href="~/css/site.css"> ve MapStaticAssets geliyor. @Html.Action child action'ı çağırıyordu ve ASP.NET Core'da child action yok; karşılığı serinin partial ve view component yazısındaki CartSummaryViewComponent. @Html.Partial hâlâ derleniyor, ama senkron olduğu için uyarı veriyor; <partial name="_Header" /> kullan.
Gözden kaçan farklar da var. Request.QueryString["x"] yerine Request.Query["x"], Request.Form yerine model binding, Url.Content("~/...") hâlâ var ama tag helper'lar ~/'yi zaten çözüyor. ViewBag ve ViewData hâlâ çalışıyor; MVC yazısında neden ViewModel tercih ettiğimizi konuşmuştuk.
Parça Parça Taşımak: YARP ile Strangler Fig
Microsoft'un geçiş dokümanı büyük ve üretimde kalması gereken uygulamalar için incremental migration'ı öneriyor ve bunu Strangler Fig pattern'inin bir uygulaması olarak tarif ediyor. Fikir şu: önüne yeni bir ASP.NET Core uygulaması koyuyorsun. Taşınan URL'leri yeni uygulama cevaplıyor, geri kalan her şey olduğu gibi eski uygulamaya iletiliyor. Kullanıcı tek bir adres görüyor; sen sayfa sayfa ilerliyorsun ve son sayfa taşındığında eski uygulamayı kapatıyorsun. Pattern'in mimari tarafını ilerideki API Gateway ve YARP (Yet Another Reverse Proxy) serisinde ayrıca ele alacağız; burada bize gereken tek satırlık bir fallback:
builder.Services.AddControllersWithViews();
builder.Services.AddSystemWebAdapters();
builder.Services.AddHttpForwarder();
var app = builder.Build();
app.UseRouting();
app.UseSystemWebAdapters();
app.UseAuthorization();
app.MapStaticAssets();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapControllers().WithStaticAssets();
// henüz taşınmamış her şey eski uygulamaya gider
app.MapForwarder("/{**catch-all}", app.Configuration["LegacyAppUrl"]!)
.WithOrder(int.MaxValue)
.ShortCircuit();
WithOrder(int.MaxValue) bu route'u en düşük önceliğe koyuyor: yeni uygulamada eşleşen bir controller varsa o kazanıyor, yoksa istek YARP ile eski uygulamaya gidiyor. ShortCircuit() iletilen istekler için pipeline'ın geri kalanını atlıyor. /Books taşındı, /Orders ve /Account/LogOn henüz taşınmadı:
=== GET /Books
Books (ASP.NET Core) | page size 20 | 42.50 TRY
HTTP/1.1 200 OK
X-Elapsed-Ms: 24.0
=== GET /Orders
<h1>Orders (legacy MVC 5)</h1><p>::1</p>
HTTP/1.1 200 OK
X-Elapsed-Ms: 37.2
=== GET /Account/LogOn?returnUrl=%2F
legacy: /Account/LogOn
HTTP/1.1 200 OK
Eski uygulama X-Forwarded-For header'ını görüyor (::1); kullanıcının gerçek IP (Internet Protocol) adresine ihtiyaç duyan eski kod bu header'ı okuyacak şekilde ayarlanmalı. Microsoft'un dokümanı, eski uygulamanın dışarıya doğrudan açılmamasını, yalnız proxy üzerinden erişilmesini ve iki uygulamanın aynı virtual directory düzenini kullanmasını şart koşuyor.
System.Web Adapters: HttpContext.Current'ı Kırmadan Taşımak
Strangler fig'in pratik sorunu şu: /Books'u taşıdığında, o sayfanın kullandığı class library'ler de yeni tarafa geçmeli. On yıllık bir library'de HttpContext.Current her yerdedir. System.Web adapters paketi, System.Web.HttpContext, HttpRequest, HttpResponse gibi tiplerin bir alt kümesini ASP.NET Core üzerinde yeniden tanımlıyor; böylece bu kod çoğu zaman olduğu gibi derleniyor. Önce Tahmin Et'teki dosya:
using System.Web;
namespace Bookstore.Web.Legacy;
// MVC 5 projesinden olduğu gibi kopyalandı
public static class LegacyPriceFormatter
{
public static string Format(decimal price)
{
string currency = HttpContext.Current.Request.QueryString["currency"] ?? "TRY";
return $"{price:0.00} {currency}";
}
}
İlk sorunun cevabı:
Legacy/LegacyPriceFormatter.cs(10,27): error CS0104: 'HttpContext' is an ambiguous reference between 'Microsoft.AspNetCore.Http.HttpContext' and 'System.Web.HttpContext'
Adapters paketi doğru çalışıyor; sorun .NET 6'dan beri varsayılan olan ImplicitUsings. Web SDK'sı her dosyaya görünmez bir using Microsoft.AspNetCore.Http; ekliyor ve iki HttpContext çakışıyor. Eski library'yi ayrı bir projede tutup orada ImplicitUsings'i kapatmak bir yol. Bizim gibi aynı projedeysen tip adını tam yazmak yeterli. Tam adı yazınca derleyici bu sefer bir uyarı verdi:
Legacy/LegacyPriceFormatter.cs(9,27): warning CS8602: Dereference of a possibly null reference.
Bu da yerinde bir uyarı: adapters'taki HttpContext.Current nullable işaretli ve bir istek dışında (background job, startup kodu) gerçekten null. .NET Framework'te de öyleydi, ama derleyici söylemiyordu. Son hali:
namespace Bookstore.Web.Legacy;
// MVC 5 projesinden taşındı; tek değişiklik System.Web'in tam adı ve ?.
public static class LegacyPriceFormatter
{
public static string Format(decimal price)
{
// ImplicitUsings Microsoft.AspNetCore.Http'yi getiriyor, System.Web'i tam adıyla yaz
string currency = System.Web.HttpContext.Current?.Request.QueryString["currency"] ?? "TRY";
return $"{price:0.00} {currency}";
}
}
builder.Services.AddSystemWebAdapters() ve app.UseSystemWebAdapters() (routing'den sonra) ile kod çalışıyor: /Books?currency=EUR isteği 42.50 EUR döndü. Adapters'ın amacı geçiş sürecini kısaltmak, kalıcı bir mimari olmak değil. Yeni yazdığın kodda constructor injection ve IHttpContextAccessor kullanmaya devam et; adapters'ı yalnız henüz dokunmadığın eski library'ler için tut.
Paketin bir de remote app özellikleri var: yeni uygulama, session ve authentication için eski uygulamaya sorabiliyor. Eski uygulamanın Session["Cart"]'ına yazdığı sepeti yeni uygulama okuyabiliyor; kullanıcı eski uygulamada açtığı oturumla yeni sayfalarda da tanınıyor. Bunun için iki tarafta da RemoteAppApiKey (GUID (Globally Unique Identifier) biçiminde paylaşılan bir anahtar) ve yeni tarafta RemoteAppUri tanımlanıyor; .NET Framework tarafında Global.asax'a AddRemoteAppServer ekleniyor. Bu kısmı .NET Framework olmadan çalıştıramadığım için kod vermiyorum; Microsoft'un "Remote app setup" dokümanı iki tarafın kodunu adım adım gösteriyor.
Araçlar: Upgrade Assistant'tan Copilot'a
Uzun süre bu işin aracı .NET Upgrade Assistant'tı. Microsoft'un dokümanı artık onu resmi olarak deprecated olarak işaretliyor ve yerine Visual Studio 2026 ile Visual Studio 2022 17.14.16 ve sonrasında gelen GitHub Copilot app modernization agent'ını gösteriyor. Agent çözümü analiz ediyor, bir geçiş planı çıkarıyor, proje dosyalarını SDK-style'a çeviriyor, kullanımdan kalkan API'leri değiştiriyor ve her adımı ayrı commit'liyor. Kodun local bir Git repository'sinde olması gerekiyor ve basın haberlerine göre ücretli bir Copilot planı istiyor.
Araçların sınırını bilmek önemli. Proje dosyasını dönüştürmek, namespace'leri değiştirmek, packages.config'ten PackageReference'a geçmek mekanik işler ve araçlar bunları iyi yapıyor. HttpContext.Current'ı on yerde kullanan bir library'yi DI'a çevirmek, session'da object saklayan kodu yeniden tasarlamak, child action'ları view component'e bölmek ise tasarım kararı. Bir araç bunları önerebilir, ama doğru olup olmadığına sen karar veriyorsun ve test etmen gerekiyor.
Üretimde Dikkat
İki uygulama, iki log. Geçiş süresince bir istek iki uygulamadan da geçebiliyor; ortak bir correlation ID (ör. W3C (World Wide Web Consortium) standardı olan traceparent header'ı) ve tek bir log hedefi olmadan "hata hangi taraftaydı" sorusunun cevabı saatler alır. YARP trace bağlamını taşıyor. Yeni uygulamaya traceparent ile gelen bir istek, eski uygulamaya aynı trace ID'siyle ve yeni bir span ID'siyle ulaşıyor:
incoming: traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
at legacy: traceparent=00-0af7651916cd43dd8448eb211c80319c-7618bff8a209c373-01
Eski uygulamanın da bu header'ı loglaması gerekiyor; yoksa zincir orada kopuyor.
Authentication cookie'lerini paylaşmak ayrı bir iş. Eski uygulama OWIN (Open Web Interface for .NET) cookie middleware'i kullanıyorsa iki taraf ortak bir Data Protection key ring'i ve aynı cookie adıyla aynı cookie'yi okuyabilir; Forms Authentication kullanan daha eski uygulamalarda en pratik yol System.Web adapters'ın remote authentication özelliği. Derinliği ilerideki Authentication ve Authorization serisinde.
Session davranışı değişiyor. ASP.NET Core session'ı byte array saklıyor ve object referansı tutmuyor; eski kodda Session["Cart"]'tan alınıp değiştirilen ve "kendiliğinden kaydedilen" object'ler yeni tarafta kaydedilmez. Taşıdığın sayfada session'a yazılan her şeyi açıkça serialize et.
Taşıma sırası için trafik verisine bak. En çok ziyaret edilen ama en az bağımlılığı olan sayfalar (katalog, ürün detayı) erken kazanç verir; sipariş ve ödeme gibi session ve transaction yoğun akışlar en sona kalır.
Sık Düşülen Tuzaklar
Her şeyi tek seferde yeniden yazmaya kalkmak. Bir yıl süren paralel geliştirme boyunca eski uygulama da değişir; yeni kod hiçbir zaman yetişemez. Strangler fig'in değeri, her hafta üretime giden küçük adımlar.
ImplicitUsings'i unutmak. HttpContext, HttpRequest, HttpResponse gibi adlar iki namespace'te birden var; CS0104 hatası buradan geliyor.
@Html.Partial'ı bırakmak. Derleniyor ama MVC1000 uyarısı boşuna değil; senkron render, yük altında thread pool'u tüketebilir.
Fallback route'una öncelik vermemek. WithOrder(int.MaxValue) olmadan catch-all route'u, yeni uygulamanın kendi endpoint'leriyle yarışır.
Eski uygulamayı proxy'nin arkasında da dışarıya açık bırakmak. İki giriş kapısı, iki ayrı güvenlik yüzeyi demek.
Küçük Bir Deneme
★☆☆ Yukarıdaki eski view'daki @helper PriceTag için iki karşılık yaz: bir partial view (_PriceTag.cshtml) ve bir tag helper (<price-tag value="...">). Hangisi test etmeye ve yeniden kullanmaya daha uygun?
★★☆ Bu yazıdaki iki uygulamayı ayağa kaldır ve /Orders sayfasını da yeni uygulamaya taşı: bir OrdersController ekle. Hiçbir YARP ayarına dokunmadan /Orders'ın artık yeni uygulamadan geldiğini curl ile doğrula.
★★★ Eski uygulamanın X-Forwarded-For header'ını okuyarak kullanıcının IP adresini logladığını varsay. YARP'ın gönderdiği X-Forwarded-* header'larını incele ve yeni uygulamanın kendisi bir load balancer'ın arkasındayken (UseForwardedHeaders) zincirin doğru IP'yi taşıdığını test et.
Bu yazıyla geriye dönüp eski kodu yeni dünyaya taşımanın yollarını gördük. Serinin son yazısında ileriye bakıyoruz: .NET 8'den .NET 11'e MVC ve Razor Pages'e neler geldi, hangi davranışlar sessizce değişti ve bir sürüm yükseltmesinde neyi kontrol etmen gerekiyor.