Search
Close this search box.

Localization in ASP.NET MVC – 3 Days Investigation, 1 Day Job


Localization is a common issue when we develop a world wide web application. The key point of making your application localizable is to separate the page content from your logic implementation. That means, when you want to display something on the page, never put them directly on the page file (or the backend logic). You should give the content a key which can be linked to the real content for the proper language setting.

Last week I was implementing the localization on my ASP.NET MVC application. This is my first time to do it so I spent about 3 days for investigation, trying and come up with a final solution which only needs 1 day’s job. So let’s take a look on what I have done.

Localization supported by ASP.NET MVC

ASP.NET MVC was built on top of the ASP.NET runtime so all feature provided by ASP.NET can be used in MVC without any wheaks such as caching, session state and localization. In the traditional ASP.NET web form ages we were using the resource files to store the content of the application with different cultures and using the ResourceManager class to retrieve them which can be generated by Visual Studio automatically. In ASP.NET MVC they works well.

Let’s create a standard ASP.NET MVC application for an example. The website was in English and we can see all content are hard-written in the view pages and the controller classes.

Now what I need to do is to put all contents out of from the pages and the controllers. ASP.NET gives us a special folder named App_GlobalResources which contains the resource files for the content of all cultures. Just right-click the project in the solution explorer window and create the folder under the Add > Add ASP.NET Folders menu

I created 2 resource files for 2 langauges: English and Chinese. The English would be the default language of this application so I will create Global.resx file firstly, then Global.zh.resx. The middle name ‘zh’ was the culture name of this language. If we need a French version in the future we can simply create Global.fr.resx. The Visual Studio will help us to generate the accessing class for them.

Then let’s move some content into the resource files. In the home page there are 3 places need to be changed: the title, message and the description. So we add 3 items in our 2 resource files.

The title and the description are defined in the view page so we will change the view page. It will load the content through the access class generated by Visual Studio.

  1: <%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage" %>
   2:  
   3: <asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
   4:     <%
   1: : Resources.Global.HomeIndex_Title 
%>
   5: </asp:Content>
   6:  
   7: <asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
   8:     <h2><%
   1: : ViewData["Message"] 
%></h2>
   9:     <p>
  10:         <%
   1: : Resources.Global.Home_Index_Desc 
%> <a href="http://asp.net/mvc" title='<%: Resources.Global.Home_Index_DescLink %>'>http://asp.net/mvc</a>
  11:     </p>
  12: </asp:Content>

The message was defined in the controller class and passed to the view page through the ViewData so we also need to change the home controller as well.
 1: public ActionResult Index()
   2: {
   3:     ViewData["Message"] = Resources.Global.HomeIndex_Message;
   4:  
   5:     return View();
   6: }

Specify the language through the URL

We had moved the content into the resource files but our application does not support localization since there’s no place we can specify the language setting. In order to make it as simple as possible we will make the URL indicate the current selected language, which means if my URL was http://localhost/en-US/Home/Index it will in English while http://localhost/zh-CN/Home/Index will in Chinese. The user can change the language at any pages he’s staying, and also when he want to share the URL it will pass his language setting as well.

In order to do so I changed the application routes, add a new route with a new partten named lang in front of the controller.

 1: public static void RegisterRoutes(RouteCollection routes)
   2: {
   3:     routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
   4:  
   5:     routes.MapRoute(
   6:         "Localization", // Route name
   7:         "{lang}/{controller}/{action}/{id}", // URL with parameters
   8:         new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
   9:     );
  10:  
  11:     routes.MapRoute(
  12:         "Default", // Route name
  13:         "{controller}/{action}/{id}", // URL with parameters
  14:         new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
  15:     );
  16:  
  17: }

You may noticed that I added a new route rather than modifed the default route, and didn’t specify the default value of the {lang} pattern. It’s because we need the default route render the default request which without the language setting such as http://localhost/ and http://localhost/Home/Index.

If I modied the default route, http://localhost/ cannot be routed; and the http://localhost/Home/Index would be routed to lang = Home, controller = Index which is incorrect.

