ASP.NET Core MVC ve Elasticsearch’de Globalization

Merhaba arkadaşlar.

Biliyoruz ki günümüz teknoloji çağında firmalar, e-ticaret siteleri üzerinden hiç tanımadığı ve farklı şehirdeki insanlara ürünlerini satabilmektedirler. Bu satışlarını daha geniş bir alanda yapabilmek ve farklı ülkelere de satabilmek için ise, globalization konusu büyük bir önem taşımaktadır.

Bu makale içerisinde ise ASP.NET Core MVC ve Elasticsearch içerisinde, nasıl “globalization” desteğini sağlayabiliriz konusuna değinmeye çalışacağım.

1) ASP.NET Core MVC’de Globalization

Globalization konusunun önemiyle alakalı küçük bir giriş yapmanın ardından, ASP.NET Core MVC içerisinde globalization işlemlerini nasıl gerçekleştirebiliriz konusuna öncelikle bir bakalım. Globalization konusunda ASP.NET Core, bize farklı “dil” ve “culture” bilgileri için localization işlemlerini gerçekleştirebilmemiz adına middleware ve servisler sunmaktadır.

Localization işlemini gerçekleştirebilmek için uygulayacağımız üç temel nokta bulunmaktadır:

  1. Uygulamanın content’ini “localizable” bir hale getirmek
  2. İstenilen dil bilgileri için localize edilmiş “resource” dosyalarına sahip olmak
  3. Son olarak da configure edip bir “implementasyon stratejisi” belirlememiz gerekmektedir

Bu noktalardan yola çıkarak, uygulamanın content’ini nasıl localizable bir hale getirebiliriz konusuna bir bakalım.

NOT: Örnekler için “ASP.NET Core Web App (Model-View-Controller)” template’ini kullanacağım. “dotnet new mvc

1) Uygulamanın Content’ini Localizable Bir Hale Getirmek

Aslında, ASP.NET deki “.resx” resource dosyaları ile benzerlik göstermektedir. Hatırlarsak uygulama içerisinde desteklenen her bir culture bilgisi için bir “.resx” resource dosyası ekliyorduk. Ardından ise “Resources.Name” şeklinde erişim sağlayarak, culture bilgisine uygun olan localize edilmiş content’i kullanıyorduk.

ASP.NET Core içerisinde ise localize edilmiş content’i alabilmek için “Resources.Name” yaklaşımı yerine, “IStringLocalizer” ve “IStringLocalizer” tipinde iki adet abstraction bulunmaktadır.

Örneğin:

public class TodoController : Controller
{
    private readonly IStringLocalizer<TodoController> _localizer;

    public TodoController(IStringLocalizer<TodoController> localizer)
    {
        _localizer = localizer;
    }

    public IActionResult Index()
    {
        string hello = _localizer["Hello!"];

        return View(hello);
    }
}

Localizer’ın kullanımına dikkat ettiniz mi? Localizer’ın içerisine bir key set etmek yerine, bir content set ettik. ASP.NET Core içerisindeki en büyük fark ise, istenilen culture bilgisi için localize bir content bulunamaz ise, içerisine set edilen content’i resource olarak kullanmaktadır. Bu sayede main culture bilgisi için bir resource dosyası oluşturmaya gerek kalmamaktadır. Tabi code review’lar sırasında magic string’lere takıntılı biri olarak, bu durumdan pek hoşlandığımı söyleyemem. 🙂

NOT: HTML içeren content’ler için ise, “IHtmlLocalizer” ve “IHtmlLocalizer” tipleri de bulunmaktadır.

Farklı tiplerdeki localizer’lara erişebilmek için ise birde “IStringLocalizerFactory” bulunmaktadır.

Örneğin shared resource’lara erişebilmek için, aşağıdaki gibi kullanabilmekteyiz.

public class TodoController : Controller
{
    private readonly IStringLocalizer _localizerForShared;

