Table of contents
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.
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.
First, let's create from scratch ASP.NET MVC Web 5 aplication +in Visual Studio (2015 Community Edition - in my case).
So, after solution initialization we'll have structure like shown below.
Let's try to run project in Google Chrome. And we'll get standard ASP.NET MVC initial screen.
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.
Then, add sample string that will be passed to the view layer.
And add string to Strings.ru.resx file with identical key.
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.
using System;
namespace RoutedLocalizationExample.ViewModels
{
public class FullViewModel
{
public FullViewModel()
{
CreationDateTime = DateTime.Now;
}
public string LocalisedString { get; set; }
public DateTime CreationDateTime { get; set; }
}
}
And our simple controller method.
public ActionResult Index()
{
var vm = new FullViewModel { LocalisedString = Strings.SomeLocalisedString };
return View(vm);
}
Then, our Index.cshtml view should like this.
@model RoutedLocalizationExample.ViewModels.FullViewModel
@{
ViewBag.Title = "Home Page";
}
<div class="jumbotron">
<p>@(Model.LocalisedString)</p>
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).
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.
We can define used localization "Dictionary" (appropriate resources file) by using some .NET capabilities:
- System.Threading.Thread.CurrentThread.CurrentCulture - defines culture used in current thread;
- 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.
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 .
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.
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).
using System.Web.Mvc;
using System.Web.Routing;
namespace RoutedLocalizationExample
{
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
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.
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.
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?
We will see predictable CultureNotFoundException exception.
We'll try to resolve this problem a little later in this article.
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.
using System.Web.Mvc;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
namespace RoutedLocalizationExample.ActionFilters
{
public class InternationalizationAttribute : ActionFilterAttribute
{
private readonly IList<string> _supportedLocales;
private readonly string _defaultLang;
public InternationalizationAttribute()
{
_supportedLocales = Utils.LocalizationHelper.GetSupportedLocales();
_defaultLang = _supportedLocales[0];
}
private void SetLang(string lang)
{
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo(lang);
Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo(lang);
}
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
string lang = (string)filterContext.RouteData.Values["lang"] ?? _defaultLang;
if (!_supportedLocales.Contains(lang))
lang = _defaultLang;
SetLang(lang);
}
}
}
Then, we need to add controller method decorated with created action filter.
[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.
Result is what we've expected.
What about handling situations with wrong locales?
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.
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.
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
{
public class LangQueryAppenderModule : IHttpModule
{
private readonly IList<string> _supportedLocales;
private readonly IList<string> _controllersNamesList;
public LangQueryAppenderModule()
{
_supportedLocales = Utils.LocalizationHelper.GetSupportedLocales();
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;
_controllersNamesList.Add(fullName.Substring(0, fullName.Length - "Controller".Length));
}
}
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;
if (string.Equals(ctx.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase))
{
var localisedUri = LocalizationHelper.GetLocalisedUrl(ctx.Request.Url, _controllersNamesList, ctx.Request.UserLanguages);
if (!string.IsNullOrEmpty(localisedUri))
ctx.Response.Redirect(localisedUri);
}
}
catch (Exception)
{
}
}
public void Dispose() { }
}
}
Logic of getting changed target url is in method "GetLocalisedUrl" of static class LocalizationHelper.
public static string GetLocalisedUrl(Uri initialUri, IList<string> controllersNames, IList<string> userLangs)
{
var res = string.Empty;
var supportedLocales = GetSupportedLocales();
var origUrl = initialUri;
var cleanedSegments = origUrl.Segments.Select(X => X.Replace("/", "")).ToList();
var isLocaleDefined = cleanedSegments.Count > 1 && supportedLocales.Contains(cleanedSegments[1]);
var isRequestPathToHandle =
(cleanedSegments.Count > 1 && cleanedSegments.Intersect(controllersNames).Count() > 0) ||
(cleanedSegments.Count == 1) ||
(cleanedSegments.Count == 2 && !supportedLocales.Contains(cleanedSegments[1]));
if (!isLocaleDefined && isRequestPathToHandle)
{
var langVal = "";
if (userLangs != null && userLangs.Count > 0)
{
var splitted = userLangs[0].Split(new char[] { '-' });
langVal = splitted[0];
}
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])))
{
cleanedSegments.RemoveAt(1);
normalisedPathAndQuery = string.Join("/", cleanedSegments) + origUrl.Query;
}
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.
<system.webServer>
<modules>
<add name="LangQueryAppenderModule" type="RoutedLocalizationExample.HttpModules.LangQueryAppenderModule" />
</modules>
Finally, let's try our new localization logic.
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.
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").
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.
@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>
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.
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.
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...
2016-04-27 - initial state.