Globalization in ASP.NET Core MVC and Elasticsearch

In today’s technological age, companies can sell their products to people in different cities through e-commerce websites. The topic of “globalization” is important for companies who want to sell their products in a wider area and sell them in different countries.

In this arcticle, I will try to describe how we can provide globalization in ASP.NET Core MVC and Elasticsearch.

1) Globalization in ASP.NET Core MVC

Let’s take a look at how we can perform globalization in ASP.NET Core MVC after the small intro about the importance of globalization. As for globalization topic, ASP.NET Core provides us with the middleware and services we need, to be able to apply localization in our applications for different “languages” and “cultures” info.

There are three main steps to follow when applying localization:

  1. Make application content “localizable”.
  2. We need to have localized “resource” files.
  3. We need to configure and define an “implementation strategy”.

First, we will take a look how we can make the application content localizable.

NOTE: I will use “ASP.NET Core Web App (Model-View-Controller)” template as a sample. “dotnet new mvc

1) Make application content “localizable”

Actually, localizer is similar to the “.resx” resource files in ASP.NET. If we remember, we were adding a “.resx” resource file for each culture supported in the application. Then we were getting localized content by accessing “Resources.Name“.

In ASP.NET Core, there are two abstractions to get localized content instead of using the “Resources.Name” approach. “IStringLocalizer” and “IStringLocalizer<T>

For example:

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);
    }
}

Can you see the use of Localizer? We set the content in the localizer instead of a key. The most important thing is if localized content cannot be found, then the localizer will use the content which we was set as a resource. Thus we don’t need to create a resource for the main culture. Actually, I can’t say I like it so much, because I’m obsessive about the “magic strings” when I do code reviews. 🙂

NOTE: For HTML content, there are also “IHtmlLocalizer” and “IHtmlLocalizer<T>” types.

We can also use “IStringLocalizerFactory” to create a different type of localizer.

For example, we can use access to a “shared resource”, for example:

public class TodoController : Controller
{
    private readonly IStringLocalizer _localizerForShared;

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

    }
}

We can create a localizer for the requested resource with the “Create” method in the factory.

To localization of Views, we have an “IViewLocalizer” as a service. “ViewLocalizer” implements the “IHtmlLocalizer“. At this point, Razor does not HTML-encode the localized string except for parameters.

Using of the “IViewLocalizer“:

@using Microsoft.AspNetCore.Mvc.Localization

@inject IViewLocalizer Localizer

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

and the result:

We see that Razor did not HTML-encode the localized string except for the parameters.

We have seen how we can localize the views in the simplest way. Now let’s look at the localization of data annotations. At this point it is very simple. Data annotations in ASP.NET Core are already localized with “IStringLocalizer<T>“.

E.g:

public class TodoModel
{
    [Required(ErrorMessage = "This field is required.")]
    [Display(Name = "Name")]
    public string Name { get; set; }
}
2) We need to have localized “resource” files

In fact, as I mentioned at the beginning of this article, localized resource files are the same as a “.resx” resource files in ASP.NET. We can add a resource file in Visual Studio as shown below:

NOTE: Currently we can not add a resource files in Visual Studio Code and Visual Studio for MAC. 🙁

At this point, one of the important topics is the naming of resource files.

The localizers that we use in both classes and views can access to localized resource in two different ways.

  1. Fully-qualified class names. E.g: “Controllers.TodoController.en-US.resx” – “Views.Todo.Index-en-US.resx
  2. Folder structures. E.g: “Resources/Controllers/TodoController.en-US.resx” – “Resources/Views/Todo/Index.en-US.resx”

We can choose either of these methods.

3) Configuration and Implementation Strategy

So far, we have looked at how we can localize content in an application. Now, we will look at how we can configure and add localization services in an application.

First, we can specify the path of the resource files, in the Startup class as shown below.

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

NOTE: If we can not specify a path, localization service will look at the root folder of the application for resource files.

We need to add view localization service to the service collection to enable localization of views.

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

If we want to create views based on cultures separately instead of creating resource files, it is possible with the “LanguageViewLocationExpanderFormat” parameter.

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

LanguageViewLocationExpanderFormat” enum has the “SubFolder” and “Suffix” values.

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

For data annotations, we can provide localization using the “AddDataAnnotationsLocalization” method.

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

Also if we want to use a shared resource for data annotations, we need to configure the data annotation localizer.

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

public class SharedResource
{
}

