Asp.NET Core’da Çok Dilli Uygulamalar Geliştirme
Merhaba,
Her web uygulamasının ideali, üretildiği kültürün sınırlarını aşabilmek ve daha geniş kitlelere ulaşarak farklı coğrafyalara hitap edebilmektir. Bu ideali gerçekleştirebilmek ve kozmopolit bir platform oluşturabilmek için birden çok dili destekleyebilmek ve gelen ziyaretçilerin bölge ve kültürlerine uygun içerik sunabilecek uygulamalar geliştirmek gerekmektedir. Asp.NET Core mimarisi, bu ihtiyaca istinaden özünde zengin bir küreselleştirme(globalization) ve yerelleştirme(localization) desteği barındırmakta ve biz geliştiriciler açısından kolaylıkla çok dilli uygulamalar oluşturmamızı sağlayacak olan özellikler sunmaktadır. Bu içeriğimizde, web uygulamalarımızda ziyaretçilerin tercihlerine göre yerelleştirilmiş içerikler sunabilmek için kullanabileceğimiz yapıları sizlere tanıtacak ve çoklu dil desteğinin temelde hangi mantıkla sağlandığını pratiksel olarak inceliyor olacağız.
Başlarken
İçeriğimiz boyunca anlatılanlara tatbik amaçlı eşlik edebilmek istiyorsanız eğer makaleye başlamadan önce hali hazırda bir Asp.NET Core MVC ya da API uygulaması oluşturmanızı tavsiye ederim. Ben deniz bu içeriğimizdeki tüm kodları .NET 6 – Asp.NET Core mimarisi üzerinden örneklendireceğim ve çalışma neticesinde örnek projeyi makalenin sonunda paylaşıyor olacağım.
Asp.NET Core’da Localization(Yerelleştirme) Yapılandırması
Asp.NET Core mimarisinin en güçlü yanlarından biri doğuştan middleware yapılanmasına dayanmasıdır. Haliyle client’lardan gelen istekleri seri bir şekilde türlü kontroller ve iş akışlarından geçirebilmekte ve duruma göre an öncesi aksiyonlar alıp, gerekli öncül çalışmaları sağlayabilmekteyiz.
Bu mantıkla olayı değerlendirirsek eğer, bir client’ın bölgesine göre gerekli yerelleştirmeyi sağlayabilmek için yapılan isteğin handle edilme sürecinde kültürün otomatik olarak ayarlanması gerekmektedir. İşte bu sebepten dolayı UseRequestLocalization middleware’ini yapılandırmamız gerekmektedir. Bunun için öncelikle ilgili uygulamanın ‘Program.cs’ dosyasına gelerek(Asp.NET Core 6.0’dan önceki sürümlerde Startup.cs dosyasına) aşağıdaki konfigürasyonun sağlanması gerekmektedir.
. . . var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews() .AddViewLocalization(); builder.Services.AddLocalization(options => { options.ResourcesPath = "Resources"; }); . . .
Yukarıdaki kod bloğuna göz atarsanız eğer; ilgili uygulamaya 9. satırda yerelleştirme ile ilgili AddLocalization servisinin eklendiğini görmekteyiz. Bu servis içerisindeki ‘ResourcesPath’ özelliği sayesinde, uygulamanın localization için nereden besleneceğine dair temel kaynak(resource) dosyalarının bulunduğu dizin bildirimini gerçekleştirmektedir.
Bunun dışında 6. satıra nazar eylersek eğer uygulamaya ‘AddControllersWithViews’ servisi akabinde AddViewLocalization servisi eklenmektedir. Bu servis ise MVC mimarisindeki V‘nin yani ‘View’ dosyalarının yerelleştirme için ihtiyacı olan hizmeti sağlamakta ve uygulamaya aktarmaktadır.
Uygulama Tarafından Desteklenecek Kültürlerin Yapılandırılması
Temel localization yapılandırmasından sonra sıra RequestLocalizationOptions konfigürasyonu üzerinden uygulama tarafından desteklenecek kültürlerin yapılandırmasına gelmiştir. Bunun için yine ‘Program.cs’ dosyasında(ya da Startup.cs) aşağıdaki gibi çalışılması yeterlidir;
. . . builder.Services.Configure<RequestLocalizationOptions>(options => { options.DefaultRequestCulture = new("tr-TR"); CultureInfo[] cultures = new CultureInfo[] { new("tr-TR"), new("en-US"), new("fr-FR") }; options.SupportedCultures = cultures; options.SupportedUICultures = cultures; }); . . .
Yukarıdaki kod bloğuna göz atarsanız eğer, uygulama sürecinde kullanılacak kültürlerin tanımlaması gerçekleştirilmiştir. Bizler örneklendirmemizde ‘Türkçe’, ‘İngilizce’ ve ‘Fransızca’ olmak üzere üç dil kullanacağız. Bu kültürler arasından ‘Türkçe’nin varsayılan kültür olarak belirlendiğine dikkatinizi çekerim.
Tüm bu işlemlerden sonra artık yapılması gereken tek hamle UseRequestLocalization middleware’inin çağrılarak, etkinleştirilmesidir.
. . . app.UseRequestLocalization(); . . .
Kullanıcıların hangi kültürden olduğunu nereden anlayacağız?
Gelen isteklerin hangi kültürden olduğunu anlayabilmek için şu ana kadar konfigürasyonunu oluşturduğumuz UseRequestLocalization middleware’inin çalışma mantığını incelememiz yeterli olacaktır. UseRequestLocalization middleware’i tetiklendiği anda konfigüre edilmiş olan RequestLocalizationOptions türünden nesnenin ayarlarını uygulamaya yüklemekte ve gelen istekteki kültürü RequestCultureProvider şeklinde nitelendirilen bir sağlayıcı dizisiyle kontrol etmektedir. Bu sağlayıcı dizisi;
- QueryStringRequestCultureProvider
- CookieRequestCultureProvider
- AcceptLanguageHeaderRequestCultureProvider
olmak üzere üç adettir. Kullanıcı yaptığı isteğin hangi kültürden olduğunu bu sağlayıcıların herhangi biriyle server’a bildirecektir. Haliyle bizler bu bildirime göre gerekli dil dönüşümünü sağlayacağız. Eğer ki kullanıcıdan herhangi bir provider’da kültür bilgisi söz konusu değilse o zaman ‘DefaultRequestCulture’ property’sine verilen kültür geçerli olacaktır.
Peki hoca! bu sağlayıcıları hafiften açıklayabilir misin?
Yazımızın devamında, çok dilliliği test edebilmek için bu provider’ları tek tek tam teferruatlı mevzu bahis ediyor olacağız. Amma velakin sizlerin meramını dindirebilmek ve hazır lafı geçmişken hafiften ön bilgi verebilmek için aşağıda sağlayıcılara dair kısaca açıklamalarda bulunalım.
- QueryStringRequestCultureProvider
Gelen isteğin hangi kültürden olduğunu query string’de ‘culture’ parametresi eşliğinde bildirmektedir. - CookieRequestCultureProvider
Gelen isteğin hangi kültürden olduğunu Cookie’de ‘.AspNetCore.Culture’ key’ine karşılık bildirmektedir. - AcceptLanguageHeaderRequestCultureProvider
Gelen isteğin hangi kültürden olduğunu header’da ‘Accept-Language’ key’ine karşılık bildirmektedir.
Artık uygulama üzerinde temel localization konfigürasyonu tamamlanmış bulunmaktadır. Bundan sonra, farklı dillerde metin karşılığını barındıracak olan resource dosyalarının adlandırma ve konum kurallarının detaylarını konuşmaya geçebiliriz.
Resource Dosyalarını Adlandırma ve Konumlandırma Kuralları
Asp.NET Core uygulamalarında, klasik Asp.NET’te olduğu gibi simgeler, resimler, özelleştirilmiş dosyalar yahut sabit metinler için .resx
uzantılı resource yapıları kullanılmaktadır. Bu yapılar sayesinde harici uzantıları ve sabit metinleri kaynak kodundan ayırabilmekte ve daha dinamik bir şekilde yönetebilmekteyiz. Bu mantıkla farklı kültür ve dillere dair gerekli verileri resource dosyalarında tutmamız ve o anki kültüre göre ihtiyaç doğrultusunda ilgili resource dosyasından verileri getirmemiz oldukça ideal bir yaklaşım olacaktır.
Peki resource dosyalarını nerede ve nasıl oluşturmalıyız?
Çoklu dil operasyonları için oluşturulacak resource dosyalarında isim standardı
[file-name].[language-code].resx
şeklinde olacaktır. Buna örnek vermemiz gerekirse eğer; İngilizce dil verilerini depolayacağımız resource kaynağının adı Lang.en-US.resx
olacakken, benzer mantıkla Fransızca için Lang.fr-FR.resx
şeklinde olacaktır. Eee haliyle Türkçe dil kaynağının ise Lang.tr-TR.resx
olacaktır. Tabi buradaki isim formatı her ne kadar bir standart gerektirse de kendi kararınıza göre değiştirilebilir.
Resource dosyalarının nerede tanımlanacağına gelirsek eğer bunun için yukarıda AddLocalization servisini tanımlarken ResourcesPath property’sine verdiğimiz ‘Resources’ değerini kullanacağız. Bu değer, projenin kök dizininde ‘Resources’ adında bir klasör olacağı ve içerisinde .resx
uzantılı dil kaynaklarının barındırılacağı anlamına gelmektedir.
Yukarıdaki görselde görüldüğü üzere ‘Resources\Languages‘ dizini altında örneklendirmemiz gereği üç dile karşılık dil depoları oluşturulmuş bulunmaktadır. Burada zihinlerinizden geçen Neden Resources klasörü altına Languages klasörü eklendi? sorusunu duyar gibiyim… Bunun nedeni birazdan bu resource dosyalarını okumayı ele alırken anlaşılacaktır. O noktaya gelmeden şimdilik bu kaynak dosyalarını açalım ve içlerini aşağıdaki gibi olacak şekilde dolduralım.
Lang.tr-TR | Lang.fr-FR | Lang.en-US |
---|---|---|
Resource Dosyalarının Okunması
Ziyaretçilerin kültürlerine göre resource dosyalarının okunabilmesi için temelde
- IStringLocalizer | IStringLocalizer<T>
- IHtmlLocalizer | IHtmlLocalizer<T>
olmak üzere iki farklı yerel servis mevcuttur. Her bir servisin kendi şahsına münhasır davranış modelinin mevcut olmasının yanında tümünde ortak olan teknik bir nokta vardır. O da, tüm servisler de resource dosyalarının okunmasının pratikte aynı altyapıyı gerektirmesidir. Haliyle bu servisleri incelemeye gelmeden önce mevzu bahis altyapıyı oluşturup hali hazır duruma getirelim.
Bunun için tek yapılması gereken resource dosyalarıyla aynı isimde olan bir sınıfın oluşturulmasıdır. Bizlerin dil verilerini depoladığımız dosyalara tekrar göz atarsanız Lang.***.resx
formatında oluşturulduğunu göreceksiniz. Haliyle bu resource dosyalarından okuma işlemini yapacak olan sınıfımızın adı da Lang.cs
olacaktır. Burada isimlendirme formatının kritik arz ettiği aşikardır. Dosyaların formatı ne ise, okuma işleminden sorumlu sınıfın adı da bire bir o olmalıdır.
Peki bu sınıf nerede oluşturulacaktır?
İşte bu sorunun cevabı yukarıda oluşturulan ‘Resources\Languages‘de saklıdır. Evet, bu sınıf projenin kök dizininde bulunan ‘Languages’ isimli bir klasörde tanımlanmalıdır. Ya da bu sınıfın namespace’i namespace Project.Languages
şeklinde olmalıdır. Aksi taktirde .NET Core’da ki yerel localization servisleri bu sınıfın dizinini, ilgili dil verilerinin bulunduğu depoların diziniyle eşleştiremeyecek ve böylece veri okuma kabileyeti söz konusu olmayacaktır. Burada genelleme yaparsak eğer resource dosyaları ‘a\b’ dizininde ise bu resource dosyalarındaki verileri okuyacak sınıf proje kök dizini içerisinde tanımlanmış ‘b’ dizinin de olmalıdır.
Şimdi localization açısından resource dosyalarını okumamızı sağlayacak servisleri sırasıyla incelemeye geçebiliriz.
- IStringLocalizer | IStringLocalizer<T>
Controller sınıflarında resource dosyalarından localization verilerini metinsel olarak almamızı sağlayan servistir.Kullanımı;
private readonly IStringLocalizer<Lang> _stringLocalizer;
şeklindedir. Dikkat ederseniz generic olarak resource dosyalarını okuyacak olan sınıfın(Lang) bildirimi yapılmaktadır.
Örnek olarak aşağıdaki kod bloğunu inceleyebilirsiniz.
public class HomeController : Controller { private readonly IStringLocalizer<Lang> _stringLocalizer; public HomeController(IStringLocalizer<Lang> stringLocalizer) => _stringLocalizer = stringLocalizer; public IActionResult Index() { ViewBag.PageAbout = _stringLocalizer["page.About"]; ViewBag.PageHome = _stringLocalizer["page.Home"]; return View(); } }
Görüldüğü üzere ‘IStringLocalizer’ dependency injection ile talep edilebilmekte ve belirtilen key’lere karşılık uygun localization verinin gelmesi sağlanmaktadır.
- IHtmlLocalizer | IHtmlLocalizer<T>
Resource dosyalarında kayıtlı verilerin içerisinde varsa HTML kodları, bunların tarayıcı tarafından derlenebilecek şekilde biçimlendirmesini sağlayan servistir. ‘IStringLocalizer’ verileri direkt getirirken, ‘IHtmlLocalizer’ ise HTML etiketlerini biçimlendirerek getirir.Kullanımı ‘IStringLocalizer’ ile birebir benzerdir;
private readonly IHtmlLocalizer<Lang> _htmlLocalizer;
Örnek mahiyetinde aşağıdaki kodu inceleyebilirsiniz.
public class HomeController : Controller { private readonly IHtmlLocalizer<Lang> _htmlLocalizer; public HomeController(IHtmlLocalizer<Lang> htmlLocalizer) => _htmlLocalizer = htmlLocalizer; public IActionResult Index() { ViewBag.PageAbout = _htmlLocalizer["page.About"]; ViewBag.PageHome = _htmlLocalizer["page.Home"]; return View(); } }
RequestCultureProvider İle Kullanıcı Kültürünü Yakalama
Yazımızın önceki satırlarında, ziyaretçilerden gelen istekler üzerinden kültürlerini RequestCultureProvider ismi verilen sağlayıcılar sayesinde edinebileceğimizden ve bu sağlayıcıların QueryStringRequestCultureProvider, CookieRequestCultureProvider ve AcceptLanguageHeaderRequestCultureProvider olmak üzere üç adet olduğundan bahsetmiştik. Şimdi gelin, ziyaretçilerin kültürlerini öğrenebilmemizi sağlayacak olan bu sağlayıcıları tek tek teferruatlarıyla inceleyelim.
- QueryStringRequestCultureProvider
Gelen ziyaretçi web uygulamasındaki varsayılan dilden farklı bir kültüre aitse buna müdahale edebilmek ve dili kendine göre değiştirebilmek için query string üzerinden ‘culture’ parametresini aşağıdaki gibi gönderebilir.
- AcceptLanguageHeaderRequestCultureProvider
Benzer şekilde gelen ziyaretçi yine varsayılan dilden farklı bir kültüre aitse buna müdahale edebilmek için yapacağı request’in header’da ‘Accept-Language’ key’ine karşılık dil bildiriminde bulunabilir.
- CookieRequestCultureProvider
Çoğu web uygulaması, ziyaretçi kültürünü Cookie’de tutmaktadır. Haliyle bizler de ziyaretçiden gelen dil bilgisini CookieRequestCultureProvider ile cookie’de saklayabiliriz. Bunun için aşağıdaki geliştirmenin yapılması gerekmektedir.- 1. Adım
Kullanıcıdan gelen istek neticesinde kültürüne uygun cookie değerini oluşturacak middleware’i tanımlayalım.public class RequestLocalizationCookiesMiddleware : IMiddleware { readonly CookieRequestCultureProvider _provider; public RequestLocalizationCookiesMiddleware(IOptions<RequestLocalizationOptions> requestLocalizationOptions) => _provider = requestLocalizationOptions.Value.RequestCultureProviders.Where(x => x is CookieRequestCultureProvider).Cast<CookieRequestCultureProvider>().FirstOrDefault(); public async Task InvokeAsync(HttpContext context, RequestDelegate next) { if (_provider != null) { IRequestCultureFeature feature = context.Features.Get<IRequestCultureFeature>(); if (feature != null) context.Response.Cookies.Append(_provider.CookieName, CookieRequestCultureProvider.MakeCookieValue(feature.RequestCulture)); } await next(context); } }
Burada ziyaretçinin kültürü ister query string’den(QueryStringRequestCultureProvider) isterse de header bilgisinden(AcceptLanguageHeaderRequestCultureProvider) gelsin fark etmeksizin yakalanmakta ve cookie olarak response’a eklenmektedir.
- 2. Adım
Bu middleware’i pipeline’a entegre edecek olan extension metodu tasarlayalım.public static class RequestLocalizationCookiesMiddlewareExtensions { public static IApplicationBuilder UseRequestLocalizationCookies(this IApplicationBuilder app) => app.UseMiddleware<RequestLocalizationCookiesMiddleware>(); }
- 3. Adım
Oluşturulan middleware’i dependency injection container’ına ekleyelim.builder.Services.AddScoped<RequestLocalizationCookiesMiddleware>();
Ve ardından UseRequestLocalization middleware’inden sonra oluşturduğumuz ‘UseRequestLocalizationCookies’ extension metodunu çağıralım.
app.UseRequestLocalization(); app.UseRequestLocalizationCookies();
Bu çalışmadan sonra ziyaretçi hangi dili gönderiyorsa o cookie’ye kaydedilecektir.
Dikkat ederseniz eğer cookie’ye .AspNetCore.Culture key’i karşılığında kaydedilen dil bilgisi sayesinde ziyaretçinin tekrardan dili gönderme gibi bir sorumluluğu kalmamaktadır. - 1. Adım
Web Uygulamasında Desteklenen Dillerin Bilgisini Almak
Localization operasyonlarında en önemli hususlardan biri uygulama sürecinde desteklenmekte olan dillerin toplu bilgisini alabilmektir. Bunun için türlü yöntemler uygulanabileceği gibi .NET Core localization servislerinin bizlere sağladığı aşağıdaki gibi pratik dokunuşlar da mevcuttur.
public class LangController : Controller { readonly RequestLocalizationOptions _localizationOptions; public LangController(IOptions<RequestLocalizationOptions> localizationOptions) => _localizationOptions = localizationOptions.Value; public IActionResult AllLanguages() { IRequestCultureFeature requestCulture = HttpContext.Features.Get<IRequestCultureFeature>(); var allCultures = _localizationOptions.SupportedCultures .Select(culture => new { Name = culture.Name, Text = culture.DisplayName }).ToList(); return Ok(allCultures); } }
Görüldüğü üzere IRequestCultureFeature arayüzü sayesinde uygulamanın ‘Program.cs'(Startup.cs) dosyasında konfigüre edilen RequestLocalizationOptions servisinin içerisinde tanımlanan tüm kültürler basit bir şekilde tarafımızca elde edilmiştir.
Nihai olarak;
Bu içeriğimizde, web geliştiricileri açısından korkutucu bir konu olan çoklu dil desteğinin özünde gayet basit ve sıradan bir mimariye sahip olduğunu gözlemlemiş olduk. Hele hele bu sıradanlığın Asp.NET Core mimarisiyle daha da basit bir hale getirildiğini görmüş ve hiç kompleks işleme gerek duyulmaksızın hızlıca nasıl entegre yapılabileceğini tecrübe etmiş olduk. Bu içeriğimiz boyunca, bir web uygulamasında yerelleştirme operasyonlarının nasıl yapılandırıldığına, çalışma zamanında ziyaretçilere uygun dile web uygulamasının nasıl eşlik edebileceğine ve belli başlı kültür sağlayıcılarının neler olduğuna teorik ve pratik olmak üzere temas etmeye çalıştık. Bol bol faydalamanız dileğiyle…
Sabredip okuduğunuz için teşekkür ederim.
İlgilenenlerin faydalanması dileğiyle…
Sonraki yazılarımda görüşmek üzere…
İyi çalışmalar…
Not : Örnek projeyi indirebilmek için buraya tıklayınız.
Gencay hocam oncelikle eğitim anlayışınızı ve tarzınızı beğenerek takip ediyorum. Boğmadan, karıştırmadan anlatmanız takdire şayan. Hocam sorum şu ki; verinin gittikçe büyüdüğü bir sistemde resx dosyalarındaki veriler de büyüyecektir aşikar, bunu sistemin genel veritabanından ayrı (örn. hybrid) bir veritabanında tutmak mı hız acisindan performans sağlar, yoksa resx dosyalarının şişmesi göze alınabilir bir bedel mi olur?
Merhaba,
Öncelikle iltifatınız için teşekkür ederim.
Sorunuza gelirsek eğer konuya dair bende herhangi bir incelemede bulunmadığım için yorum yapamıyorum. Ama şunu söyleyebilirim ki, yapısal olarak büyük ve opsiyonel verilerin olduğu sistemlerde buradaki ihtiyacı resx gibi statik yapılanmalardan ziyade veritabanı gibi dinamik yapılarla gidermeye çalışmak daha doğru olacaktır diye düşünüyorum. Tabi bunla ilgili çalışma yapanların yorumlarını ve var olan uygulamaları değerlendirmekte ve incelemekte fayda var diye düşünüyorum.
Sevgiler.
Merhabalar Gencay hocam,
Öncelikle sizlere özenli ve detaylı yazınız için teşekkür ediyorum.
Bu yapının View’de kullanımını yazıda göremediğim için ve aynı yapıyı MVC uygulamasında kendim de kullandığımdan sizlerle paylaşmak istedim 😊
Saygılar.
—————————
Teşekkür ederim 🙂
Merhaba hocam,
Benim blog sitem var ve yazılan yazılar farklı ülkerdeki kullanıcıların web aramalarında gözlüksün istiyorum. anladığım kadarıyla bu işlem yazıyı anlık olarak çeviriyor değil mi? ama arama motorları yazıları analiz ederek kullanıcılara listeliyor. Yazınızda bahsettiğiniz işlemleri gerçekleştirdiğimiz takdirde arama motorlarının yazımızı farklı dillerde de indexleyebilmesi sağlanacak mı? ya da sitemap.xml dosyamızda bir işlem yapmalı mıyız?
Merhaba,
Arama motorları localization’a göre uygun culture’ı yakalayabilirse farklı dillerde indexlemeyi gerçekleştirebileceği kanaatindeyim.
Bir dakika hocam daha detaylı inceleme yaptım da burda language klasörünün içindeki dosyalarda key ve valularında belirlediğimiz içeriklerin aynısı mı geliyor? Yani ben daha önceden yazılmış yazılarımı otomatik olarak çeviremeyecek miyim? Ya da sayfada yazan her şeyi (menüdekileri, blog yazımı, sayfadaki sliderları gibi şeyleri) tek tek lang.***.resx doslayarlındaki key ve value alanlarına girmem mi gerekiyor?
Bu yöntem sayfanın static noktalarını localization’a göre özelleştirmemizi sağlamaktadır. Ama dikkat! bu bir translate işlemi değildir! Eğer çeviri istiyorsanız culture’a göre farklı servisler üzerinden translate sürecini başlatabilir ve içeriklerinizi o dilde ziyaretçilerinize sunabilirsiniz.
SelamünAleyküm Hocam, bu yazıdan çok istifade ettim ve sitemdeki statik yapıların çevirisini yaptım ama burada sizinde dediğiniz gibi tarnslate işlemi değil. Bu konuda araştırma yaptım, azure portal üzrineden azure ai services| translator kullanarak bu işi yapabileceimizi gördüm ama tam olarak nasıl yapacağımı kafamda oturtamadım. genel olarak belirlediğmiz culture’larla eşleştirirsek istediğimiz şekilde translate yapabiliriz diye düşünüyorum. Bu konuda bir yazı kaleme alır mısınız.
Umarım 🙂
API projelerinde cookie kullanmadan nasıl yapıcaz kullanıcıdan gelen “tr” yada ” fr” bilgisini
Hocam Selamlar, bu yapıyı çok katmanlı bir projede nasıl kullanabiliriz. Örneğin DataAcessLayer ve BusinessLayer katmanlarını ekledik. Bu katmanlar UI katmanı tarafından referans alınıyor. Bu nedenle UI katmanından localization işlemi yapabilirken diğer 2 kayman üzerinden bu dosyalara erişim olmuyor. Bu durum için nasıl bir geliştirme yapmak gerekir.
Teşekkür ederim. Güzel bir anlatım.
Ne yaptıysam apide çalışmadı. Neden çalışmadı anlamış da değilim.
Çok güzel bir anlatım ve örnek olmuş. Çok işime yaradı. Elinize sağlık.