Click here to Skip to main content
15,884,836 members
Articles / Web Development / HTML

Route-friendly localization of ASP.NET MVC 5

Rate me:
Please Sign up or sign in to vote.
4.79/5 (13 votes)
27 Apr 2016CPOL8 min read 68.8K   1.7K   21   7
Creating of user-friendly configurable internationalization mechanism in ASP.NET MVC application

Table of contents

Introduction

Localization is the common task for all the services created for multilingual usage.

There are a lot of well-known approaches to localize .NET applications, including ASP.NET MVC web applications.

But in this article I want to show how to create localization system, that will have next key features:

  • routing system with support of locale;
  • correct caching work for localised views;
  • change current site locale from interface;
  • if locale is not defined in route - then automatically get locale from user request context.

Background

Base of localisation in ASP.NET MVC is using resources file.

What is resources file and how to define the content of these files is described well in this Codeproject article.

Mechaninsm used to pass user localization preferences in primary source can be found on W3C site section.

Using the code

Create solution

First, let's create from scratch ASP.NET MVC Web 5 aplication +in Visual Studio (2015 Community Edition - in my case).

Create solution in VS

Create solution - change template

So, after solution initialization we'll have structure like shown below.

Initial solution structure

Let's try to run project in Google Chrome. And we'll get standard ASP.NET MVC initial screen.

Start scree of inInitial project.png

Add localization resources and get first localized result

We'll use an approach to localize web applicaion using .NET resources files.

Details of creating the .NET resources files can be found in this MSDN article.

First, add the resources files.

Add *.resx file

Then, add sample string that will be passed to the view layer.

Add english string example

And add string to Strings.ru.resx file with identical key.

Add string to resources file for russian locale

Then, let's create method in Home controller to test localization behaviour.

Clean all existing methods in file HomeController.cs.

Then, add ViewModel class for our experiments.

C#
using System;

namespace RoutedLocalizationExample.ViewModels
{
    public class FullViewModel
    {
        public FullViewModel()
        {
            CreationDateTime = DateTime.Now;
        }

        /// <summary>
        /// This will contain localised string value
        /// </summary>
        public string LocalisedString { get; set; }

        /// <summary>
        /// For see difference of cretion time
        /// </summary>
        public DateTime CreationDateTime { get; set; }
    }
} 

And our simple controller method.

C#
        // Localize string without any external impact
        public ActionResult Index()
        {
            // Get string from strongly typed localzation resources
            var vm = new FullViewModel { LocalisedString = Strings.SomeLocalisedString };
            return View(vm);
        }

Then, our Index.cshtml view should like this.

C#
@model RoutedLocalizationExample.ViewModels.FullViewModel

@{
    ViewBag.Title = "Home Page";
}

<div class="jumbotron">
    <p>@(Model.LocalisedString)</p>
    <p>@Url.Action("LangFromRouteInActionFilter", "Home")</p>
    <p>@Model.CreationDateTime.ToString()</p>
</div>

What do we have here?

  • Model.LocalisedString is to show localized content;
  • Url.Action("LangFromRouteInActionFilter", "Home") is to show how url are builded. We will need it to show some features of routing mechanism;
  • Model.CreationDateTime.ToString() - we will need to know the time when a page was rendered.

Finally, let's try to get result in web browser (Google Chrome in my case).

First localization result

So, here we have localised string. How is target locale defined? Magic takes place in backgound. Prefered locale is catched from HTTP header "Accept-Language" and is applied to view rendering result.

So, in this case we have no explicit control over used locale, except of languages list in  browser settings. But, like the User, I want to have option to define language with less efforts.

Get language from request query string

We can define used localization "Dictionary" (appropriate resources file) by using some .NET capabilities:

  1. System.Threading.Thread.CurrentThread.CurrentCulture - defines culture used in current thread;
  2. System.Threading.Thread.CurrentThread.CurrentUICulture - defines UI culture used in current thread.

So, let's try to take control over localization result with help of the mentioned above features. Add new method to HomeController.

C#
        // Get language from query string (by binder)
        public ActionResult LangFromQueryString(string lang)
        {
            Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(lang);
            Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(lang);

            var vm = new FullViewModel { LocalisedString = Strings.SomeLocalisedString };
            return View("Index", vm);
        }

