MVC 2: Complete Localization Guide

Original author: Alex Adamyan
  • Transfer
imageIn this article, we will consider all aspects of localizing a web application based on ASP.NET MVC. I am using the latest available MVC 2 RC 2 version at the time of writing this topic.

Before we begin, I would like to thank the MVC team, great work guys, I enjoy the process of writing web applications when I use this framework. I was looking for a framework of this type, after a little experience with Ruby on Rails.

We will consider the following problems:
  1. View validation
  2. Simple crop switching mechanism
  3. Localization of model validation messages
  4. Localization of the DisplayName attribute
  5. Cache and localization
To work, you will need Visual Studio 2008 Express and ASP.NET MVC 2 RC2, as well as create a new MVC 2 web project.

View localization


For localized text, of course, resource files will be used, but we will not use the standard asp.net folders for storage.

I propose the following directory structure:

image

Views - resource files for aspx pages. Models - resource files for localizing view models.

The Views directory contains subfolders for each controller in which the resource files are located (the number of files depends on the set of supported languages).

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

Resource files


A few words about the rules for naming resource files. Resource files have the following name format:
[RESOURCE-NAME]. [CULTURE] .resx

RESOURCE-NAME - file name. It can be absolutely anything. It is used to group when we have several resource files with the same resource-name - they make up a single resource with different cultures specified in CULTURE.

CULTURE - indicator of the resource file culture. There are two types of cultures: neutral and accurate . Neutral cultures consist only of the language code ( en , ru , de , etc.). Exact cultures consist of a language code and a region code ( en-US , en-UK )

There is also a special meaning for resource files that do not have a culture defined; they are called “default” or “fall-back” cultures. As you can guess by name, they are used for resource files if the text was not found in a specific culture resource file or when there is no file for a given culture. I highly recommend that you use basic resource files, especially if the user has the ability to somehow install an unsupported culture.

Some examples of resource files:

MyStrings.en-US.resx - English United States

MyStrings.en-UK.resx - English United Kingdom

MyStrings.en.resx - neutral English (default for English)

MyStrings.ru.resx - neutral Russian

MyStrings.resx is the base resource file.

Great, now we are ready to localize something and see how it works. I will show you a small example of the header localization in the created web application. In the example, I will use two languages: English (default) and neutral Russian, but you can use any other languages.

First of all, create a folder structure, as I described above, we will need resource files for the parent page of Site.Master. I will create the Shared directory in Resources \ Views and add two resource files:

SharedStrings.resx - the base resource file with data in English.

SharedStrings.ru.resx - a basic resource file with data in Russian.

Add and fill the “Title” property in both files.

image

Important! Verify that the access modifiers for each resource file are set to public . Also check that the resource tool has a Custom Tool property value of PublicResXFileCodeGenerator . Otherwise, the resource files will not be compiled and available.

image

image

A few words about the resource file namespace. Files created in this way will have a namespace of the following form:

[PROJECT-NAME] .Resources.Views.Shared

To improve readability and compliance with naming rules, I changed the properties of the Custom Tool Namespace resource files to ViewRes (for view resource files).

It's time to make changes to the Site.Master page.



My MVC Application


change to


<%=ViewRes.SharedStrings.Title%>




Launch the application and make sure that everything works and you see the header in the right place (now it should be read from the resource file). If everything works, let's move on to demonstrating how to change culture.

To change the culture, we need to change the CurrentCulture and CurrentUICulture properties of the CurrentThread object for each request !!! To do this, we will place the code that changes the culture in the Application_AcquireRequestState Global.asax method (this method is an event handler and is called for each request)

Add the following code to the Global.asax.cs file:
protected void Application_AcquireRequestState(object sender, EventArgs e)
{
  //Create culture info object
  CultureInfo ci = new CultureInfo("en");

  System.Threading.Thread.CurrentThread.CurrentUICulture = ci;
  System.Threading.Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(ci.Name);
}

Launch the application again and make sure that it works. Next, we change the string parameter of the CulturInfo constructor (in my case it will be “ru”) and restart the project. You should have the following:

image

image

That's it. We localized the Site.Master header and you can do this with any text.

Simple crop switching mechanism


In the previous chapter, we successfully localized the application title, but there was no way to change the culture in real time. Now we are going to create some kind of mechanism that will help us to control cultural settings in real time.

As the repository for the culture selected by the user, we will use the session object. And to change the culture, we will place links for each language on the master page. Clicking on the links will cause some action in the Account controller, which changes the session value to the appropriate culture.

Add the following code to the AccountController class:
public ActionResult ChangeCulture(string lang, string returnUrl)
{
    Session["Culture"] = new CultureInfo(lang);
    return Redirect(returnUrl);
}

We now have an action method with two parameters, the first is the culture code, the second is the address for redirecting the user back to the original page. You just need to set a new culture for the session collection, but do not forget to add a check of the received user data to prevent the installation of an unsupported culture.

