In this article we are going to explore all aspects of localization web application based on ASP.NET MVC framework. The version I'll be using for that purpose will be 2 RC 2 which is last available at the time of writing.

NOTE 01.09.2010: In this article Session is used for storing current culture, please also as an addition consider reading my next post about localization where routing mechanism used for that purpose(better SEO and simpler implementation). Also you will find there link to the source code.

Before we start I would like to thank the MVC framework team, great job guys, I really like it :) I really enjoy writing web application with the framework.  I was searching for such kind a framework after small experience with Ruby on Rails

OK, lets see what issues we'll cover
  1. Views validation
  2. Simple culture switching mechanism
  3. Model Validation messages localization
  4. DisplayName attribute localization
  5. OutputCache and Localization
For this guide you'll need Visual Studio 2008 Express and ASP.NET MVC 2 RC2 installed. To follow instructions of the guide please create new MVC 2 web project.

Views localization


For localized strings, of course, we'll be using resource files, but we'll not use asp.net standard folders for storing them.

Here is folder structure I suggest

Resources folder structure

Views - resource files for views aspx pages. Models - resource files for view models localization.

Views folder contains sub folders for each controller and each folder will contain resource files(as much as many languages we'll support)

Models contains sub folders for each group of view models. For generated Account models (LogOn, Register, ChangePassword) we have Account folder and resource files for each language-culture.


Resource files


Some word about resource files naming convention. Resource files have following format
[RESOURCE-NAME].[CULTURE].resx

RESOURCE-NAME - the name of file. Can be anything you like. It's used for grouping, so when there are several resource files with same resource-name they construct one resource with different cultures described by CULTURE

CULTURE - word indicating culture of resource file. Cultures are two type: neutral(also called invariant) and concrete. Neutral culture consists from language code only(examples: en, ru, de etc), Concrete culture consists from language code and region code(examples: en-US, en-UK etc).

Also there is special meaning for resource files that haven't any culture specified, they are called default or fall-back. As you can guess from it's name they are used as default resource files if string was not found in specified culture's resource file or even when there is no resource file for specified culture. I strongly encourage you to use default resource file if user somehow can change to unsupported culture.

Some example of resource files:

MyStrings.en-US.resx -English US

MyStrings.en-UK.resx - English UK

MyStrings.en.resx - English neutral (this is also fall back for English)

MyStrings.ru.resx - Russian neutral

MyStrings.resx - Fall back resource file

OK, now we are ready to localize something and look how it works. I'll show you small example how to localize title of the created web application. Throughout the tutorial I'll use two languages: English(as default) and Russian neutral, but you are free to use any languages you wish.

First of all create folder structure I described above for resource files, and particularly we'll need resource files for Site.Master master page. I create folder Shared under Resources\Views and create two resource files

SharedStrings.resx - Default resource file with English values

SharedStrings.ru.resx - resource file with Russian values

Add property "Title" in both files and fill values.


Title property in resource file

Important! Make sure to change access modifier of each resource file you create to public. Also check that resource files Custom Tool property value is "PublicResXFileCodeGenerator". Otherwise resource files won't be compiled and won't be accessible.





Custom tool property

Some words about resource files namespace. Created this way resource file will have namespace:

[PROJECT-NAME].Resources.Views.Shared

To make it more readable and convenient I changed Custom Tool Namespace properties of resource files to ViewRes (for views resource files)

Now it's time to make modifications in Site.Master page.

Locate following HTML code snippet


<div id="title">
<h1>My MVC Application</h1>
</div>
and replace with
<div id="title">
<h1><%=ViewRes.SharedStrings.Title%></h1>
</div>


Run application and make sure everything is still working, and you can see title in its place(now it should be read from resource file). If everything is OK, it's time to somehow change culture and check whether that will work.

To change culture we need to change CurrentCulture and CurrentUICulture properties of CurrentThread for every request!!! To do this we need to place culture changing code to Global.asax Application_AcquireRequestState method(this method is event handler and is called for every request).

Add following code to Global.asax.cs file




   1:  protected void Application_AcquireRequestState(object sender, EventArgs e)
   2:  {
   3:    //Create culture info object 
   4:    CultureInfo ci = new CultureInfo("en");
   5:  
   6:    System.Threading.Thread.CurrentThread.CurrentUICulture = ci;
   7:    System.Threading.Thread.CurrentThread.CurrentCulture = 
CultureInfo.CreateSpecificCulture(ci.Name);
   8:  }

Run application to check that everything is working. Than change string parameter of CulturInfo constructor (in my case this will be "ru") and run again. You should get following results for both cases

Master page localized Title


Master page localized title


That's all. We have localized Site.Master's title and you can do the same with any string you need.


Simple culture switching mechanism



In the previous chapter we successfully localized title of the application, but there wasn't any chance to change the culture at the runtime. Now we are going to create some mechanism which can help us to control culture setting at the runtime.

As a place for storing users selected culture we'll use session object. And for changing culture we'll place links for each language on the master page. Clicking links will call some action in Account controller which will change session's value corresponding to culture.

Add following code to AccountController class

   1:  public ActionResult ChangeCulture(string lang, string returnUrl)
   2:  {
   3:       Session["Culture"] = new CultureInfo(lang);
   4:       return Redirect(returnUrl);
   5:  }

We have here action method with two parameters, first one is for culture code, second is for redirecting back user to original page. There is no much to do in this action, it's just setting new culture to session dictionary, but remember to add some user input validation here to prevent setting unsupported culture code.

Now we'll create simple user control with supported culture hyper links. Add new partial view to Views\Shared folder CultureChooserUserControl.ascx and paste following

<%= Html.ActionLink("English", "ChangeCulture", "Account",  
     new { lang = "en", returnUrl = this.Request.RawUrl }, null)%>
<%= Html.ActionLink("Русский", "ChangeCulture", "Account",  
     new { lang = "ru", returnUrl = this.Request.RawUrl }, null)%>

We just now created two hyper links, first one for English and second one for Russian languages. And now its time to place this culture chooser user control to Site.Master master page. I'll add this to <div> corresponding to login functionality just as an example.

Find and replace <div id="logindisplay"> with following

<div id="logindisplay">
<% Html.RenderPartial("LogOnUserControl"); %>
<% Html.RenderPartial("CultureChooserUserControl"); %>
</div>

What else? Right, the most important is left, we put culture info object to session, but we never use it. It's time to make some changes in Global.asax.cs, and again this is Application_AcquireRequestState method.

   1:  protected void Application_AcquireRequestState(object sender, EventArgs e)
   2:  {
   3:       //It's important to check whether session object is ready
   4:       if (HttpContext.Current.Session != null)
   5:       {
   6:           CultureInfo ci = (CultureInfo)this.Session["Culture"];
   7:           //Checking first if there is no value in session 
   8:           //and set default language 
   9:           //this can happen for first user's request
  10:           if (ci == null)
  11:           {
  12:               //Sets default culture to english invariant
  13:               string langName = "en";
  14:  
  15:               //Try to get values from Accept lang HTTP header
  16:               if (HttpContext.Current.Request.UserLanguages != null && 
HttpContext.Current.Request.UserLanguages.Length != 0)
  17:               {
  18:                   //Gets accepted list 
  19:                   langName = HttpContext.Current.Request.UserLanguages[0].Substring(0, 2);
  20:               }
  21:               ci = new CultureInfo(langName);
  22:               this.Session["Culture"] = ci;
  23:           }
  24:           //Finally setting culture for each request
  25:           Thread.CurrentThread.CurrentUICulture = ci;
  26:           Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(ci.Name);
  27:       }
  28:  }


Running this will result with following page, and clicking language links will reload page with selected culture

Culture Chooser



Model Validation Messages Localization



There is already good solution I found recently available on the net posted by Phil Haack, but as this should be full guide, I can't leave that aspect untouched and because there are some misunderstandings which I want to clarify. But before you begin I strongly recommend you to read Phil Haack's post.

I'm going to explain how to localize Account models validation messages, and particularly for RegistrationModel. Also I want to describe how to localize Membership validation messages which are hard coded in AccountController.

OK, lets create ValidationStrings.resx and ValidationStrings.ru.resx in Resources\Models\Account folder(make sure you set access modifier to public). As you can guess we'll be storing all validation messages in that files.

I created following properties in both resource files(English example)

Validation messages

We need to modify our models in following way(example of RegisterModel)


   1:  [PropertiesMustMatch("Password", "ConfirmPassword"
ErrorMessageResourceName = "PasswordsMustMatch"
ErrorMessageResourceType = typeof(ValidationStrings))]
   2:      public class RegisterModel
   3:      {
   4:          [Required(ErrorMessageResourceName = "Required"
ErrorMessageResourceType = typeof(ValidationStrings))]
   5:          [DisplayName("Username")]
   6:          public string UserName { get; set; }
   7:  
   8:          [Required(ErrorMessageResourceName = "Required"
ErrorMessageResourceType = typeof(ValidationStrings))]
   9:          [DataType(DataType.EmailAddress)]
  10:          [DisplayName("Email")]
  11:          public string Email { get; set; }
  12:  
  13:          [Required(ErrorMessageResourceName = "Required"
ErrorMessageResourceType = typeof(ValidationStrings))]
  14:          [ValidatePasswordLength(ErrorMessageResourceName = "PasswordMinLength"
ErrorMessageResourceType = typeof(ValidationStrings))]
  15:          [DataType(DataType.Password)]
  16:          [DisplayName("Password")]
  17:          public string Password { get; set; }
  18:  
  19:          [Required(ErrorMessageResourceName = "Required"
ErrorMessageResourceType = typeof(ValidationStrings))]
  20:          [DataType(DataType.Password)]
  21:          [DisplayName("Confirm password")]
  22:          public string ConfirmPassword { get; set; }
  23:      }

We add ErrorMessageResourceName and ErrorMessageResourceType properties to Required, PropertiesMustMatch and ValidatePasswordLength attributes, where ErrorMessageResourceType is type of resource class where messages are stored and ErrorMessageResourceName is property name. Unfortunately there is no way to provide strongly typed mechanism for reading that values, so be sure that these magic string have right values.

We are almost done, just one little thing. There are two custom validation attributes PropertiesMustMatchAttribute and ValidatePasswordLenghtAttribute in which we should change CultureInfo.CurrentUICulture in FormatErrorMessage method to CultureInfo.CurrentCulture otherwise this will not work for our configuration.

OK, now run application, go to registration page, click language link to change culture and you should get something like this when try to submit empty form

Registration page validation


Oops, as you can notice we forgot to localize names of properties in view models and now it's mixed language. To do so we need to localize DisplayName attribute value, but it's not so simple as it seems to be. I'm going to cover this issue in the next chapter, and now we have some little thing left. It's Membership API validation messages localization.

Open AccountController and scroll down to the end, there should be method ErrorCodeToString which create error messages in case when user registration was failed. All messages are hard coded. All we need to do is create appropriate properties for each one in already created ValidationStrings resource files and put them instead of strings in ErrorCodeToString method.

That's all with model validation. Now it's time for DisplayName!



DisplayName Attribute localization


As we see in the previous chapter DisplayName value participates in validation messages which are using parameters for formatting. Also one more reason to think about DisplayName attribute is labels of fields in HTML form, these are created using value of DisplayName.

The real problem is that DisplayName doesn't support localization, there is no way to provide resource file from which it can read its value.

This both mean that we need to extend DisplayNameAttribute and override DisplayName property which will always return localized name. I created such derived class and named it LocalizedDisplayName


   1:  public class LocalizedDisplayNameAttribute : DisplayNameAttribute
   2:  {
   3:     private PropertyInfo _nameProperty;
   4:     private Type _resourceType;
   5:  
   6:     public LocalizedDisplayNameAttribute(string displayNameKey)
   7:         : base(displayNameKey)
   8:     {
   9:  
  10:     }
  11:  
  12:     public Type NameResourceType
  13:     {
  14:         get
  15:         {
  16:             return _resourceType;
  17:         }
  18:         set
  19:         {
  20:             _resourceType = value;
  21:             //initialize nameProperty when type property is provided by setter
  22:             _nameProperty = _resourceType.GetProperty(base.DisplayName, 
BindingFlags.Static | BindingFlags.Public);
  23:         }
  24:     }
  25:  
  26:     public override string DisplayName
  27:     {
  28:        get
  29:        {
  30:             //check if nameProperty is null and return original display name value
  31:             if (_nameProperty == null)
  32:             {
  33:                 return base.DisplayName;
  34:             }
  35:  
  36:             return (string)_nameProperty.GetValue(_nameProperty.DeclaringType, null);
  37:         }
  38:      }
  39:  
  40:  }


Important thing here to understand is that we need to read property value every time it's called, that's why GetValue method called in the getter of DisplayName property, and not in the constructor.

For storing display names I created Names.resx and Names.ru.resx resource files under Resources\Models\Account folder and create following properties

Display names localized

Now we need to change DisplayName attribute to LocalizedDisplayName and provide resource class type. The modified RegisterModel code will look like this


   1:  [PropertiesMustMatch("Password", "ConfirmPassword"
ErrorMessageResourceName = "PasswordsMustMatch"
ErrorMessageResourceType = typeof(ValidationStrings))]
   2:      public class RegisterModel
   3:      {
   4:          [Required(ErrorMessageResourceName = "Required"
ErrorMessageResourceType = typeof(ValidationStrings))]
   5:          [LocalizedDisplayName("RegUsername", NameResourceType = typeof(Names))]
   6:          public string UserName { get; set; }
   7:  
   8:          [Required(ErrorMessageResourceName = "Required"
ErrorMessageResourceType = typeof(ValidationStrings))]
   9:          [DataType(DataType.EmailAddress)]
  10:          [LocalizedDisplayName("RegEmail", NameResourceType = typeof(Names))]
  11:          public string Email { get; set; }
  12:  
  13:          [Required(ErrorMessageResourceName = "Required"
ErrorMessageResourceType = typeof(ValidationStrings))]
  14:          [ValidatePasswordLength(ErrorMessageResourceName = "PasswordMinLength"
ErrorMessageResourceType = typeof(ValidationStrings))]
  15:          [DataType(DataType.Password)]
  16:          [LocalizedDisplayName("RegPassword", NameResourceType = typeof(Names))]
  17:          public string Password { get; set; }
  18:  
  19:          [Required(ErrorMessageResourceName = "Required"
ErrorMessageResourceType = typeof(ValidationStrings))]
  20:          [DataType(DataType.Password)]
  21:          [LocalizedDisplayName("RegConfirmPassword", NameResourceType = typeof(Names))]
  22:          public string ConfirmPassword { get; set; }
  23:      }


Run application to make sure that everything work as expected, for me this will be like

DisplayNames Fixed Registration



OutputCache and Localization



What a strange chapter? You think how can caching and localization be connected? OK, lets try following scenario: open HomeController and add OutputCache attribute to Index action method, so that action code will look like following:


   1:  [OutputCache(Duration=3600, VaryByParam="none")]
   2:  public ActionResult Index()
   3:  {
   4:      ViewData["Message"] = "Welcome to ASP.NET MVC!";
   5:  
   6:      return View();
   7:  }


Now build application and try to change language in Index page to check that localized Title is still localized.

Oh no? You already think that these two things can't be used together? Don't hurry, there is a solution :)