Then, try to use it. Please, pay attention to the query string of requested URL .

Get language from query string.png

Excellent! Now we have control of locale by query parameter.

But... Is it user friendly? With it's qestion mark and parameter definition... I think that for the average user - it doesn't seems friendly enough.

Define localization routing rule

ASP.NET MVC have the great out-of-box feature for building informative and nice looking URLs. It's the routing mechanism. So, we definetly need to use it for our localization purposes.

Let's add new routing rule in App_Start/RouteConfig.cs file (to void method RegisterRoutes).

C#
using System.Web.Mvc;
using System.Web.Routing;

namespace RoutedLocalizationExample
{
    public class RouteConfig
    {
        public static void RegisterRoutes(RouteCollection routes)
        {
            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

            // Localization route - it will be used as a route of the first priority 
            routes.MapRoute(
                name: "DefaultLocalized",
                url: "{lang}/{controller}/{action}/{id}",
                defaults: new
                {
                    controller = "Home",
                    action = "Index",
                    id = UrlParameter.Optional,
                    lang = "en"
                });

            routes.MapRoute(
                name: "Default",
                url: "{controller}/{action}/{id}",
                defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
            );
        }
    }
}

Then, add Home controller method that will catch lang route parameter.

C#
        // Get language as a parameter from route data
        public ActionResult LangFromRouteValues(string lang)
        {
            Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(lang);
            Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(lang);

            var vm = new FullViewModel { LocalisedString = Strings.SomeLocalisedString };
            return View("Index", vm);
        }

It does not differ from method "LangFromQueryString" declared previosly. Why is it so?

Because of result of binders working. It's the out-of-box magic. We don't need to care how to catch parameter value - is it should to be catched from query string or from RouteTable values?

Finally, let's see new controller method in action.

Get user language from route values

I think that it's obviously better than "Controller/Method?lang=ru". But...

What will occur if user try to use some locale for which we don't have appropriate resources file in our project?

Unknown locale exception.png

We will see predictable CultureNotFoundException exception.

We'll try to resolve this problem a little later in this article.

Localisation logic in action filter

Using localization logic will be the common task for the majority of controllers methods, of course.

So, we definetly need to follow DRY principle when perform localization task. And, again, platform has a feature that will help us. It's ActionFilterAttribute class. You can get more information about action filters in this article.

Let's create catalogue "ActionFilters" and code our InternationalizationAttribute.

C#
using System.Web.Mvc;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;

namespace RoutedLocalizationExample.ActionFilters
{
    /// <summary>
    /// Set language that is defined in route parameter "lang"
    /// </summary>
    public class InternationalizationAttribute : ActionFilterAttribute
    {
        private readonly IList<string> _supportedLocales;
        private readonly string _defaultLang;

        public InternationalizationAttribute()
        {
            // Get supported locales list
            _supportedLocales = Utils.LocalizationHelper.GetSupportedLocales();

            // Set default locale
            _defaultLang = _supportedLocales[0];
        }

        /// <summary>
        /// Apply locale to current thread
        /// </summary>
        /// <param name="lang">locale name</param>
        private void SetLang(string lang)
        {
            Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(lang);
            Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(lang);
        }

        public override void OnActionExecuting(ActionExecutingContext filterContext)
        {
            // Get locale from route values
            string lang = (string)filterContext.RouteData.Values["lang"] ?? _defaultLang;

            // If we haven't found appropriate culture - seet default locale then
            if (!_supportedLocales.Contains(lang))
                lang = _defaultLang;

            SetLang(lang);
        }
    }
}

Then, we need to add controller method decorated with created action filter.

C#
        // Get language in action filter (from route parameter)
        [Internationalization]
        public ActionResult LangFromRouteInActionFilter()
        {
            var vm = new FullViewModel { LocalisedString = Strings.SomeLocalisedString };
            return View("Index", vm);
        }

So, let's try to test it. Start in Chrome and see results.

Localization logic in action filter

Result is what we've expected.

What about handling situations with wrong locales?

Handle wrong locale in action filter