Now create a simple user control that contains cultural references. Add a new partial view to the Views \ Shared directory, name the file CultureChooserUserControl.ascx and paste the following into it:
<%= 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)%>

So, we created two links, the first for English, and the second for Russian. The time has come to place this control on the Site.Mater page. I will add it in, next to the login form.

Locate and replace in code
as follows:

<% Html.RenderPartial("LogOnUserControl"); %>
<% Html.RenderPartial("CultureChooserUserControl"); %>

What else is left to do? And the most important thing remains, we place the object of information about the culture in the session, but do not use it anywhere. We again make changes to the Global.asax.cs file in the Application_AcquireRequestState method:
protected void Application_AcquireRequestState(object sender, EventArgs e)
{
    //Очень важно проверять готовность объекта сессии
    if (HttpContext.Current.Session != null)
    {
      CultureInfo ci = (CultureInfo)this.Session["Culture"];
      //Вначале проверяем, что в сессии нет значения
      //и устанавливаем значение по умолчанию
      //это происходит при первом запросе пользователя
      if (ci == null)
      {
        //Устанавливает значение по умолчанию - базовый английский
        string langName = "en";
        //Пытаемся получить значения с HTTP заголовка
        if (HttpContext.Current.Request.UserLanguages != null && HttpContext.Current.Request.UserLanguages.Length != 0)
        {
          //Получаем список 
          langName = HttpContext.Current.Request.UserLanguages[0].Substring(0, 2);
        }
        ci = new CultureInfo(langName);
        this.Session["Culture"] = ci;
      }
      //Устанавливаем культуру для каждого запроса
      Thread.CurrentThread.CurrentUICulture = ci;
      Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(ci.Name);
    }
}

Launching the application, we get the following page, clicking on the links will overload the page with the selected culture:

image

Localization of model validation messages


I found an excellent solution to this problem, published by Phil Haack, but since this article should be a complete guide, I cannot but touch on this issue, and there are also certain ambiguities that I would like to clarify. But first of all, I recommend reading Phil Haack's post.

I will explain how to localize the validation messages of the Account model, especially for the RegistrationModel. I also want to describe how to localize the Membership validation messages that are written directly in the AccountController code.

So, let's create ValidationStrings.resx and ValidationStrings.ru.resx in the Resources \ Models \ Account folder(make sure access modifiers are public). As you may have guessed, we will store validation messages in these files.

I created the following properties in both files (English example):

image

We must change our models as follows (RegisterModel example):
[PropertiesMustMatch("Password", "ConfirmPassword",
ErrorMessageResourceName = "PasswordsMustMatch",
ErrorMessageResourceType = typeof(ValidationStrings))]
   public class RegisterModel
   {
     [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ValidationStrings))]
     [DisplayName("Username")]
     public string UserName { get; set; }
     [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ValidationStrings))]
     [DataType(DataType.EmailAddress)]
     [DisplayName("Email")]
     
     public string Email { get; set; }
     [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ValidationStrings))]
     [ValidatePasswordLength(ErrorMessageResourceName = "PasswordMinLength",ErrorMessageResourceType = typeof(ValidationStrings))]
     [DataType(DataType.Password)]
     [DisplayName("Password")]
     public string Password { get; set; }
 
     [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ValidationStrings))]
     [DataType(DataType.Password)]
     [DisplayName("Confirm password")]
     public string ConfirmPassword { get; set; }
}

We will add the ErrorMessageResourceName and ErrorMessageResourceType properties to the Required , PropertiesMustMatch and ValidatePasswordLength attributes , where ErrorMessageResourceType is the type of the resource class in which the messages are stored and ErrorMessageResourceName is the name of the property. Unfortunately, there is no strictly typed version of reading a property, so make sure that these magic lines have the correct values.

We are almost there, the little detail remains. We have two of our own validation attributes: PropertiesMustMatchAttribute and ValidatePasswordLenghtAttributein which we must change CultureInfo.CurrentUICulture in the FormatErrorMessage method to CultureInfo.CurrentCulture , otherwise in our case nothing will work.

We launch the application, go to the registration page, select the language for culture modification and get approximately the following when sending an empty form:

image

Oh, as you noticed, we forgot to localize the property names in the presentation models and got a little mess. To do this, we need to localize the values ​​of the DisplayName attribute, but it is not as simple as it seems at first glance. I will talk about this in the next chapter, and now let's finish the rest of the detail. This is the localization of the validation messages of the Membership API.

Open the AccountController and move to the end of the file, there should be the ErrorCodeToString method, which creates an error message if user registration fails. All messages are hardcoded in the code. All we need to do is create the appropriate properties for each message in the ValidationStrings resource file already created and put them there instead of the text in the ErrorCodeToString method.

That's all with the validation model. It's time for DisplayName!

Localization of the DisplayName attribute


As we noted in the previous chapter, the DisplayName value is involved in validation messages that use parameters for formatting. Another reason to think about the DisplayName attribute is the text field labels in the HTML form, they are created with the participation of the DisplayName value.

