Click here to Skip to main content
15,887,214 members
Articles / Web Development / ASP.NET

ASP.NET 8 – Multilingual Application with Single Resx File – Part 2 – Alternative Approach

Rate me:
Please Sign up or sign in to vote.
5.00/5 (2 votes)
13 Mar 2024CPOL5 min read 1.8K   21   3  
How to build a multi-language ASP.NET 8 MVC application
This is a practical guide to building a multi-language ASP.NET 8 MVC application where all language resource strings are kept in a single shared file, as opposed to having separate resource files for each controller/view. Here, we show a variant of a solution different from the previous article in the series.

Table of Contents

  1. Variant of the Previous Article's Solution
  2. Articles in this Series
  3. Shared Resources Approach
  4. Steps to Multilingual Application
    1. Create Marker Class SharedResources.cs
    2. Create Wrapper Helper Classes
    3. Create Language Resources Files
    4. Configuring Localization Services and Middleware
    5. Selecting Language/Culture
    6. Using Localization Services in the Controller
    7. Using Localization Services in the View
    8. Execution Result
  5. Full Code
  6. References
  7. History

1. Variant of the Previous Article's Solution

In this article, we show a variant of a solution in a previous article on how to solve the issue of having only one Resx file of language strings. We are showing this variant because it has been a popular approach on the internet (see [7], [8], [9]) although basic work principles are the same as in the previous article. This approach is a kind of usage of a helper/wrapper object to achieve the same result.

I personally prefer the direct approach from the previous article, but this approach is quite popular on the internet, so it is up to the developer to choose according to his/her preferences.

2. Articles in this Series

Articles in this series are:

3. Shared Resources Approach

By default, ASP.NET Core 8 MVC technology envisions separate resource file .resx for each controller and the view. But most people do not like it, since most multilanguage strings are the same in different places in the application, we would like it to be all in the same place. Literature [1] calls that approach the “Shared Resources” approach. In order to implement it, we will create a marker class SharedResources.cs to group all the resources.

Then in our app, we use a factory function to create a StringLocalizer service focused on that class/type and wrap it into the helper object called SharedStringLocalizer.

Then in our application, we will use Dependency Injection (DI) to inject that wrapper object/service into methods where we need Localization Services.

The main difference from the solution from the previous article in this series is that instead of using DI to inject directly IStringLocalizer<SharedResource>, we wrap it into the helper object, SharedStringLocalizer, and then inject that helper object instead. The underlying principles of how it works are the same.

4. Steps to Multilingual Application

4.1 Create Marker Class SharedResources.cs

This is just a dummy marker class to group shared resources. We need it for its name and type.

It seems the namespace needs to be the same as the app root namespace, which needs to be the same as the assembly name. I had some problems when changing the namespace, it would not work.

There is no magic in the name SharedResource, you can name it MyResources and change all references in the code to MyResources and all will still work.

The location seems can be any folder, although some articles ([6]) claim it needs to be the root project folder. I do not see such problems in this example. To me, looks like it can be any folder, just keep your namespace tidy.

C#
//SharedResource.cs===================================================
namespace SharedResources02
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one: SharedResources02.SharedResource
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder.
    * I do not see such problems in this example. 
    * To me, looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

4.2 Create Wrapper Helper Classes

We will create wrapper helper classes/services that we will inject using DI into our code.

C#
//ISharedStringLocalizer.cs=================================
namespace SharedResources02
{
    //we create this interface to use it for DI dependency setting
    public interface ISharedStringLocalizer
    {
        public LocalizedString this[string key]
        {
            get;
        }

        LocalizedString GetLocalizedString(string key);
    }
}

//SharedStringLocalizer.cs==================================================
namespace SharedResources02
{
    //we create this helper/wrapper class/service
    //that we are going to pass around in DI
    public class SharedStringLocalizer : ISharedStringLocalizer
    {
        //here is object that is doing the real work
        //it is almost the same as IStringLocalizer<SharedResource>
        private readonly IStringLocalizer localizer;

        public SharedStringLocalizer(IStringLocalizerFactory factory)
        {
            var type = typeof(SharedResource);
            var assemblyName = new AssemblyName(
                type.GetTypeInfo().Assembly.FullName ?? String.Empty);
            this.localizer = factory.Create("SharedResource", 
                assemblyName?.Name ?? String.Empty);
        }

