MVC ve Razor Pages'te Localization: IStringLocalizer, IViewLocalizer, Request Culture ve Türkçe "i" Tuzağı
UseRequestLocalization her istek için kültürü query string, cookie, Accept-Language ya da URL'den seçer; .resx, IViewLocalizer ve DataAnnotations mesajları bu kültürle çevrilir. Bookstore kataloğunu TR/EN yapıyor; tr ile tr-TR farkını, query string'de 25,5'in 255 olmasını, "C" formatı ve Türkçe i tuzağını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.402) ile derlenip denenmiştir; kültür verisi macOS'taki ICU (International Components for Unicode) library'sinden gelir.
Önce Tahmin Et
Bookstore'un Türkçe kataloğu /tr/catalog adresinde ve sayfa, fiyatı belli bir tutarın altındaki kitapları gösteren bir MaxPrice filtresi alıyor. Türkçe kullanıcı adres çubuğuna alışkanlıkla ?maxPrice=25,5 yazıyor. Katalogda 25,50'nin altında iki kitap var. Sayfada kaç kitap görür? Aynı kullanıcı yönetim panelindeki fiyat alanına 42,50 yazıp kaydediyor; aynı formu İngilizce arayüzde kullanan bir editör ise yine 42,50 yazıyor. Veritabanına iki farklı sayı mı yazılır? Cevaplar yazının ortasında, gerçek çıktılarla.
Kültür Nereden Geliyor?
.NET'te iki ayrı kültür var. CultureInfo.CurrentCulture sayı, tarih ve para birimi biçimlendirmesini, string karşılaştırmayı ve büyük/küçük harf dönüşümünü belirliyor. CultureInfo.CurrentUICulture ise hangi .resx dosyasından metin okunacağını seçiyor. Localization (yerelleştirme) derken çoğu zaman ikincisi akla gelir, ama hataların çoğu birincisinden çıkıyor.
İkisini de ayarlamazsan sunucunun işletim sistemi karar veriyor. Bu serinin örneklerini Türkçe bir macOS'ta yazıyorum. Razor Pages yazısındaki listeyi ilk çalıştırdığımda fiyatlar şöyle geldi:
<td>The Pragmatic Path</td>
<td>Ada Lane</td>
<td>42,50</td>
Kodda hiçbir kültür ayarı yoktu; ToString("0.00") makinenin dilini kullandı. Aynı uygulama İngilizce bir Linux container'ında 42.50 yazar. Yerelde her şey doğru görünür, sunucuda başka türlü çıkar. Bu yüzden seri boyunca sonraki çıktıları LC_ALL=en_US.UTF-8 ile aldım. Bu yazıda kültürü işletim sistemine bırakmıyor, her istek için kendimiz seçiyoruz.
Kurulum: Service'ler ve Middleware
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddControllersWithViews()
.AddViewLocalization()
.AddDataAnnotationsLocalization(options =>
options.DataAnnotationLocalizerProvider = (_, factory) => factory.Create(typeof(SharedResource)));
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
string[] cultures = ["en-US", "tr-TR"];
options.SetDefaultCulture("en-US")
.AddSupportedCultures(cultures)
.AddSupportedUICultures(cultures);
options.ApplyCurrentCultureToResponseHeaders = true;
});
Pipeline'da tek satır var, ama yeri önemli:
app.UseRouting();
app.UseRequestLocalization();
AddLocalization IStringLocalizer<T>'yi ve .resx okuyan factory'yi kaydediyor. AddViewLocalization view'lara IViewLocalizer getiriyor, AddDataAnnotationsLocalization validation mesajlarını localizer'dan geçiriyor. UseRequestLocalization her istekte provider'ları sırayla deniyor ve ilk cevap veren provider'ın kültürünü o isteğin CurrentCulture ve CurrentUICulture'ı yapıyor. Bu ayar async akış boyunca isteğe bağlı kalıyor; başka isteklere sızmıyor. ApplyCurrentCultureToResponseHeaders da seçilen kültürü Content-Language header'ı olarak yanıta ekliyor; aşağıdaki testlerde hangi provider'ın kazandığını bu header'dan okuyacağız.
Request Culture Provider'ları
Varsayılan sıra şöyle: query string (?culture=tr-TR), cookie (.AspNetCore.Culture), ardından tarayıcının gönderdiği Accept-Language header'ı. Hiçbiri sonuç vermezse SetDefaultCulture ile verdiğin kültür kullanılıyor. Gerçek istekler:
Accept-Language: tr-TR,tr;q=0.9,en;q=0.8 -> Content-Language: tr-TR
Accept-Language: de-DE,tr-TR;q=0.5 -> Content-Language: tr-TR
Accept-Language: tr -> Content-Language: en-US
?culture=tr-TR -> Content-Language: tr-TR
Cookie tr-TR + Accept-Language: en-US -> Content-Language: tr-TR
Üçüncü satıra dikkat. Desteklenen listede tr-TR var, istemci yalnız tr gönderiyor ve sonuç İngilizce. Middleware bir kültür bulamazsa onun parent'ına (tr-TR → tr) doğru yukarı çıkıyor, ama tr'den tr-TR'ye aşağı inmiyor. Neutral kültürü desteklenenlere eklemek bu boşluğu kapatır, ancak tr ile para birimi biçimlendirmesi ¤39,99 çıkıyor; neutral kültürün bir para birimi yok. Bu da aşağıda konuşacağımız "para birimini kültüre bırakma" kuralının bir gerekçesi.
Kullanıcıya dil seçtirmek için cookie yazan küçük bir Razor Page yeterli:
public class CultureModel : PageModel
{
public IActionResult OnPost(string culture, string? returnUrl)
{
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1), IsEssential = true });
return LocalRedirect(returnUrl ?? "/");
}
}
HTTP/1.1 302 Found
Location: /Manage/Books
Set-Cookie: .AspNetCore.Culture=c%3Dtr-TR%7Cuic%3Dtr-TR; expires=Mon, 08 Nov 2027 02:13:22 GMT; path=/
Cookie değeri c=tr-TR|uic=tr-TR biçiminde: c biçimlendirme kültürü, uic UI (User Interface) kültürü. LocalRedirect yalnız uygulama içi adreslere redirect ediyor; returnUrl bir query string parametresi olduğu için bu bir open redirect korumasıdır.
URL'de Kültür: /tr/catalog
Arama motorları ve paylaşılan linkler için kültürün URL'de (Uniform Resource Locator) olması en sağlam yol. İlk denemem dokümandaki RouteDataRequestCultureProvider'ı listenin başına eklemekti. /tr/catalog sayfası hata vermeden açıldı, ama başlık Catalog (en-US) idi ve header Content-Language: en-US diyordu. Sebep yukarıdaki tr satırıyla aynı: route değeri tr, desteklenen kültür tr-TR. Provider sonucu destekli bulunmayınca middleware sessizce bir sonraki provider'a geçiyor. Çözüm, route değerini tam kültür adına çeviren bir custom provider:
// /tr/catalog -> tr-TR; route değerleri ancak UseRouting'den sonra var
options.AddInitialRequestCultureProvider(new CustomRequestCultureProvider(context =>
{
ProviderCultureResult? result = context.GetRouteValue("culture") switch
{
"tr" => new ProviderCultureResult("tr-TR"),
"en" => new ProviderCultureResult("en-US"),
_ => null
};
return Task.FromResult(result);
}));
UseRequestLocalization'ı UseRouting'den sonra koymamızın sebebi bu provider: route değerleri ancak routing eşleşmesinden sonra HttpContext'te var. Sayfanın kendisi route template'iyle kültürü kısıtlıyor:
@page "/{culture:regex(^(en|tr)$)}/catalog"
@model Bookstore.Web.Pages.Catalog.IndexModel
@inject IViewLocalizer Localizer
@{
ViewData["Title"] = Localizer["Catalog"];
}
<h1>@Localizer["Catalog"] (@Model.CultureName)</h1>
<p>@Localizer["{0} books", Model.Books.Count] / max: @Model.MaxPrice</p>
<table>
<tr><th>@Localizer["Title"]</th><th>@Localizer["Price"]</th><th>@Localizer["Published"]</th></tr>
@foreach (Book book in Model.Books)
{
<tr>
<td>@book.Title</td>
<td>@book.Price.ToString("N2") | @book.Price.ToString("C")</td>
<td>@book.PublishedOn.ToString("d") | @book.PublishedOn.ToString("D")</td>
</tr>
}
</table>
.resx Dosyaları ve IViewLocalizer
IViewLocalizer, resource dosyasını view'ın yolundan buluyor. Pages/Catalog/Index.cshtml için ResourcesPath = "Resources" ile aranan dosya Resources/Pages/Catalog/Index.tr.resx:
<data name="Catalog" xml:space="preserve">
<value>Katalog</value>
</data>
<data name="{0} books" xml:space="preserve">
<value>{0} kitap</value>
</data>
İngilizce için bir .resx dosyası yok. Anahtar bulunamayınca localizer anahtarın kendisini döndürüyor; İngilizce metni anahtar olarak yazdığımız için İngilizce sayfa hiçbir dosyaya ihtiyaç duymuyor. İki dilin gerçek çıktısı:
<h1>Catalog (en-US)</h1>
<p>4 books / max: </p>
<tr><th>Title</th><th>Price</th><th>Published</th></tr>
<td>Clean Lines</td>
<td>39.99 | $39.99</td>
<td>1/20/2023 | Friday, January 20, 2023</td>
<h1>Katalog (tr-TR)</h1>
<p>4 kitap / max: </p>
<tr><th>Başlık</th><th>Fiyat</th><th>Yayın tarihi</th></tr>
<td>Clean Lines</td>
<td>39,99 | ₺39,99</td>
<td>20.01.2023 | 20 Ocak 2023 Cuma</td>
₺ tuhaf görünüyor. Diğer satırlar da benzer:
<td>5.09.2022 | 5 Eylül 2022 Pazartesi</td>
<td>1.10.2019 | 1 Ekim 2019 Salı</td>
Razor, @ ile basılan her değeri HTML (HyperText Markup Language) encode ediyor ve varsayılan encoder yalnız Basic Latin aralığını olduğu gibi bırakıyor. "ü", "ı", "ş", "₺" sayısal karakter referansına dönüşüyor. Tarayıcı bunları doğru gösterir, yani bir güvenlik ya da görüntü sorunu yok. Ama HTML kaynağı okunmaz hale geliyor, sayfa boyutu büyüyor ve HTML üzerinde metin arayan testler kırılıyor. Türkçe içerikli bir uygulamada encoder'a izin verilen aralığı genişletmek yaygın bir ayar:
// "ü" karakterini Razor çıktısında "ü"e çevirme
builder.Services.Configure<WebEncoderOptions>(options =>
options.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));
Bu ayardan sonra aynı satırlar ₺39,99 ve 5 Eylül 2022 Pazartesi olarak geliyor. <, >, & ve tırnaklar yine encode ediliyor; XSS (Cross-Site Scripting) korumasından bir şey kaybetmiyorsun. Razor'un encoding davranışını serinin Razor syntax yazısında görmüştük. IViewLocalizer'ın bir farkı var: resource string'inin kendisini encode etmiyor, yalnız {0} gibi parametreleri encode ediyor. Çeviri dosyasına HTML koyabilirsin, ama çevirmenlerin eline geçen bir dosya artık sayfana doğrudan HTML basabilir demek. Microsoft'un dokümanı da "yalnız metni yerelleştir, HTML'i değil" diyor.
DataAnnotations Mesajları ve Display Adları
Form'un validation mesajları için tek bir paylaşılan resource kullanıyoruz: SharedResource marker class'ı ve Resources/SharedResource.tr.resx. Kuralın en çok gözden kaçan kısmı şu: localizer yalnız senin yazdığın ErrorMessage'ı anahtar olarak kullanıyor. [Required]'ı mesajsız bırakırsan framework'ün varsayılan İngilizce mesajı resource'a hiç sorulmuyor.
[Display(Name = "Title")]
[Required(ErrorMessage = "The {0} field is required."), StringLength(120)]
public string Title { get; set; } = "";
[Required, RegularExpression(@"^\d{13}$", ErrorMessage = "ISBN must be 13 digits.")]
public string Isbn { get; set; } = "";
[Display(Name = "Price")]
[Range(0.01, 10000, ErrorMessage = "The field {0} must be between {1} and {2}.")]
public decimal Price { get; set; }
Türkçe kültürde boş form gönderince:
<label for="Input_Title">Başlık</label>
<span class="field-validation-error" data-valmsg-for="Input.Title" data-valmsg-replace="true">Başlık alanı zorunludur.</span>
<label for="Input_Isbn">Isbn</label>
<span class="field-validation-error" data-valmsg-for="Input.Isbn" data-valmsg-replace="true">ISBN 13 haneden oluşmalıdır.</span>
<label for="Input_Price">Fiyat</label>
<span class="field-validation-error" data-valmsg-for="Input.Price" data-valmsg-replace="true">Fiyat alanı 0,01 ile 10000 arasında olmalıdır.</span>
<label for="Input_Stock">Stock</label>
[Display(Name = "Title")] eklediğimiz alanlarda hem label hem mesajdaki {0} Türkçe. Stock'ta Display attribute'u olmadığı için label property adıyla kalıyor. Aynı sayfanın HTML'inde Isbn input'unun data-val-required attribute'u hâlâ The Isbn field is required. diyor, çünkü o [Required]'ın mesajı yok. data-val-number="The field Price must be a number." da İngilizce kalıyor; o mesaj DataAnnotations'tan değil, model binding'in ModelBindingMessageProvider'ından geliyor ve MvcOptions üzerinden ayrıca çevrilmesi gerekiyor. Validation yazısında bunun client-side tarafını konuşmuştuk.
Sayılar, Tarihler ve Para Birimi
Katalog çıktısındaki ToString("C") sütununa tekrar bak: aynı kitap İngilizce sayfada $39.99, Türkçe sayfada ₺39,99. Kitabın fiyatı değişmedi, yalnızca para biriminin sembolü değişti. Yani yanlış. "C" formatı tutarın hangi para biriminde olduğunu bilmiyor, sembolü o anki kültürden alıyor. Fiyat tek bir para biriminde tutuluyorsa N2 ile sayıyı biçimlendir, para birimini veriden ya da sabit bir koddan ekle. Tarih formatlarında ise kültüre bırakmak doğru: d ve D her dilde okurun beklediği sırayı veriyor.
Şimdi baştaki sorular. Query string'deki maxPrice için gerçek sonuç:
/tr/catalog?maxPrice=25,5 -> <p>4 kitap / max: 255</p>
/tr/catalog?maxPrice=25.5 -> <p>2 kitap / max: 25,5</p>
Türkçe sayfada, Türkçe kültür seçiliyken 25,5 255 olarak bind edildi ve dört kitabın hepsi listelendi. Sebep ASP.NET Core'un bilinçli bir kararı: route ve query string değerleri, kültürden bağımsız URL'ler üretilebilsin diye InvariantCulture ile bind ediliyor. Invariant kültürde virgül binlik ayırıcı; 25,5 hata vermiyor, 255 oluyor. Form değerleri ise o anki kültürle bind ediliyor. Aynı edit formuna dört ayrı POST gönderdim:
tr-TR Input.Price=42,50 -> 42.50
tr-TR Input.Price=42.50 -> 4250.00
en-US Input.Price=42,50 -> 4250.00
en-US Input.Price=42.50 -> 42.50
İki kullanıcı aynı 42,50'yi yazdı ve veritabanına iki farklı sayı girdi; ikisinde de validation hatası yok, çünkü 4250 de [Range(0.01, 10000)] içinde. Server tarafı kendi içinde tutarlı: Türkçe arayüzde input value="42,50" ile render ediliyor ve aynı kültürle geri okunuyor. Sorun kültür değiştiğinde ya da biri alışkanlıkla diğer ayırıcıyı yazdığında çıkıyor. Bu yüzden para gibi kritik alanlar için ayrı ayırıcı kontrolü, makul üst sınır ve gerekirse type="number" input'u (tarayıcı değeri her zaman noktalı gönderir) düşünmeye değer.
Türkçe "i" Tuzağı
Türkçe, büyük/küçük harf dönüşümünde İngilizceden ayrılan birkaç dilden biri: küçük "i"nin büyüğü "İ", büyük "I"nın küçüğü "ı". Request localization'ı açtığın an, Türkçe kullanıcıların her isteği tr-TR kültürüyle çalışıyor ve kültüre duyarlı her string işlemi bu kurala uyuyor:
CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
string role = "admin";
Console.WriteLine($"ToUpper: {role.ToUpper()}");
Console.WriteLine($"== \"ADMIN\": {role.ToUpper() == "ADMIN"}");
Console.WriteLine($"ToUpperInvariant: {role.ToUpperInvariant() == "ADMIN"}");
Console.WriteLine($"\"ISBN\".ToLower(): {"ISBN".ToLower()}");
Console.WriteLine($"CurrentCultureIgnore: {string.Equals("FILE", "file", StringComparison.CurrentCultureIgnoreCase)}");
Console.WriteLine($"OrdinalIgnoreCase: {string.Equals("FILE", "file", StringComparison.OrdinalIgnoreCase)}");
ToUpper: ADMİN
== "ADMIN": False
ToUpperInvariant: True
"ISBN".ToLower(): ısbn
CurrentCultureIgnore: False
OrdinalIgnoreCase: True
if (user.Role.ToUpper() == "ADMIN") gibi bir satır aylarca çalışır, dil seçici eklendiği gün yalnız Türkçe seçen yöneticiler için kırılır. Hiçbir hata log'a düşmez; kullanıcı yalnızca yetkisiz olduğunu görür. Aynı sorun "ISBN".ToLower() ile oluşturulan cache key'lerinde, dosya uzantısı kontrollerinde (".PDF".ToLower()) ve header adı karşılaştırmalarında da var. Kural basit: makinenin okuduğu string'ler (rol adı, key, uzantı, protokol değeri) için Ordinal/OrdinalIgnoreCase ve ToUpperInvariant; kültür yalnızca insanın okuyacağı metin için.
Sıralama ise tam tersi; insana gösterilen listede kültüre duyarlı karşılaştırma istiyorsun:
tr-TR order: Canberra, Çanakkale, Istanbul, Izmir, Ödemiş, Zagreb
ordinal order: Canberra, Istanbul, Izmir, Zagreb, Çanakkale, Ödemiş
Ordinal sıralama Unicode kod noktasına baktığı için "Çanakkale" ve "Ödemiş" listenin sonuna düşüyor. Veritabanı tarafındaki sıralama collation'a bağlı; Entity Framework Core (EF Core) serisinde gördüğümüz gibi o ayrı bir karar.
Üretimde Dikkat
Container'da ICU olduğundan emin ol. .NET'in chiseled container image'ları ICU içermiyor ve globalization-invariant modda çalışmak üzere tasarlanmış; ICU ve tzdata isteyen uygulamalar için -extra varyantları var. Invariant modda tr-TR oluşturmaya çalıştığımda uygulama açılmadı bile:
Unhandled exception. System.Globalization.CultureNotFoundException: Only the invariant culture is supported in globalization-invariant mode. See https://aka.ms/GlobalizationInvariantMode for more information. (Parameter 'name')
tr-TR is an invalid culture identifier.
Localized yanıtları cache'lerken kültürü key'e kat. Aynı URL cookie'ye göre iki farklı dilde içerik dönüyorsa output cache ya da reverse proxy kültürü bilmeden ilk gelen dili herkese sunar. URL'de kültür taşımak bu sorunu kökten çözüyor; cookie tabanlı seçimde ise cache'in kültüre göre ayrışması gerekiyor (ilerideki Caching serisinin konusu).
Kültür cookie'si teknik olarak bir tercih cookie'si. Uygulamada cookie consent kullanıyorsan IsEssential = true vermezsen, onay vermemiş kullanıcının dil seçimi kaydedilmez ve kullanıcı neden sürekli İngilizceye döndüğünü anlamaz.
Çeviri anahtarları için bir karar ver. Biz İngilizce cümleyi anahtar yaptık. Hızlı başlıyor ve İngilizce için dosya gerektirmiyor, ama İngilizce metinde bir virgülü düzelttiğin an anahtar değişiyor ve Türkçe çeviri sessizce kayboluyor; sayfada İngilizce metin görünüyor. Büyüyen uygulamalarda Catalog.Title gibi anlamsal anahtarlar ve İngilizce için de bir .resx dosyası bakımı kolaylaştırır. Bu tercih ekip büyüdükçe ve çevirmen sayısı arttıkça önem kazanıyor.
Sık Düşülen Tuzaklar
CultureInfo.DefaultThreadCurrentCulture'ı Program.cs'te ayarlamak. Process genelindeki bütün yeni thread'lerin varsayılanını değiştiriyor; istek bazlı değil. Kültürü UseRequestLocalization seçsin, background service'ler gibi istek dışı kod da kültürü açıkça versin.
Desteklenen listede yalnız tr-TR varken tr gelmesini beklememek. Route değeri, query string ya da Accept-Language olsun, tr aşağı doğru tr-TR'ye eşlenmiyor ve kullanıcı sessizce varsayılan dile düşüyor.
Varsayılan validation mesajlarının çevrileceğini sanmak. Mesajı olmayan attribute'lar ve model binding mesajları localizer'a uğramıyor.
Para birimini "C" formatına bırakmak. Kültür değiştiğinde fiyatın para birimi değişmiş gibi görünüyor.
Query string'deki ondalık sayıyı kullanıcı kültürüyle okunacak sanmak. Route ve query değerleri invariant kültürle bind ediliyor; 25,5 255 oluyor.
Küçük Bir Deneme
★☆☆ Resources/Pages/Catalog/Index.tr.resx dosyasından {0} books anahtarını sil ve /tr/catalog'u aç. Sayfada ne görüyorsun? Hangi metnin çevrilmediğini log'dan yakalamak için IStringLocalizer'ın döndürdüğü LocalizedString.ResourceNotFound property'sini kullanan bir yardımcı yaz.
★★☆ Isbn'deki [Required]'a ErrorMessage ver ve SharedResource.tr.resx'e karşılığını ekle. Sonra MvcOptions.ModelBindingMessageProvider.SetValueMustBeANumberAccessor ile "The field {0} must be a number." mesajını da Türkçeleştir.
★★★ /tr/catalog?maxPrice=25,5 isteğinin 255'e dönüşmesini engelle: query string için kültüre duyarlı bir IValueProviderFactory yaz ya da MaxPrice'ı string olarak alıp açıkça decimal.TryParse(..., NumberStyles.Number, CultureInfo.CurrentCulture, ...) ile oku. Hangisinin URL paylaşımında daha az sürpriz yarattığını tartış: Türkçe kullanıcının kopyaladığı link İngilizce kullanıcıda ne gösterir?
Bu yazıda sayfalarımızı iki dile ve iki kültüre taşıdık. Hâlâ her etkileşim tam bir sayfa yüklemesi demek: filtreyi değiştir, sayfa baştan gelsin. Bir sonraki yazıda htmx ile Razor Pages'e SPA (Single-Page Application) yazmadan etkileşim ekliyoruz: canlı arama, sonsuz scroll, satır içi düzenleme ve antiforgery token'ının htmx isteklerine taşınması.