    public CulturesController(IStringLocalizerFactory localizerFactory)
    {
        _localizerForShared = localizerFactory.Create(typeof(SharedResource));

    }
}

Factory üzerinde bulunan “Create” method’u ile, istenilen resource için bir localizer oluşturabiliriz.

View localization kısmına baktığımızda ise, servis olarak kullanabileceğimiz “IViewLocalizer” bulunmaktadır. “ViewLocalizer” ise “IHtmlLocalizer” ı implemente etmektedir. Bu noktada Razor, parametreler hariç localize edilmiş string’i HTML encode yapmamaktadır.

Örnek bir view’a bakalım:

@using Microsoft.AspNetCore.Mvc.Localization

@inject IViewLocalizer Localizer

@Localizer["<h1>Selam {0}</h1>", "<b>Gökhan</b>"]

Yukarıdaki localize edilmiş string’in çıktısı, aşağıdaki gibi olacaktır.

Çıktıya baktığımızda ise Razor‘ın, parametre değerleri hariç localize edilmiş string’i HTML encode yapmadığını görmekteyiz.

View’ları en basit haliyle nasıl localize edebileceğimizi gördük. Şimdi birde Data Annotations‘ların localization konusuna bir bakalım. Bu noktada ise yine işimiz gayet basittir. ASP.NET Core içerisinde data annotations’lar hali hazırda “IStringLocalizer<T><” ile localize edilmiş durumdadır.

Örneğin:

public class TodoModel
{
    [Required(ErrorMessage = "This field is required.")]
    [Display(Name = "Name")]
    public string Name { get; set; }
}

Yukarıdaki gibi bir kullanımda “This field is required.” ve “Name” string’leri, istenilen culture’da bir resource dosyası varsa bu değerler localize edilecektir.

2) Localize Edilmiş Resource Dosyası Oluşturmak

Aslında makalenin girişinde de bahsettiğim gibi localize edilmiş resource dosyaları, ASP.NET‘deki “.resx” resource dosyaları ile aynıdır. Visual Studio üzerinden, aşağıdaki gibi ekleyebilmekteyiz.

NOT: Henüz Visual Studio Code ve Visual Studio for MAC üzerinden eklenememektedir. 🙁

Bu noktada dikkat etmemiz gereken önemli unsurlardan birisi, resource dosyalarının isimlendirilmesidir.

Hem class’ların hem de view’ların içerisinde kullanmış olduğumuz localizer’lar, iki farklı yöntemle localize edilmiş resource’lara erişim sağlamaktadır.

  1. Fully-qualified class name’inden erişebilmektedir. Örneğin: “Controllers.TodoController.en-US.resx” – “Views.Todo.Index-en-US.resx
  2. Folder structure’ı üzerinden de erişebilmektedir. Örneğin: “Resources/Controllers/TodoController.en-US.resx” – “Resources/Views/Todo/Index.en-US.resx”

Buradaki kullanım tercihi tamamen bize kalmış durumdadır.

3) Configurasyon ve Implementasyon Stratejisi

Bu noktaya kadar content’leri nasıl localize bir hale getirebiliriz konularına baktık. Şimdi ise localization servislerini, uygulama içerisine nasıl ekleyebiliriz ve configure edebiliriz kısımlarına değineceğiz.

İlk olarak resource dosyaları için olan path’i, Startup class’ı içerisinde aşağıdaki gibi belirleyebilmek mümkündür.

public void ConfigureServices(IServiceCollection services)
{
    services.AddLocalization(options => {
        options.ResourcesPath = "Resources";
    });
}

NOT: Eğer herhangi bir path set etmez isek, localization servisi culture resource’larını uygulamanın root folder’ı altında arayacaktır.

View’lar için localization’ı etkinleştirebilmek ise localization servisini aşağıdaki gibi service collection’ına ekleyebiliriz.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc()
    .AddViewLocalization()
}