        //in our methods, we just pass work to internal object
        public LocalizedString this[string key] => this.localizer[key];

        public LocalizedString GetLocalizedString(string key)
        {
            return this.GetLocalizedString(key);
        }
    }
}

//ISharedHtmlLocalizer.cs===============================================
namespace SharedResources02
{
    //we create this interface to use it for DI dependency setting
    public interface ISharedHtmlLocalizer
    {
        public LocalizedHtmlString this[string key]
        {
            get;
        }

        LocalizedHtmlString GetLocalizedString(string key);
    }
}

//SharedHtmlLocalizer.cs==================================================
namespace SharedResources02
{
    //we create this helper/wrapper class/service
    //that we are going to pass around in DI
    public class SharedHtmlLocalizer: ISharedHtmlLocalizer
    {
        //here is object that is doing the real work
        //it is almost the same as IHtmlLocalizer<SharedResource>
        private readonly IHtmlLocalizer localizer;

        public SharedHtmlLocalizer(IHtmlLocalizerFactory factory)
        {
            var type = typeof(SharedResource);
            var assemblyName = new AssemblyName(
                type.GetTypeInfo().Assembly.FullName ?? String.Empty);
            this.localizer = factory.Create("SharedResource", 
                assemblyName?.Name ?? String.Empty);
        }

        //in our methods, we just pass work to internal object
        public LocalizedHtmlString this[string key] => this.localizer[key];

        public LocalizedHtmlString GetLocalizedString(string key)
        {
            return this.GetLocalizedString(key);
        }
    }
}

4.3 Create Language Resources Files

In the Resources folder, create your language resources files, and make sure you name them SharedResources.xx.resx.

Image 1

Image 2

4.4 Configuring Localization Services and Middleware

Localization services are configured in Program.cs:

C#
private static void AddingMultiLanguageSupportServices(WebApplicationBuilder? builder)
{
    if (builder == null) { throw new Exception("builder==null"); };

    builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
    builder.Services.AddMvc()
            .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
    builder.Services.Configure<RequestLocalizationOptions>(options =>
    {
        var supportedCultures = new[] { "en", "fr", "de", "it" };
        options.SetDefaultCulture(supportedCultures[0])
            .AddSupportedCultures(supportedCultures)
            .AddSupportedUICultures(supportedCultures);
    });
    builder.Services.AddSingleton<ISharedStringLocalizer, SharedStringLocalizer>();
    builder.Services.AddSingleton<ISharedHtmlLocalizer, SharedHtmlLocalizer>();
}

private static void AddingMultiLanguageSupport(WebApplication? app)
{
    app?.UseRequestLocalization();
}

4.5 Selecting Language/Culture

Based on [5], the Localization service has three default providers:

  1. QueryStringRequestCultureProvider
  2. CookieRequestCultureProvider
  3. AcceptLanguageHeaderRequestCultureProvider

Since most apps will often provide a mechanism to set the culture with the ASP.NET Core culture cookie, we will focus only on that approach in our example.

This is the code to set .AspNetCore.Culture cookie:

C#
private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
{
    if(culture == null) { throw new Exception("culture == null"); };

    //this code sets .AspNetCore.Culture cookie
    myContext.Response.Cookies.Append(
        CookieRequestCultureProvider.DefaultCookieName,
        CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
        new CookieOptions { Expires = DateTimeOffset.UtcNow.AddMonths(1) }
    );
}

Cookie can be easily seen with Chrome DevTools:

Image 3

I built a small application to demo it, and here is the screen where I change the language:

Image 4

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

4.6 Using Localization Services in the Controller

In the Controller is, of course, the Dependency Injection (DI) coming in and filling all the dependencies. So, here services will SharedStringLocalizer and SharedHtmlLocalizer be injected. Here is the code snippet:

C#
public class HomeController : Controller
{
    private readonly ILogger<HomeController> _logger;
    private readonly ISharedStringLocalizer _stringLocalizer;
    private readonly ISharedHtmlLocalizer _htmlLocalizer;