Since we need the URL control the language setting we should perform some logic before each action was executed. The ActionFilter would be a good solution in this scenario.
   1: using System;
   2: using System.Collections.Generic;
   3: using System.Linq;
   4: using System.Web;
   5: using System.Web.Mvc;
   6: using System.Threading;
   7: using System.Globalization;
   8:  
   9: namespace ShaunXu.MvcLocalization
  10: {
  11:     public class LocalizationAttribute : ActionFilterAttribute
  12:     {
  13:         public override void OnActionExecuting(ActionExecutingContext filterContext)
  14:         {
  15:             if (filterContext.RouteData.Values["lang"] != null &&
  16:                 !string.IsNullOrWhiteSpace(filterContext.RouteData.Values["lang"].ToString()))
  17:             {
  18:                 // set the culture from the route data (url)
  19:                 var lang = filterContext.RouteData.Values["lang"].ToString();
  20:                 Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(lang);
  21:             }
  22:             else
  23:             {
  24:                 // load the culture info from the cookie
  25:                 var cookie = filterContext.HttpContext.Request.Cookies["ShaunXu.MvcLocalization.CurrentUICulture"];
  26:                 var langHeader = string.Empty;
  27:                 if (cookie != null)
  28:                 {
  29:                     // set the culture by the cookie content
  30:                     langHeader = cookie.Value;
  31:                     Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(langHeader);
  32:                 }
  33:                 else
  34:                 {
  35:                     // set the culture by the location if not speicified
  36:                     langHeader = filterContext.HttpContext.Request.UserLanguages[0];
  37:                     Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(langHeader);
  38:                 }
  39:                 // set the lang value into route data
  40:                 filterContext.RouteData.Values["lang"] = langHeader;
  41:             }
  42:  
  43:             // save the location into cookie
  44:             HttpCookie _cookie = new HttpCookie("ShaunXu.MvcLocalization.CurrentUICulture", Thread.CurrentThread.CurrentUICulture.Name);
  45:             _cookie.Expires = DateTime.Now.AddYears(1);
  46:             filterContext.HttpContext.Response.SetCookie(_cookie);
  47:  
  48:             base.OnActionExecuting(filterContext);
  49:         }
  50:     }
  51: }

I created an attribute named LocalizationAttribute which inherited from the ActionFilterAttribute and overrided its OnActionExecuting method. I firstly checked the RouteData. If it contains the language setting I will set it to the CurrentUICulture of the CurrentThread, which will indicate the resource manager (generated by Visual Studio based on the resource files) retrieve the related value.

If no language setting in the RouteData I checked the cookie and set it if available. Otherwise I used the user language of the HttpRequest and set into the current thread.

Finally I set the language setting back to the route data so all coming actions would retrieve it and also saved it into the cookie so that next time the user opened the browser he will see his last language setting.

Then I applied the attribute on the home controller so that all actions will perform my localization logic.

  1: namespace ShaunXu.MvcLocalization.Controllers
   2: {
   3:     [HandleError]
   4:     [Localization]
   5:     public class HomeController : Controller
   6:     {
   7:         public ActionResult Index()
   8:         {
   9:             ViewData["Message"] = Resources.Global.HomeIndex_Message;
  10:  
  11:             return View();
  12:         }
  13:  
  14:         public ActionResult About()
  15:         {
  16:             return View();
  17:         }
  18:     }
  19: }

Now if we start the application and add the language setting on the URL we can see the result.

Links for the language selection