Eğer resource dosyaları oluşturmak yerine culture’lara göre farklı view’lar oluşturmak istersek, “LanguageViewLocationExpanderFormat” parametresi ile mümkündür.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc()
    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix)
}

LanguageViewLocationExpanderFormat” enum’ı, “SubFolder” ve “Suffix” değerlerine sahiptir.

  • Suffix: “Todo.en-US.cshtml
  • SubFolder: “en-US/Todo.cshtml

Data annotation’lar için ise localization’ı, “AddDataAnnotationsLocalization” method’u sağlayabiliriz:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc()
    .AddDataAnnotationsLocalization();
}

Data annotation’lar için shared bir resource kullanmak istiyorsak eğer, aşağıdaki gibi configure etmemiz yeterli olacaktır.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc()
    .AddDataAnnotationsLocalization(options =>
    {
        options.DataAnnotationLocalizerProvider = (type, factory) => {
            return factory.Create(typeof(SharedResource));
        }
    });
}

public class SharedResource
{
}

Configure” method’u içerisinde eklememiz gereken bir diğer önemli middleware ise, Request Localization middleware’idir.

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    var supportedCultures = new List
    {
        new CultureInfo("tr-TR"),
        new CultureInfo("en-US"),
    };

    app.UseRequestLocalization(new RequestLocalizationOptions
    {
        DefaultRequestCulture = new RequestCulture("tr-TR"),
        SupportedCultures = supportedCultures,
        SupportedUICultures = supportedCultures
    });

    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
    });
}

Burada uygulamanın destekleyeceği culture bilgilerini ve default culture bilgisini belirleyebiliriz.

Localization’ın son kısmı olarak implementasyon stratejilerine bakacağız. Request localization middleware’i, current culture’ı seçebilmek için default olarak üç farklı opsiyona sahiptir.

  1. QueryStringRequestCultureProvider
    QueryStringRequestCultureProvider”, “RequestCultureProvider” içerisinde ilk provider olarak kayıtlıdır. Query string üzerinden localization işlemini gerçekleştirmektedir. Örneğin: “http://localhost:5000/?culture=en-US
  2. CookieRequestCultureProvider
    “CookieRequestCultureProvider” ise kullanıcının seçmiş olduğu culture’ı, cookie bilgisini kullanarak track edebilmeyi sağlamaktadır.
  3. AcceptLanguageHeaderRequestCultureProvider
    AcceptLanguageHeaderRequestCultureProvider” ise kullanıcının browser’ında ön tanımlı olarak gelen culture bilgisine göre, uygulamanın localize edilmesini sağlamaktadır.

Opsiyonel olarak kullanabileceğimiz birde, “RouteDataRequestCultureProvider” vardır. Bununla birlikte route üzerinden localize edebilmek mümkündür. Örneğin: “http://localhost:5000/en-US/home

Bunun için:

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseRouter(routes =>
    {
        routes.MapMiddlewareRoute("{culture=tr-TR}/{*mvcRoute}", _app =>
        {
            var supportedCultures = new List<CultureInfo>
            {
                new CultureInfo("tr-TR"),
                new CultureInfo("en-US")                                   
            };

            var requestLocalizationOptions = new RequestLocalizationOptions
            {
                DefaultRequestCulture = new RequestCulture("tr-TR"),
                SupportedCultures = supportedCultures,
                SupportedUICultures = supportedCultures
            };

            requestLocalizationOptions.RequestCultureProviders.Insert(0, new RouteDataRequestCultureProvider());
            _app.UseRequestLocalization(requestLocalizationOptions);

            _app.UseMvc(mvcRoutes =>
            {
                mvcRoutes.MapRoute(
                    name: "default",
                    template: "{culture=tr-TR}/{controller=Home}/{action=Index}/{id?}");
            });
        });
    });
}