    /* Here is, of course, the Dependency Injection (DI) coming in and filling 
     * all the dependencies. 
     * So, here, services SharedStringLocalizer and SharedHtmlLocalizer
     * will be injected
     */
    public HomeController(ILogger<HomeController> logger,
        ISharedStringLocalizer stringLocalizer,
        ISharedHtmlLocalizer htmlLocalizer)
    {
        _logger = logger;
        _stringLocalizer = stringLocalizer;
        _htmlLocalizer = htmlLocalizer;
    }
    
    public IActionResult LocalizationExample(LocalizationExampleViewModel model)
{
    //so, here we use ISharedStringLocalizer
    model.IStringLocalizerInController = _stringLocalizer["Wellcome"];
    //so, here we use ISharedHtmlLocalizer
    model.IHtmlLocalizerInController = _htmlLocalizer["Wellcome"];
    return View(model);
}

4.7 Using Localization Services in the View

In the View is, of course, the Dependency Injection (DI) coming in and filling all the dependencies. So, here SharedStringLocalizer and SharedHtmlLocalizer services will be injected. Here is the code snippet:

Razor
@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization

@model LocalizationExampleViewModel

@* Here is, of course, the Dependency Injection (DI) coming in and filling
 all the dependencies.
 So, here services SharedStringLocalizer and SharedHtmlLocalizer
 will be injected
*@

@inject ISharedStringLocalizer StringLocalizer
@inject ISharedHtmlLocalizer HtmlLocalizer

@{
    <div style="width:600px">
        <p class="bg-info">
            ISharedStringLocalizer Localized  in Controller:
            @Model.IStringLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text1 = StringLocalizer["Wellcome"];
            }
            ISharedStringLocalizer Localized  in View: @text1
        </p>

        <p class="bg-info">
            ISharedHtmlLocalizer Localized  in Controller:
            @Model.IHtmlLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text2 = "Wellcome";
            }
            ISharedHtmlLocalizer Localized  in View: @HtmlLocalizer[@text2]
        </p>
    </div>
}

4.8 Execution Result

Here is what the execution result looks like:

Image 5

Note that I added some debugging info into the footer, to show the value of the Request language cookie, to see if the app is working as desired.

5. Full Code

Since most people like code they can copy-paste, here is the full code of the application.

C#
//SharedResource.cs===================================================
namespace SharedResources02
{
    /*
    * This is just a dummy marker class to group shared resources
    * We need it for its name and type
    * 
    * It seems the namespace needs to be the same as app root namespace
    * which needs to be the same as the assembly name.
    * I had some problems when changing the namespace, it would not work.
    * If it doesn't work for you, you can try to use full class name
    * in your DI instruction, like this one: SharedResources02.SharedResource
    * 
    * There is no magic in the name "SharedResource", you can
    * name it "MyResources" and change all references in the code
    * to "MyResources" and all will still work
    * 
    * Location seems can be any folder, although some
    * articles claim it needs to be the root project folder.
    * I do not see such problems in this example. 
    * To me, looks it can be any folder, just keep your
    * namespace tidy. 
    */

    public class SharedResource
    {
    }
}

//ISharedStringLocalizer.cs=================================
namespace SharedResources02
{
    //we create this interface to use it for DI dependency setting
    public interface ISharedStringLocalizer
    {
        public LocalizedString this[string key]
        {
            get;
        }

        LocalizedString GetLocalizedString(string key);
    }
}

//SharedStringLocalizer.cs==================================================
namespace SharedResources02
{
    //we create this helper/wrapper class/service
    //that we are going to pass around in DI
    public class SharedStringLocalizer : ISharedStringLocalizer
    {
        //here is object that is doing the real work
        //it is almost the same as IStringLocalizer<SharedResource>
        private readonly IStringLocalizer localizer;

        public SharedStringLocalizer(IStringLocalizerFactory factory)
        {
            var type = typeof(SharedResource);
            var assemblyName = new AssemblyName(
                type.GetTypeInfo().Assembly.FullName ?? String.Empty);
            this.localizer = factory.Create("SharedResource", 
                assemblyName?.Name ?? String.Empty);
        }

        //in our methods, we just pass work to internal object
        public LocalizedString this[string key] => this.localizer[key];

        public LocalizedString GetLocalizedString(string key)
        {
            return this.GetLocalizedString(key);
        }
    }
}

//ISharedHtmlLocalizer.cs===============================================
namespace SharedResources02
{
    //we create this interface to use it for DI dependency setting
    public interface ISharedHtmlLocalizer
    {
        public LocalizedHtmlString this[string key]
        {
            get;
        }