Let the user change the language through the URL would not be a good solution. We need to give them some links on top of the pages so that they can change it at any time. In ASP.NET MVC the simplest way is to create a HtmlHelper to render the links for each language.

 1: using System;
   2: using System.Collections.Generic;
   3: using System.Linq;
   4: using System.Web;
   5: using System.Web.Routing;
   6: using System.Web.Mvc;
   7: using System.Web.Mvc.Html;
   8: using System.Threading;
   9:  
  10: namespace ShaunXu.MvcLocalization
  11: {
  12:     public static class SwitchLanguageHelper
  13:     {
  14:         public class Language
  15:         {
  16:             public string Url { get; set; }
  17:             public string ActionName { get; set; }
  18:             public string ControllerName { get; set; }
  19:             public RouteValueDictionary RouteValues { get; set; }
  20:             public bool IsSelected { get; set; }
  21:  
  22:             public MvcHtmlString HtmlSafeUrl
  23:             {
  24:                 get
  25:                 {
  26:                     return MvcHtmlString.Create(Url);
  27:                 }
  28:             }
  29:         }
  30:  
  31:         public static Language LanguageUrl(this HtmlHelper helper, string cultureName,
  32:             string languageRouteName = "lang", bool strictSelected = false)
  33:         {
  34:             // set the input language to lower
  35:             cultureName = cultureName.ToLower();
  36:             // retrieve the route values from the view context
  37:             var routeValues = new RouteValueDictionary(helper.ViewContext.RouteData.Values);
  38:             // copy the query strings into the route values to generate the link
  39:             var queryString = helper.ViewContext.HttpContext.Request.QueryString;
  40:             foreach (string key in queryString)
  41:             {
  42:                 if (queryString[key] != null && !string.IsNullOrWhiteSpace(key))
  43:                 {
  44:                     if (routeValues.ContainsKey(key))
  45:                     {
  46:                         routeValues[key] = queryString[key];
  47:                     }
  48:                     else
  49:                     {
  50:                         routeValues.Add(key, queryString[key]);
  51:                     }
  52:                 }
  53:             }
  54:             var actionName = routeValues["action"].ToString();
  55:             var controllerName = routeValues["controller"].ToString();
  56:             // set the language into route values
  57:             routeValues[languageRouteName] = cultureName;
  58:             // generate the language specify url
  59:             var urlHelper = new UrlHelper(helper.ViewContext.RequestContext, helper.RouteCollection);
  60:             var url = urlHelper.RouteUrl("Localization", routeValues);
  61:             // check whether the current thread ui culture is this language
  62:             var current_lang_name = Thread.CurrentThread.CurrentUICulture.Name.ToLower();
  63:             var isSelected = strictSelected ?
  64:                 current_lang_name == cultureName :
  65:                 current_lang_name.StartsWith(cultureName);
  66:             return new Language()
  67:             {
  68:                 Url = url,
  69:                 ActionName = actionName,
  70:                 ControllerName = controllerName,
  71:                 RouteValues = routeValues,
  72:                 IsSelected = isSelected
  73:             };
  74:         }
  75:  
  76:         public static MvcHtmlString LanguageSelectorLink(this HtmlHelper helper,
  77:             string cultureName, string selectedText, string unselectedText,
  78:             IDictionary<string, object> htmlAttributes, string languageRouteName = "lang", bool strictSelected = false)
  79:         {
  80:             var language = helper.LanguageUrl(cultureName, languageRouteName, strictSelected);
  81:             var link = helper.RouteLink(language.IsSelected ? selectedText : unselectedText,
  82:                 "Localization", language.RouteValues, htmlAttributes);
  83:             return link;
  84:         }
  85:  
  86:     }
  87: }

I created a class to store the information of the language links. This can be used to render a linkage for a language, and it also can be used if we need the selector it be an image linkage, dropdown list or anything we want as well.

The LanguageUrl method takes the main responsible for generating the information that can be used in the selector such as the URL, RouteValues, etc. It loads the RouteData and query string from the incoming request and swich the language part, then generate the URL of current page with that language so that it will render the same page with that language when the user clicked.

The LanguageSelectorLink method takes the responsible for rendering a full Html linkage for this language which we will use it for our simple exmaple.

We need the language select available in all pages so we should put the links in the master page.
   1: <div id="logindisplay">
   2:     <%
   1:  Html.RenderPartial("LogOnUserControl"); 
%>
   3:  
   4:     <%
   1: : Html.LanguageSelectorLink("en-US", "[English]", "English", null) 
%>
   5:     <%
   1: : Html.LanguageSelectorLink("zh-CN", "[中文]", "中文", null) 
%>
   6: </div> 
     
 Don’t forget to import the namespace of the SwitchLanguageHelper class on top of the master page otherwise the extension method will not work.

Oops! Log On page crashed

Some web application only can be viewed once logged on, such as some CRM system used inside the company. It means before any action was performed the application should be redirected to the log on page if the user was not identitied. Let’s have a look on what will be in our sample application. Just add the Authorize attribute on the home controller.

Oops! It gave me a 404 error which means the application cannot find the resource for /Account/LogOn. This is because our routes. We have 2 routes registered in the system, the Localization route has 4 parttens: lang, controller, action and id; the Default route has 3 parttens: controller, action and id. The incoming request will be checked through all routes based on their rules and once it’s matched the value would be identified by that route. For example if the URL was http://localhost/en-US/Home/Index it matches the Localization route (lang = en-US, controller = Home, action = Index, id is optional). If the URL was http://localhost/ it was not match the Localization route (lang cannot be null or empty) so it matches the Default route since it allows all parttens to be null.

Now let’s have a look on this URL http://localhost/Account/LogOn, the Account could be the lang partten and LogOn could be the controller, since the action and id partten are all optional it matchs the Localization route so it means: language = Account, controller = LogOn, action = Index (by default). But there is no controller named LogOn and action named Index so it’s the reason why it returned 404 error.