Yukarıdaki kod bloğuna baktığımızda “MapMiddlewareRoute” method’u ile culture bilgisini, localization middleware’i çalışmadan önce yakalıyoruz. Bunu yapmamızın sebebi ise, localization middleware’inin MVC router’ından önce çalışıyor olmasıdır. Ardından “RouteDataRequestCultureProvider” ı ilk request culture provider’ı olarak insert ederek, request localization middleware’ini configure ettik. Son olarak route template’i içerisine ise birde culture bilgisini ekledik. Bu configuration ile, localization işlemi route üzerinden gelen culture bilgisi ile gerçekleştirilecektir.

NOT: Bu implementasyonları içeren bir örneği, makalenin sonunda paylaşıyor olacağım.

2) Elasticsearch İçerisinde Globalization

Elasticsearch kısmında ise her bir culture bilgisi için, farklı index’ler oluşturarak multi-language desteğini sağlayacağız. Burada tercih edebileceğiniz farklı yöntemlerde mevcut. Örneğin tek bir index üzerinde multi-field’lar ile multi-language desteğini sağlayabilmek gibi. Farklı index’ler oluşturarak multi-language desteği sağlamak, ileride farklı culture bilgilerinin de kolaylıkla entegre olabilmesi açısından kolaylık sağlayacaktır. Buna ek olarak her bir dil bilgisi için analyzer’lar ayarlayabilmek de kolay olacaktır. (Ben daha önce bu şekilde kullandım)

Örnek içerisinde ASP.NET Core MVC içerisinde NEST kütüphanesinin implementasyonunu gerçekleştireceğiz. Öncelikle “IProductService” isminde bir interface tanımlayalım.

namespace ASPNETCoreAndESearch.Business
{
    public interface IProductService
    {
        Task CreateIndexAsync(string indexName);
        Task<bool> IndexAsync(List<Product> products, string langCode);
        Task<ProductSearchResponse> SearchAsync(string keyword, string langCode);
    }
}

CreateIndexAsync” method’u ile bir index oluşturup, “IndexAsync” method’u ile de product’ları indexleyeceğiz. Ardından kullanıcının istediği culture bilgisine göre ise search işlemini “SearchAsync” method’u ile gerçekleştireceğiz.

Şimdi “ProductService” isminde bir class oluşturalım ve aşağıdaki gibi implemente edelim.

namespace ASPNETCoreAndESearch.Business
{
    public class ProductService : IProductService
    {
        private readonly ElasticClient _elasticClient;

        public ProductService(ConnectionSettings connectionSettings)
        {
            _elasticClient = new ElasticClient(connectionSettings);
        }

        public async Task CreateIndexAsync(string indexName)
        {
            var createIndexDescriptor = new CreateIndexDescriptor(indexName.ToLowerInvariant())
                                         .Mappings(m => m.Map<Product>(p => p.AutoMap()));

            await _elasticClient.CreateIndexAsync(createIndexDescriptor);
        }

        public async Task<bool> IndexAsync(List<Product> products, string langCode)
        {
            string indexName = $"products_{langCode}";

            IBulkResponse response = await _elasticClient.IndexManyAsync(products, indexName);

            return response.IsValid;
        }

        public async Task<ProductSearchResponse> SearchAsync(string keyword, string langCode)
        {
            ProductSearchResponse productSearchResponse = new ProductSearchResponse();
            string indexName = $"products_{langCode}";

            ISearchResponse<Product> searchResponse = await _elasticClient.SearchAsync<Product>(x => x
                .Index(indexName)
                .Query(q =>
                            q.MultiMatch(mp => mp
                                        .Query(keyword)
                                        .Fields(f => f.Fields(f1 => f1.Name, f2 => f2.Description)))
                ));

            if (searchResponse.IsValid && searchResponse.Documents != null)
            {
                productSearchResponse.Total = (int)searchResponse.Total;
                productSearchResponse.Products = searchResponse.Documents;
            }

            return productSearchResponse;
        }
    }
}