Result is better, then screen with fatal error, of course. We see the effect of using default locale ("en") in action filter.

But it'll be better to return supported language in route.

Handle situations when locale is not defined in url

Let's assume, that we have some ASP.NET MVC site that initially hasn't support of localizaion. Site is in production environment already. It has many visitros and a lot hyperlinks in Internet follow to this site. This site has only english version.

Once, site's owner have decided to add localization feature.

So, there may be cases when user will request url of site without any lang parameter value. And we have to handle that requests correctly. Fatal error or 404 page shouldn't be the predictable case for system in production.

The problem is that we can't handle the situation described above in any action filter, because action filters methods are invoked in context of explicit controller, when target controller was defined according to requested url. But it does not in case when we have changed routing mechanism.

So - sutiation seems like a deadlock for the first time.

But... Actually we can control request handling logic before ASP.NET MVC core will decide whether appropriate controller exists ot not. We can do this in HTTPModules.

Let's try to create such a module by implementing interface System.Web.IHttpModule.

C#
using System;
using System.Reflection;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Collections.Generic;
using RoutedLocalizationExample.Utils;

namespace RoutedLocalizationExample.HttpModules
{
    /// <summary>
    /// Module to append lang parameter to the requested url if it's absent or unsupported
    /// </summary>
    public class LangQueryAppenderModule : IHttpModule
    {
        /// <summary>
        /// List of supported locales
        /// </summary>
        private readonly IList<string> _supportedLocales;

        /// <summary>
        /// We need to have controllers list to correctly handle situations
        /// when target method name is missed
        /// </summary>
        private readonly IList<string> _controllersNamesList;

        public LangQueryAppenderModule()
        {
            // Get list of supported locales 
            _supportedLocales = Utils.LocalizationHelper.GetSupportedLocales();

            // Get controllers list of current project by reflection
            var asmPath = HttpContext.Current.Server.MapPath("~/bin/RoutedLocalizationExample.dll");
            Assembly asm = Assembly.LoadFile(asmPath);

            var controllerTypes = asm.GetTypes()
                .Where(type => typeof(Controller).IsAssignableFrom(type));
            _controllersNamesList = new List<string>();

            foreach (var controllerType in controllerTypes)
            {
                var fullName = controllerType.Name;

                // We need only name part of Controller class that is used in route
                _controllersNamesList.Add(fullName.Substring(0, fullName.Length - "Controller".Length));
            }
        }

        // In the Init function, register for HttpApplication 
        // events by adding your handlers.
        public void Init(HttpApplication application)
        {
            application.BeginRequest += (new EventHandler(this.Application_BeginRequest));
        }

        private void Application_BeginRequest(Object source, EventArgs e)
        {
            try
            {
                HttpApplication app = (HttpApplication)source;
                HttpContext ctx = app.Context;

                // We will redirect to url with defined locale only in case for HTTP GET verb
                // cause we assume that all requests with other verbs will be called from site directly
                // where all the urls created with URLHelper, so it complies with routing rules and will contain "lang" parameter
                if (string.Equals(ctx.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase))
                {                    
                    var localisedUri = LocalizationHelper.GetLocalisedUrl(ctx.Request.Url, _controllersNamesList, ctx.Request.UserLanguages);
                    if (!string.IsNullOrEmpty(localisedUri))
                        // Perform redirect action to changed url if it exists
                        ctx.Response.Redirect(localisedUri);
                }
            }
            catch (Exception)
            {
                // Some logging logic could be here
            }
        }

        public void Dispose() { }
    }
}

Logic of getting changed target url is in method "GetLocalisedUrl" of static class LocalizationHelper.