Since the logon URL was redirected by the ASP.NET authorizing model which means we cannot add the language data into the URL we have to add a new route individually for it.

  1: routes.MapRoute(
   2:     "LogOn", // Route name
   3:     "Account/{action}", // URL with parameters
   4:     new { controller = "Account", action = "LogOn" } // Parameter defaults
   5: );
   6:  
   7: routes.MapRoute(
   8:     "Localization", // Route name
   9:     "{lang}/{controller}/{action}/{id}", // URL with parameters
  10:     new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
  11: );
  12:  
  13: routes.MapRoute(
  14:     "Default", // Route name
  15:     "{controller}/{action}/{id}", // URL with parameters
  16:     new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
  17: );

In front of the Localization and Default route I added a new one named LogOn, it only accept the Account controller so for this URL http://localhost/Account/LogOn the controller would be Account and action would be LogOn which is correct. Let’s have a look on the result.

Refactoring the codes

This application is a fairly simple example, like all example the microsoft provided, it only contains one project which is my website. In the real projects we might not want to organize our application in one project. We would like to separate the webiste, the controllers, the models, and maybe we need the data access and business layers in the different projects. So let’s refactor my example a little bit and see what we should do then to make it localizable.

Here I just want to separte the controllers and view models out of my website project but would not create the data access and business logic layer since normally they would not be related with the localization.

Localization is the matter we should solve at the persentation layer. We should never pass any localized i information from the business layer. For example, if the password was incorrect when authorizing an user at the business layer we should always return a flag (int or enum) to indicate the error instead of any error message strings.

I moved the controllers and models out of the my main website project and let them referenced by the webiste project. I added the necessary assemblies and built it. All worked well except my localization code under the home controller class. I set the message string into the ViewData from the resource class which defined in my website project, now it cannot be accessed through my controller project.

So what I should do here is to move the resource files out of the website project since it’s at the bottom of the references hierarchy.

After moved the resource files to the new project and added the refereneces, I built it again but I got more error. This is because the accessing classes generated for the resource files are defined as “internal” by default which cannot be invoked out of the project. So what I can do is to create the new resource files for localization and update their access model to “Public

And this time our application works well.

Localizing messages in ViewModels

In the ASP.NET MVC application the message can be defined directly on the view pages, controllers and the view models. One of the scenario is to define the error messages on the view model classes through the attributes provided by System.ComponentModel.DataAnnotations. With the DataAnnotations attributes we can add the validation method and the error messages on the propties of the model classes through the AOP approch. For example when loggin on the user name field should be displayed as “User name” and should be mandatory. So the model would be defined like this, whichi doesn’t support localization.

   1: [Required]
   2: [DisplayName("User name")]
   3: public string UserName { get; set; }

We can defined the error messages for the Requeired attribute. And the pre-defined DataAnnotations attributes supports localization which means we can define the resource key and resource type then it will find the relevant resources and returned the content back. Assuming that I had defined 2 resources one for the display name the other for error message, then the attribute would be changed like this.
 1: [Required(ErrorMessageResourceName = "LogOnModel_UserName_Required", 
   2:           ErrorMessageResourceType = typeof(Resources.Global))]
   3: [Display(Name = "LogOnModel_UserName_Required", 
   4:          ResourceType = typeof(Resources.Global))]
   5: public string UserName { get; set; }

You might noticed that I changed the DisplayName attribute to Display attribute. This is because the DisplayName attribute does not support localization. Let's execute and see what's happen.

Oops! I think we are running into a lot of problems.

  • The localization doesn’t work. You can see on the URL it’s in Chinese version but the user name displayed in English.
  • The display name doesn’t work. In my model attributes I specified its resource name but now it shown the property’s name.
  • The error message was not localized.

Let’s explain one by one.

For the first one, the localization didn’t work at all. That is because we implemented the localization by the action filter attribute we created before. But the validation logic was performed by the default model binder which was invoked before the action filter attribute was performed. So when the model binder failed the validation and attempted to retrieve the error message from the resource files the culture of the current thread has not been changed by the action filter.