IndexAsync” method’u ile “products_{langCode}” şeklinde bir index name oluşturduk ve product’ları bu index name ile indexleyeceğiz. “SearchAsync” method’u ile ise, istenilen dil bilgisine göre “Name” ve “Description” field’ları üzerinde full text search işlemini gerçekleştireceğiz.

Örnek olarak aşağıdaki gibi bir controller oluşturalım.

namespace ASPNETCoreAndESearch.Controllers
{
    public class HomeController : Controller
    {
        private readonly IProductService _productService;

        public HomeController(IProductService productService)
        {
            _productService = productService;
        }
        
        public async Task<IActionResult> Index()
        {   
            string indexName = $"products";
            await FeedProductsInTurkish(indexName, "tr");
            await FeedProductsInEnglish(indexName, "en");

            return View();
        }

        public async Task<IActionResult> Products(string keyword)
        {
            var requestCultureFeature = Request.HttpContext.Features.Get<IRequestCultureFeature>();
            CultureInfo culture = requestCultureFeature.RequestCulture.Culture;
    
            ProductSearchResponse productSearchResponse = await _productService.SearchAsync(keyword, culture.TwoLetterISOLanguageName);

            return View(productSearchResponse);
        }

        private async Task FeedProductsInTurkish(string indexName, string lang)
        {
            List<Product> products = new List<Product>
            {
                new Product
                {
                    ProductId = 1,
                    Name = "Iphone X Cep Telefonu",
                    Description = "Gümüş renk, 128 GB",
                    Price = 6000
                },
                new Product
                {
                    ProductId = 2,
                    Name = "Iphone X Cep Telefonu",
                    Description = "Uzay grisi rengi, 128 GB",
                    Price = 6000
                },
                new Product
                {
                    ProductId = 3,
                    Name = "Rayban Erkek Gözlük",
                    Description = "Yeşil renk"
                },
                new Product
                {
                    ProductId = 4,
                    Name = "Rayban Kadın Gözlük",
                    Description = "Gri renk"
                }
            };

            await _productService.CreateIndexAsync($"{indexName}_{lang}");
            await _productService.IndexAsync(products, lang);
        }

        private async Task FeedProductsInEnglish(string indexName, string lang)
        {
            List<Product> products = new List<Product>
            {
                new Product
                {
                    ProductId = 1,
                    Name = "Iphone X Mobile Phone",
                    Description = "Silver color, 128 GB",
                    Price = 6000
                },
                new Product
                {
                    ProductId = 2,
                    Name = "Iphone X Mobile Phone",
                    Description = "Space gray color, 128 GB",
                    Price = 6000
                },
                new Product
                {
                    ProductId = 3,
                    Name = "Rayban Men's Glasses",
                    Description = "Green color"
                },
                new Product
                {
                    ProductId = 4,
                    Name = "Rayban Women's Glasses",
                    Description = "Gray color"
                }
            };

            await _productService.CreateIndexAsync($"{indexName}_{lang}");
            await _productService.IndexAsync(products, lang);
        }
    }
}

Controller’ın “Index” method’u içerisinde Türkçe ve İngilizce culture’ları için bir index oluşturacağız ve product’ları oluşturulan bu index’e feed edeceğiz. “Products” action içerisinde ise query string üzerinden alacak olduğumuz “keyword” ile, culture’a uygun product index’i üzerinden search işlemini gerçekleştireceğiz.

Products” action için ise aşağıdaki gibi bir view oluşturalım.

@model ProductSearchResponse
@using Microsoft.AspNetCore.Mvc.Localization
@inject IViewLocalizer Localizer