What do you know about OutputCache and particularly what do you know about VaryByCustom property? Now it's time to use it.

What's happening here? When we request Index page for first time OutputCache caches the page. For the second request (when we click language link)  OutputCache thinks that nothing was changed and returns result from cache, so the page is not created again. That's why language chooser doesn't work. To solve the problem we need somehow say to OutputCache that page version was changed(like as it's working in case if action has some param and we put that param to VaryByParam property).

VaryByCustom is ideal candidate for that purpose and there is special method in System.Web.HttpApplication which derived class placed in Global.asax.cs file. We'll override the default implementation of that method.


   1:  public override string GetVaryByCustomString(HttpContext context, string value)
   2:  {
   3:       if (value.Equals("lang"))
   4:       {
   5:           return Thread.CurrentThread.CurrentUICulture.Name;
   6:       }
   7:       return base.GetVaryByCustomString(context,value);
   8:  }


First the method checks if value param equals to "lang" (no special meaning, just string which will be used as value for VaryByCustom) and in case if they are equal returns name of current culture. Otherwise we return value of default implementation.

Now add VaryByCustom property with value "lang" to each OutputCache attribute you want to use together with localization and that's all. The updated Index action method will look like this


   1:  [OutputCache(Duration=3600,VaryByParam="none", VaryByCustom="lang")]
   2:  public ActionResult Index()
   3:  {
   4:       ViewData["Message"] = "Welcome to ASP.NET MVC!";
   5:       return View();
   6:  }

Try to run again and ensure that culture chooser is working again.

We've finished the last chapter and I hope I didn't miss anything. If you don't think so please let me know.

Thanks for reading, feel free to contact me if you have any question and make comments :)

kick it on DotNetKicks.com

Shout it
94

View comments

    Loading