In order to make the localization (culture setting) being invokde before the model binder was executed we should move the localization logic to the Controller.ExecuteCode method, which is earlier than the model binder and validation. So I created a new class named BaseController and let it inherited from the abstract Controller class and then overrided the ExecuteCode method with the localization logic. Then I updated all controllers in the application to inherit from this BaseController.

  1: using System;
   2: using System.Collections.Generic;
   3: using System.Linq;
   4: using System.Text;
   5: using System.Web.Mvc;
   6: using System.Threading;
   7: using System.Globalization;
   8: using System.Web;
   9:  
  10: namespace ShaunXu.MvcLocalization.Controllers
  11: {
  12:     public abstract class BaseController : Controller
  13:     {
  14:         protected override void ExecuteCore()
  15:         {
  16:             if (RouteData.Values["lang"] != null &&
  17:                 !string.IsNullOrWhiteSpace(RouteData.Values["lang"].ToString()))
  18:             {
  19:                 // set the culture from the route data (url)
  20:                 var lang = RouteData.Values["lang"].ToString();
  21:                 Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(lang);
  22:             }
  23:             else
  24:             {
  25:                 // load the culture info from the cookie
  26:                 var cookie = HttpContext.Request.Cookies["ShaunXu.MvcLocalization.CurrentUICulture"];
  27:                 var langHeader = string.Empty;
  28:                 if (cookie != null)
  29:                 {
  30:                     // set the culture by the cookie content
  31:                     langHeader = cookie.Value;
  32:                     Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(langHeader);
  33:                 }
  34:                 else
  35:                 {
  36:                     // set the culture by the location if not speicified
  37:                     langHeader = HttpContext.Request.UserLanguages[0];
  38:                     Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(langHeader);
  39:                 }
  40:                 // set the lang value into route data
  41:                 RouteData.Values["lang"] = langHeader;
  42:             }
  43:  
  44:             // save the location into cookie
  45:             HttpCookie _cookie = new HttpCookie("ShaunXu.MvcLocalization.CurrentUICulture", Thread.CurrentThread.CurrentUICulture.Name);
  46:             _cookie.Expires = DateTime.Now.AddYears(1);
  47:             HttpContext.Response.SetCookie(_cookie);
  48:  
  49:             base.ExecuteCore();
  50:         }
  51:     }
  52: }

For the second and third problem that is because the DisplayName attributes was defined on .NET 4.0 platform but currently the ASP.NET MVC runtime was built on .NET 3.5 which cannot invoke the assemblies under .NET 4.0.

If you downloaded the source code of the ASP.NET MVC 2 there is a solution named MvcFuturesAspNet4 which will create some assemblies built on .NET 4.0 and support the DisplayName attribute. Here I would like to use another approch to work around it.
 1: using System;
   2: using System.Collections.Generic;
   3: using System.Linq;
   4: using System.Text;
   5: using System.ComponentModel;
   6: using System.ComponentModel.DataAnnotations;
   7:  
   8: namespace ShaunXu.MvcLocalization.Models
   9: {
  10:     public class LocalizationDisplayNameAttribute : DisplayNameAttribute
  11:     {
  12:         private DisplayAttribute display;
  13:  
  14:         public LocalizationDisplayNameAttribute(string resourceName, Type resourceType)
  15:         {
  16:             this.display = new DisplayAttribute()
  17:             {
  18:                 ResourceType = resourceType,
  19:                 Name = resourceName
  20:             };
  21:         }
  22:  
  23:         public override string DisplayName
  24:         {
  25:             get
  26:             {
  27:                 return display.GetName();
  28:             }
  29:         }
  30:     }
  31: }

I created another attribute which wapped the DisplayName one. It will create an inner instance of the DisplayAttribute and set the resource key and type accordingly. Then when the DisplayName was invoked it will perform the DisplayAttribute.GetName method which supports localization.

So the view model part should be changed like this below.
  1: [Required(ErrorMessageResourceName = "LogOnModel_UserName_Required",
   2:           ErrorMessageResourceType = typeof(Resources.Global))]
   3: [LocalizationDisplayName("LogOnModel_UserName_Required", 
   4:                          typeof(Resources.Global))]
   5: public string UserName { get; set; }

And the let's take a look.

Summary

In this post I explained about how to implement the localization on an ASP.NET MVC web application. I utilized the resource files as the container of the localization information which provided by the ASP.NET runtime. And I also explain on how to update our solution while the project was being grown and separated which more usefule when we need to implement in the real projects.

The localization information can be stored in any places. In this post I just use the resource files which I can use the ASP.NET localization support classes. But we can store them into some external XML files, database and web services. The key point is to separate the content from the usage. We can isolate the resource provider and create the relevant interface to make it changable and testable.

PS: You can download the source code of the example here

Hope this helps,

Shaun

This article is part of the GWB Archives. Original Author: Shaun Xu

Related Posts