<div class="container">
  <h3>@Localizer["Toplam {0} ürün bulundu.", Model.Total]</h3>
  <table class="table">
    <thead>
      <tr>
        <th>Id</th>
        <th>@Localizer["İsim"]</th>
        <th>@Localizer["Açıklama"]</th>
        <th>@Localizer["Fiyat"]</th>
        <th></th>
      </tr>
    </thead>
    <tbody>
        @foreach(var product in Model.Products)
        {
          <tr>
            <td>@product.ProductId</td>
            <td>@product.Name</td>
            <td>@product.Description</td>
            <td>@product.Price</td>
          </tr>
        }
    </tbody>
  </table>
</div>

Docker üzerinde test işlemleri için single-node olarak bir Elasticsearch çalıştıralım.

docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.0.1

Son olarak “Startup” class’ı içerisinde ise, “ProductService” ve “ConnectionSettings” class’larının service collection’ına injection işlemini aşağıdaki gibi gerçekleştirelim.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();

    services.AddSingleton(x => new ConnectionSettings(new Uri("http://localhost:9200")));
    services.AddTransient<IProductService, ProductService>();
}

İşte bu kadar.

Şimdi projeyi “dotnet run” komut satırı ile çalıştıralım. Ardından “http://localhost:5000/tr-TR/home” URL’ine girelim. Böylece product index’i oluşturulacak ve product dokümanları indexlenecektir.

Şimdi search işlemi için “http://localhost:5000/tr-TR/home/products?keyword=Cep Telefonu” URL’ine browser üzerinden girelim ve sonucuna bakalım.

Eğer response’a bakarsak, resource’ların culture’a göre localize edildiğini, ayrıca product result’larının da “products_tr” index’i içerisinde arandığını görebiliriz.

Culture bilgisini birde “en-US“, keyword’ü ise “Cell Phone” olarak değiştirelim ve response’a tekrar bir bakalım. URL ise: “http://localhost:5000/en-US/home/products?keyword=Cell Phone“.

Şimdi ise resource’lar “en-US” culture’ı için localize olmuş durumda ve product result’ları ise “products_en” index’i içerisinde aranmıştır.

Örnek projeye ise buradan ulaşabilirsiniz: https://github.com/GokGokalp/Globalization-In-ASP.NET-Core-MVC-And-Elasticsearch

Referanslar

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/localization

https://joonasw.net/view/aspnet-core-localization-deep-dive

Gökhan Gökalp

View Comments

Recent Posts

Containerized Uygulamaların Supply Chain’ini Güvence Altına Alarak Güvenlik Risklerini Azaltma (Güvenlik Taraması, SBOM’lar, Artifact’lerin İmzalanması ve Doğrulanması) – Bölüm 1

{:tr}Bildiğimiz gibi modern yazılım geliştirme ortamında containerization'ın benimsenmesi, uygulamaların oluşturulma ve dağıtılma şekillerini oldukça değiştirdi.…

8 ay ago

Identity & Access Management İşlemlerini Azure AD B2C ile .NET Ortamında Gerçekleştirmek

{:tr}Bildiğimiz gibi bir ürün geliştirirken olabildiğince farklı cloud çözümlerinden faydalanmak, harcanacak zaman ve karmaşıklığın yanı…

1 yıl ago

Azure Service Bus Kullanarak Microservice’lerde Event’ler Nasıl Sıralanır (FIFO Consumers)

{:tr}Bazen bazı senaryolar vardır karmaşıklığını veya eksi yanlarını bildiğimiz halde implemente etmekten kaçamadığımız veya implemente…

2 yıl ago

.NET Microservice’lerinde Outbox Pattern’ı ile Eventual Consistency için Atomicity Sağlama

{:tr}Bildiğimiz gibi microservice architecture'ına adapte olmanın bir çok artı noktası olduğu gibi, maalesef getirdiği bazı…

2 yıl ago

Dapr ve .NET Kullanarak Minimum Efor ile Microservice’ler Geliştirmek – 02 (Azure Container Apps)

{:tr}Bir önceki makale serisinde Dapr projesinden ve faydalarından bahsedip, local ortamda self-hosted mode olarak .NET…

2 yıl ago