        LocalizedHtmlString GetLocalizedString(string key);
    }
}

//SharedHtmlLocalizer.cs==================================================
namespace SharedResources02
{
    //we create this helper/wrapper class/service
    //that we are going to pass around in DI
    public class SharedHtmlLocalizer: ISharedHtmlLocalizer
    {
        //here is object that is doing the real work
        //it is almost the same as IHtmlLocalizer<SharedResource>
        private readonly IHtmlLocalizer localizer;

        public SharedHtmlLocalizer(IHtmlLocalizerFactory factory)
        {
            var type = typeof(SharedResource);
            var assemblyName = new AssemblyName(
                type.GetTypeInfo().Assembly.FullName ?? String.Empty);
            this.localizer = factory.Create("SharedResource", 
                assemblyName?.Name ?? String.Empty);
        }

        //in our methods, we just pass work to internal object
        public LocalizedHtmlString this[string key] => this.localizer[key];

        public LocalizedHtmlString GetLocalizedString(string key)
        {
            return this.GetLocalizedString(key);
        }
    }
}

//Program.cs===========================================================================
namespace SharedResources02
{
    public class Program
    {
        public static void Main(string[] args)
        {
            //=====Middleware and Services=============================================
            var builder = WebApplication.CreateBuilder(args);

            //adding multi-language support
            AddingMultiLanguageSupportServices(builder);

            // Add services to the container.
            builder.Services.AddControllersWithViews();

            //====App===================================================================
            var app = builder.Build();

            //adding multi-language support
            AddingMultiLanguageSupport(app);

            // Configure the HTTP request pipeline.
            if (!app.Environment.IsDevelopment())
            {
                app.UseExceptionHandler("/Home/Error");
            }
            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthorization();

            app.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=ChangeLanguage}/{id?}");

            app.Run();
        }

        private static void AddingMultiLanguageSupportServices
                            (WebApplicationBuilder? builder)
        {
            if (builder == null) { throw new Exception("builder==null"); };

            builder.Services.AddLocalization
                    (options => options.ResourcesPath = "Resources");
            builder.Services.AddMvc()
                    .AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix);
            builder.Services.Configure<RequestLocalizationOptions>(options =>
            {
                var supportedCultures = new[] { "en", "fr", "de", "it" };
                options.SetDefaultCulture(supportedCultures[0])
                    .AddSupportedCultures(supportedCultures)
                    .AddSupportedUICultures(supportedCultures);
            });
            builder.Services.AddSingleton<ISharedStringLocalizer, SharedStringLocalizer>();
            builder.Services.AddSingleton<ISharedHtmlLocalizer, SharedHtmlLocalizer>();
        }

        private static void AddingMultiLanguageSupport(WebApplication? app)
        {
            app?.UseRequestLocalization();
        }
    }
}

//HomeController.cs================================================================
namespace SharedResources02.Controllers
{
    public class HomeController : Controller
    {
        private readonly ILogger<HomeController> _logger;
        private readonly ISharedStringLocalizer _stringLocalizer;
        private readonly ISharedHtmlLocalizer _htmlLocalizer;

        /* Here is, of course, the Dependency Injection (DI) coming in and filling 
         * all the dependencies. 
         * So, here services SharedStringLocalizer and SharedHtmlLocalizer
         * will be injected
         */
        public HomeController(ILogger<HomeController> logger,
            ISharedStringLocalizer stringLocalizer,
            ISharedHtmlLocalizer htmlLocalizer)
        {
            _logger = logger;
            _stringLocalizer = stringLocalizer;
            _htmlLocalizer = htmlLocalizer;
        }

        public IActionResult ChangeLanguage(ChangeLanguageViewModel model)
        {
            if (model.IsSubmit)
            {
                HttpContext myContext = this.HttpContext;
                ChangeLanguage_SetCookie(myContext, model.SelectedLanguage);
                //doing funny redirect to get new Request Cookie
                //for presentation
                return LocalRedirect("/Home/ChangeLanguage");
            }

            //prepare presentation
            ChangeLanguage_PreparePresentation(model);
            return View(model);
        }