C#
        /// <summary>
        /// Get request url corrected according to logic of routing with locale 
        /// </summary>
        /// <param name="initialUri"></param>
        /// <param name="controllersNames"></param>
        /// <param name="userLangs"></param>
        /// <returns></returns>
        public static string GetLocalisedUrl(Uri initialUri, IList<string> controllersNames, IList<string> userLangs)
        {
            var res = string.Empty;

            var supportedLocales = GetSupportedLocales();

            var origUrl = initialUri;

            // Dicide requested url to parts
            var cleanedSegments = origUrl.Segments.Select(X => X.Replace("/", "")).ToList();

            // Check is already supported locale defined in route
            // cleanedSegments[0] is empty string, so lang parameter will be in [1] url segment
            var isLocaleDefined = cleanedSegments.Count > 1 && supportedLocales.Contains(cleanedSegments[1]);

            // does request need to be changed
            var isRequestPathToHandle =
                // Url has controller's name part
                (cleanedSegments.Count > 1 && cleanedSegments.Intersect(controllersNames).Count() > 0) ||
                // This condition is for default (initial) route
                (cleanedSegments.Count == 1) ||
                // initial route with lang parameter that is not supported -> need to change it
                (cleanedSegments.Count == 2 && !supportedLocales.Contains(cleanedSegments[1]));     

            if (!isLocaleDefined && isRequestPathToHandle)
            {
                var langVal = "";
                // Get user preffered language from Accept-Language header
                if (userLangs != null && userLangs.Count > 0)
                {
                    // For our locale name approach we'll take only first part of lang-locale definition
                    var splitted = userLangs[0].Split(new char[] { '-' });
                    langVal = splitted[0];
                }

                // If we don't support requested language - then redirect to requested page with default language
                if (!supportedLocales.Contains(langVal))
                    langVal = supportedLocales[0];

                var normalisedPathAndQuery = origUrl.PathAndQuery;
                if ((cleanedSegments.Count > 2 &&
                    !controllersNames.Contains(cleanedSegments[1]) &&
                    controllersNames.Contains(cleanedSegments[2])) ||
                    (cleanedSegments.Count == 2) && (!controllersNames.Contains(cleanedSegments[1])))
                {
                    // Second segment contains lang parameter, third segment contains controller name
                    cleanedSegments.RemoveAt(1);

                    // Remove wrong locale name from initial Uri
                    normalisedPathAndQuery = string.Join("/", cleanedSegments) + origUrl.Query;
                }

                // Finally, create new uri with language loocale
                res = string.Format("{0}://{1}:{2}/{3}{4}", origUrl.Scheme, origUrl.Host, origUrl.Port, langVal.ToLower(), normalisedPathAndQuery);
            }

            return res;
        }

Comments are presented in code.

This method seems to be not trivial, so I've added the project with unit tests to cover all the possible situations. Not all of course :), but at least - situations that has a place in practice.

Now, we need to activate our module by editing web.config file like shown below.

C#
  <system.webServer>
    <modules>
        <add name="LangQueryAppenderModule" type="RoutedLocalizationExample.HttpModules.LangQueryAppenderModule" />
    </modules>  
  </system.webServer>

Finally, let's try our new localization logic.

Initial route with prepender module.png

Now, for initial route "ru" value of lang parameter is added by our module. OK.

Further, let's try a little more interesting case. Simulate situation of requesting some page from extarnal link with no lang parameter. Create new browaser tab with url "http://localhost:64417/Home/LangFromRouteInActionFilter".

And we will receive redirect to "http://localhost:64417/ru/Home/LangFromRouteInActionFilter".

Exactly what we need!

If your prefered browser language is English, then you will be redirected to english variant of page.

What about not supported locale in initial url?

Requested url "http://localhost:64417/qwerty/Home/LangFromRouteInActionFilter" will be redirected to "http://localhost:64417/ru/Home/LangFromRouteInActionFilter" again.

And finally. We don't want to not web-browsers clients will receive 404 error. So, let's simulate case when request doesn't provide value of "Accept-Language" value. We'll use Fiddler (more info about application can be found here) for this purpose.

Use Fiddler to simulate request with no language header

Pay attention to response chain - first - code 302 (redirection). Then - english version of requested page. Why English? Because in created request we haven't provide "Accept-Language" header. So, our LangQueryAppenderModule inserts value of project's default locale ("en").

Change current site locale from interface

Supporting of localization in routing mechanism is good. But I think that user will prefer to change it in graphical interface. So, let's add language selector into the main menu of site.

Let's add file HeaderPartial.cshtml to Views/Shared catalogue.