Another important piece of middleware that we need to add for the “Configure” method is Request Localization middleware.

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?}");
    });
}

Here we can specify supported languages and default culture info in the application.

Now let’s look at the last part of the localization. Request localization middleware has 3 default options to select current culture.

  1. QueryStringRequestCultureProvider
    QueryStringRequestCultureProvider” is the first provider in “RequestCultureProvider“. It provides localization with query string. E.g: “http://localhost:5000/?culture=en-US
  2. CookieRequestCultureProvider
    CookieRequestCultureProvider” is a good option for tracking the user’s selected culture.
  3. AcceptLanguageHeaderRequestCultureProvider
    AcceptLanguageHeaderRequestCultureProvider” determines the culture information of a request via the value of the Accept-Language header.

There is an optional provider called “RouteDataRequestCultureProvider” that we can use. However, localization is possible via routing. E.g: “http://localhost:5000/en-US/home

To achieve this:

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?}");
            });
        });
    });
}

We grabbed the culture information before the localization middleware works by using the “MapMiddlewareRoute” method. The reason is the localization middleware works before the MVC router. So we inserted the “RouteDataRequestCultureProvider” provider be the first request culture provider. Then we added the culture info in the route template. With this configuration, the localization will be applied with the culture info from the route.

NOTE: I will share a sample project that contains localization implementations.

2) Globalization in Elasticsearch

We will provide multi-language support with creating indices for each culture separately. At this point, there are a few different methods. For example, it is possible to provide multi-language support with multi-fields on a single index. Providing multi-language support by creating different indices will make it easier to add different culture info in the future easily. In addition, it will also easy to configure the analyzers for each language. (I used it like this before)

Anyway, let’s implement NEST library in our ASP.NET Core MVC sample. First, create an interface called “IProductService“.

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);
    }
}

We will create an index using the “CreateIndexAsync” method, and feed products to an index which we will create with “IndexAsync” method. Then based on the culture info requested by the user, we will perform the search operation with the “SearchAsync” method.

Now let’s implement “IProductService” interface as shown below.

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;
        }
    }
}

Using the “IndexAsync” method, we have created an index name called “products_ {langCode}“, and we will feed products to this index. With the “SearchAsync” method, we will perform a full-text search in the fields “Name” and “Description” with the user’s requested culture info.

Let’s create a controller as an example:

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);
        }
    }
}

We will create an index for the Turkish and English cultures in the “Index” method of the controller, and we will index the products. In the “Products” action, we will provide product search operation with the user’s requested culture.

Create a view for the “Products” action as shown below.

@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>

For testing, let’s run an Elasticsearch as a single-node on the Docker.

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

Finally, in the “Startup” class, perform the injection operation to the service collection of the “ProductService” and “ConnectionSettings” classes as shown below.

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

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

That’s all.

Now let’s run the project with the command “dotnet run“. Then open the URL “http://localhost:5000/tr-TR/home“. Thus, product index will be created, and the products will be indexed.

Now let’s test it with the URL “http://localhost:5000/tr-TR/home/products?keyword=Cep Telefonu” and see the result.

If we look at the response, we can see that the resources were localized based on the culture and the product results were searched in the “products_tr” index.

Also, change the culture to “en-US” and the keyword to “mobile” and let’s see the response again. So the URL: “http://localhost:5000/en-US/home/products?keyword=Cell Phone

Now, the resources were localized with the “en-US” culture, and the product results were searched in the “products_en” index.

Here is a sample project: 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

Securing the Supply Chain of Containerized Applications to Reduce Security Risks (Policy Enforcement-Automated Governance with OPA Gatekeeper and Ratify) – Part 2

{:tr} Makalenin ilk bölümünde, Software Supply Chain güvenliğinin öneminden ve containerized uygulamaların güvenlik risklerini azaltabilmek…

6 months ago

Securing the Supply Chain of Containerized Applications to Reduce Security Risks (Security Scanning, SBOMs, Signing&Verifying Artifacts) – Part 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 months ago

Delegating Identity & Access Management to Azure AD B2C and Integrating with .NET

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

1 year ago

How to Order Events in Microservices by Using Azure Service Bus (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 years ago

Providing Atomicity for Eventual Consistency with Outbox Pattern in .NET Microservices

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

2 years ago

Building Microservices by Using Dapr and .NET with Minimum Effort – 02 (Azure Container Apps)

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

2 years ago