        private void ChangeLanguage_PreparePresentation(ChangeLanguageViewModel model)
        {
            model.ListOfLanguages = new List<SelectListItem>
                        {
                            new SelectListItem
                            {
                                Text = "English",
                                Value = "en"
                            },

                            new SelectListItem
                            {
                                Text = "German",
                                Value = "de",
                            },

                            new SelectListItem
                            {
                                Text = "French",
                                Value = "fr"
                            },

                            new SelectListItem
                            {
                                Text = "Italian",
                                Value = "it"
                            }
                        };
        }

        private void ChangeLanguage_SetCookie(HttpContext myContext, string? culture)
        {
            if(culture == null) { throw new Exception("culture == null"); };

            //this code sets .AspNetCore.Culture cookie
            myContext.Response.Cookies.Append(
                CookieRequestCultureProvider.DefaultCookieName,
                CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
                new CookieOptions { Expires = DateTimeOffset.UtcNow.AddMonths(1) }
            );
        }

        public IActionResult LocalizationExample(LocalizationExampleViewModel model)
        {
            //so, here we use ISharedStringLocalizer
            model.IStringLocalizerInController = _stringLocalizer["Wellcome"];
            //so, here we use ISharedHtmlLocalizer
            model.IHtmlLocalizerInController = _htmlLocalizer["Wellcome"];
            return View(model);
        }

        public IActionResult Error()
        {
            return View(new ErrorViewModel 
               { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
        }
    }
}

//ChangeLanguageViewModel.cs=====================================================
namespace SharedResources02.Models.Home
{
    public class ChangeLanguageViewModel
    {
        //model
        public string? SelectedLanguage { get; set; } = "en";

        public bool IsSubmit { get; set; } = false;

        //view model
        public List<SelectListItem>? ListOfLanguages { get; set; }
    }
}

//LocalizationExampleViewModel.cs===============================================
namespace SharedResources02.Models.Home
{
    public class LocalizationExampleViewModel
    {
        public string? IStringLocalizerInController { get; set; }
        public LocalizedHtmlString? IHtmlLocalizerInController { get; set; }
    }
}
Razor
@* ChangeLanguage.cshtml ===================================================*@
@model ChangeLanguageViewModel

@{
    <div style="width:500px">
        <p class="bg-info">
            <partial name="_Debug.AspNetCore.CultureCookie" /><br />
        </p>

        <form id="form1">
            <fieldset class="border rounded-3 p-3">
                <legend class="float-none w-auto px-3">Change Language</legend>
                <div class="form-group">
                    <label asp-for="SelectedLanguage">Select Language</label>
                    <select class="form-select" asp-for="SelectedLanguage"
                            asp-items="@Model.ListOfLanguages">
                    </select>
                    <input type="hidden" name="IsSubmit" value="true">
                    <button type="submit" form="form1" 
                            class="btn btn-primary mt-3 float-end"
                            asp-area="" asp-controller="Home" 
                            asp-action="ChangeLanguage">
                        Submit
                    </button>
                </div>
            </fieldset>
        </form>
    </div>
}

@* LocalizationExample.cshtml ====================================================*@
@using Microsoft.AspNetCore.Mvc.Localization
@using Microsoft.Extensions.Localization

@model LocalizationExampleViewModel

@* Here is, of course, the Dependency Injection (DI) coming in and filling
 all the dependencies.
 So, here services SharedStringLocalizer and SharedHtmlLocalizer
 will be injected
*@

@inject ISharedStringLocalizer StringLocalizer
@inject ISharedHtmlLocalizer HtmlLocalizer

@{
    <div style="width:600px">
        <p class="bg-info">
            ISharedStringLocalizer Localized  in Controller:
            @Model.IStringLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text1 = StringLocalizer["Wellcome"];
            }
            ISharedStringLocalizer Localized  in View: @text1
        </p>

        <p class="bg-info">
            ISharedHtmlLocalizer Localized  in Controller:
            @Model.IHtmlLocalizerInController
        </p>

        <p class="bg-info">
            @{
                string? text2 = "Wellcome";
            }
            ISharedHtmlLocalizer Localized  in View: @HtmlLocalizer[@text2]
        </p>
    </div>
}

6. References

7. History

  • 13th March, 2024: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer
Serbia Serbia
Mark Pelf is the pen name of just another Software Engineer from Belgrade, Serbia.
My Blog https://markpelf.com/

Comments and Discussions

 
-- There are no messages in this forum --