C#
@helper langSelector() {
    string curLang = "en"; ;
    if (this.ViewContext.RouteData.Values["lang"] != null)
    {
        curLang = this.ViewContext.RouteData.Values["lang"].ToString();
    }

    var enabledLangsList = RoutedLocalizationExample.Utils.LocalizationHelper.GetSupportedLocales();

    var targetPath = string.Format("/{0}/{1}{2}",
        ViewContext.RouteData.Values["controller"].ToString(),
        ViewContext.RouteData.Values["action"].ToString(),
        Request.Url.Query);
    var hostRoot = string.Format("{0}://{1}", Request.Url.Scheme, Request.Url.Authority);
    var targetUrlMask = string.Format("{0}/{{0}}/{1}", hostRoot, targetPath);

    <li class="dropdown special" style="margin-left: 15px;">
        <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
            @(curLang)
            <span class="caret"></span>
        </a>
        <ul class="dropdown-menu lang-selector">
            @foreach (var lang in enabledLangsList)
            {
                <li><a href="@(string.Format(targetUrlMask, lang))">@lang</a></li>
            }
        </ul>
    </li>
}

<div class="navbar navbar-inverse navbar-fixed-top">
    <div class="container">
        <div class="navbar-header">
            <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            </button>
            @Html.ActionLink("Application name", "Index", "Home", new { area = "" }, new { @class = "navbar-brand" })
        </div>
        <div class="navbar-collapse collapse">
            <ul class="nav navbar-nav navbar-left">
                <li>@Html.ActionLink("Home", "Index", "Home")</li>
                <li>@Html.ActionLink("About", "About", "Home")</li>
                <li>@Html.ActionLink("Contact", "Contact", "Home")</li>
            </ul>
            <ul class="nav navbar-nav navbar-right" style="margin-right: -5px;">
                @langSelector()
            </ul>
        </div>        
    </div>
</div>

We've added a helper that will contain all the language selector logic and visual elements.

And, finally, try to change language from site menu.

Select locale from UI

Localization and caching - using together

Another thing to note when you are working with ASP.NET MVC project localization is that you have to be very carefully when using caching mechanism, including OutputCacheAttribute. The reason is that your localization logic should comply to limitations and logic of caching.

Most common negative scenario is when the client will receive cached page that will not match to requested locale. This problem is resolved in localization logic described above in this article.

But you can try to create conditions to reproduce it. In the case of out-of-box ASP.NET MVC caching configuration key condition to reproduce bad result is that pages for different locales will be requested by the same url. Then, site will return view with locale, that was called at first.

When we've added "lang" route parameter - we've gone around this problem.

You can see working example of localization approach described above here for example.

Points of Interest

One of the most interesting part of localizaton scheme described in this article is to imagine all the combinations of url segments for all possible real life scenarios.

For example, correct handling of requests with no "Accept-Language" header provided. I've found these requests only during analysis of application's and IIS's logs. Because it received 404 response. :)

This situation once again have proved the importance and benefits of using unit tests in your projects...

History

2016-04-27 - initial state.

License

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


Written By
Chief Technology Officer Cashee
Russian Federation Russian Federation
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionHow to ignore some route from this localization? Pin
Yorlen Guirado24-Jul-19 4:36
Yorlen Guirado24-Jul-19 4:36 
GeneralMy vote of 5 Pin
xuanhoang_itdlu29-Aug-17 3:11
xuanhoang_itdlu29-Aug-17 3:11 
BugLittle misredirection Pin
DJmRek24-Aug-17 0:05
DJmRek24-Aug-17 0:05 
SuggestionI thought it was on: "user-friendly localized routes in ASP.NET" Pin
Red Feet29-Apr-16 0:06
Red Feet29-Apr-16 0:06 
GeneralRe: I thought it was on: "user-friendly localized routes in ASP.NET" Pin
DmitriyArh8829-Apr-16 0:23
professionalDmitriyArh8829-Apr-16 0:23 
PraiseRe: I thought it was on: "user-friendly localized routes in ASP.NET" Pin
Red Feet29-Apr-16 0:25
Red Feet29-Apr-16 0:25 
GeneralRe: I thought it was on: "user-friendly localized routes in ASP.NET" Pin
Member 121984802-May-16 23:57
Member 121984802-May-16 23:57 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.