The real problem is that DisplayName does not support localization, there is no way to associate it with a resource file, from where it will take the value.

This means that we need to extend the DisplayNameAttribute and rewrite the DisplayName property so that it returns a localized name. I created an inherited class and called it LocalizedDisplayName.
public class LocalizedDisplayNameAttribute : DisplayNameAttribute
{
   private PropertyInfo _nameProperty;
   private Type _resourceType;
 
   public LocalizedDisplayNameAttribute(string displayNameKey)
     : base(displayNameKey)
   {
 
   }
 
   public Type NameResourceType
   {
     get
     {
       return _resourceType;
     }
     set
     {
       _resourceType = value;
       //инициализация nameProperty, когда тип свойства устанавливается set'ром
       _nameProperty = _resourceType.GetProperty(base.DisplayName, BindingFlags.Static | BindingFlags.Public);
     }
   }
 
   public override string DisplayName
   {
    get
    {
       //проверяет,nameProperty null и возвращает исходный значения отображаемого имени
       if (_nameProperty == null)
       {
         return base.DisplayName;
       }
 
       return (string)_nameProperty.GetValue(_nameProperty.DeclaringType, null);
     }
   } 
}

An important detail is the understanding that we need to read the value of the property every time it is requested, therefore the GetValue method is called in the get'er of the DisplayName property, and not in the constructor.

To store the display names, I created the Names.resx and Names.ru.resx resource files in the Resources \ Models \ Account folder and created the following properties.

image

Now we need to replace the DisplayName attribute with LocalizedDisplayName and provide the type of the resource class. The modified RegisterModel code will look like this:
[PropertiesMustMatch("Password", "ConfirmPassword",
ErrorMessageResourceName = "PasswordsMustMatch",
ErrorMessageResourceType = typeof(ValidationStrings))]
   public class RegisterModel
   {
     [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ValidationStrings))]
     [LocalizedDisplayName("RegUsername", NameResourceType = typeof(Names))]
     public string UserName { get; set; }
 
     [Required(ErrorMessageResourceName = "Required",ErrorMessageResourceType = typeof(ValidationStrings))]
     [DataType(DataType.EmailAddress)]
     [LocalizedDisplayName("RegEmail", NameResourceType = typeof(Names))]
     public string Email { get; set; }

     [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ValidationStrings))]
     [ValidatePasswordLength(ErrorMessageResourceName = "PasswordMinLength",
ErrorMessageResourceType = typeof(ValidationStrings))]
     [DataType(DataType.Password)]
     [LocalizedDisplayName("RegPassword", NameResourceType = typeof(Names))]
     public string Password { get; set; }
 
     [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ValidationStrings))]
     [DataType(DataType.Password)]
     [LocalizedDisplayName("RegConfirmPassword", NameResourceType = typeof(Names))]
     public string ConfirmPassword { get; set; }

   }
* This source code was highlighted with Source Code Highlighter.

Run the application and make sure that it works, it should look like this:

image

Cache and localization


Strange chapter, right? Do you think how to combine caching and localization? Ok, let's imagine the following scenario: open the HomeController and add the OutputCache attribute to the Index action method:
[OutputCache(Duration=3600, VaryByParam="none")]
 public ActionResult Index()
 {
   ViewData["Message"] = "Welcome to ASP.NET MVC!";

   return View();
 }

We launch the application and try to change the language on the Index page to check that the header is still localized.

Damn it! You probably thought that caching and localization could not be used together? Don’t worry, there is a solution to this problem :)

What do you know about OutputCache, or rather, what do you know about the VaryByCustom property? It's time to use it.

When we request the Index page for the first time, OutputCache caches the page. At the second request (when we click on the language selection link), OutputCache thinks that nothing has changed and returns the result from the cache, therefore the page is not re-created. That is why the choice of language did not work. To solve this problem, we need to somehow tell OutputCache that the page version has changed (as in the case when the action receives a specific parameter and we pass it to the VaryByParam properties).

VaryByCustom is an ideal candidate for our solution to the problem and there is a special method for the System.Web.HttpApplication class in the Global.asax.cs file. We will rewrite the standard implementation of this method:
public override string GetVaryByCustomString(HttpContext context, string value)
 {
    if (value.Equals("lang"))
    {
      return Thread.CurrentThread.CurrentUICulture.Name;
    }
    return base.GetVaryByCustomString(context,value);
 }

First, the method checks if the parameter value matches “lang” (no special value, a simple string that is used as the value of VaryByCustom) and, if so, returns the name of the current culture. Otherwise, returns the value of the standard implementation.

Now add the VaryByCustom property with the value “lang” to each OutputCache attribute that you want to use localization and that’s all. The updated Index action method is as follows:

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

Try running the application again and enjoy working crop switching.

We have finished the last chapter and I hope I have not missed anything.

Also popular now: