ASP.NET MVC 3 Internationalization
Introduction
If your website targets users from different parts of the world, these users might like to see your website content in their own language. Creating a multilingual website is not an easy task, but it will certainly allow your site to reach more audience. Fortunately, the .NET Framework already has components that support different languages and cultures.
We will build an ASP.NET MVC 3 web application that contains the following features:
- It can display contents in different languages.
- It autodetects the language from the user's browser.
- It allows the user to override the language of their browser.
Globalization and Localization in ASP.NET
Internationalization involves Globalization and Localization. Globalization is the process of designing applications that support different cultures. Localization is the process of customizing an application for a given culture.
The format for the culture name is "<languagecode2>-<country/regioncode2>", where <languagecode2> is the language code and <country/regioncode2> is the subculture code. Examples include es-CL for Spanish (Chile) and en-US for English (United States).
ASP.NET keeps track of two culture values, the Culture and UICulture. The culture value determines the results of culture-dependent functions, such as the date, number, and currency formatting. The UICulture determines which resources are to be loaded for the page by the ResourceManager. The ResourceManager simply looks up culture-specific resources that is determined by CurrentUICulture. Every thread in .NET has CurrentCulture and CurrentUICulture objects. So ASP.NET inspects these values when rendering culture-dependent functions. For example, if current thread's culture (CurrentCulture) is set to "en-US" (English, United States), DateTime.Now.ToLongDateString() shows "Saturday, January 08, 2011", but if CurrentCulture is set to "es-CL" (Spanish, Chile) the result will be "sábado, 08 de enero de 2011".
How to Support Different Languages in ASP.NET MVC 3
There are two ways to incorporate different languages and cultures in ASP.NET MVC 3:
- By using resource strings in all our site views. (See part 2)
- By using different set of views for every language and locale.
- By mixing between 1 and 2
Which one is the best?
It depends on you. It is a matter of convenience. Some people prefer to use a single view for all languages because it is more maintainable. While others think replacing views content with code like "@Resources.Something" might clutter the views and will become unreadable. Recall that views have to be as simple as possible. If your views look fine with a lot of inline code, it's fine. But sometimes you have no choice in languages where layout has to be different like right-to-left languages. Perhaps, a mix of the two is the best. Anyway, in this example we will use the 2nd approach just to show a different way than the usual resource strings.
Views Naming Conventions
In order to create different views for every culture, we will append the culture name to the name of the view. For example, Index.cshtml (the default view), Index.es-CL.cshtml (Spanish, Chile), Index.ar-JO.cshtml (Arabic, Jordan). The view name that has no ending is considered the default culture. A default culture view will be rendered if the requested culture name is not implemented explicitly.
Globalizing our Web Site
We will create a new ASP.NET MVC 3 web application and globalize it step by step.
Click "File->New Project" menu command within Visual Studio to create a new ASP.NET MVC 3 Project. We'll create a new project using the "Internet Application" template.
Creating the Model
We'll need a model to create our web application. Add a class named "User" to the "Models" folder:
Internationalizing Validation Messages
Our model presented above contains no validation logic, and this is not the case in normal applications nowadays. We can use data annotation attributes to add some validation logic to our model. However, in order to globalize validation messages, we need to specify a few extra parameters. The "ErrorMessageResourceType" indicates the type of resource to look up the error message. "ErrorMessageResourceName" indicates the resource name to lookup the error message. Resource manager will pick the correct resource file based on the current culture.
Now modify the "Person" class and add the following attributes:
{
public class Person
{
[Required(ErrorMessageResourceType=typeof(MyResources.Resources),
ErrorMessageResourceName="FirstNameRequired")]
[StringLength(50, ErrorMessageResourceType = typeof(MyResources.Resources),
ErrorMessageResourceName = "FirstNameLong")]
public string FirstName { get; set; }
[Required(ErrorMessageResourceType = typeof(MyResources.Resources),
ErrorMessageResourceName = "LastNameRequired")]
[StringLength(50, ErrorMessageResourceType = typeof(MyResources.Resources),
ErrorMessageResourceName = "LastNameLong")]
public string LastName { get; set; }
[Required(ErrorMessageResourceType = typeof(MyResources.Resources),
ErrorMessageResourceName = "AgeRequired")]
[Range(0, 130, ErrorMessageResourceType = typeof(MyResources.Resources),
ErrorMessageResourceName = "AgeRange")]
public int Age { get; set; }
[Required(ErrorMessageResourceType = typeof(MyResources.Resources),
ErrorMessageResourceName = "EmailRequired")]
[RegularExpression(".+@.+\\..+", ErrorMessageResourceType = typeof(MyResources.Resources),
ErrorMessageResourceName = "EmailInvalid")]
public string Email { get; set; }
public string Biography { get; set; }
}
}
Because we need to perform data validation on our model using Data Annotations, we will have to add translated resource strings for every culture our site will support. In this case, English, Spanish, and Arabic.
We will store resource files in a separate assembly, so we can reference them in other project types in the future.
Right click on the Solution and then choose the "Add->New Project" context menu command. Choose "Class Library" project type and name it "MyResources".
Now right click on "MyResources" project and then choose "Add->New Item" context menu command. Choose "Resource File" and name it "Resources.resx". This will be our default culture (en-US) since it has no special endings. Add the following names and values to the file like below:
Remember to mark the resource's access modifier property to "public", so it will be accessible from other projects.
Now create a new resource file and name it "Resources.es-CL.resx" and add the following names and values like below:
Now, do the same for the Arabic version. You may not be able to enter the correct strings by keyboard because your OS may not be configured to accept Arabic. However, you can download the files from the link at the top. Anyway, the resource file is included for reference:
We need to reference "MyResources" project from our web application, so that we can read the resource strings right from our web site. Right click on "References" under our web project "MvcInternationalization", and choose the "MyResources" project from Projects tab.
Determining Culture
How do we determine which version of a view to return to the end user?
How do we know which culture does the user want?
There is a header field called "Accept-Language" that the browser sends on every request. This field contains a list of culture names (language-country) that the user has configured in their browser. The problem is that this culture may not reflect the real user's preferred language, such as a computer in a cybercafé. We should allow the user to choose a language explicitly and allow them even to change it. In order to do this sort of things, we need to store the user's preferred language in a store, which can be perfectly a cookie.
We will create a base controller that inspects the cookie contents first, if there is no cookie, we will use the "Accept-Language" field sent by their browser. Create a controller and name it "BaseController" like below:
{
public class BaseController : Controller
{
protected override void OnActionExecuted(ActionExecutedContext filterContext)
{
// Is it View ?
ViewResultBase view = filterContext.Result as ViewResultBase;
if (view == null) // if not exit
return;
string cultureName = Thread.CurrentThread.CurrentCulture.Name; // e.g. "en-US" // filterContext.HttpContext.Request.UserLanguages[0]; // needs validation return "en-us" as default
// Is it default culture? exit
if (cultureName == CultureHelper.GetDefaultCulture())
return;
// Are views implemented separately for this culture? if not exit
bool viewImplemented = CultureHelper.IsViewSeparate(cultureName);
if (viewImplemented == false)
return;
string viewName = view.ViewName;
int i = 0;
if (string.IsNullOrEmpty(viewName))
viewName = filterContext.RouteData.Values["action"] + "." + cultureName; // Index.en-US
else if ((i = viewName.IndexOf('.')) > 0)
{
// contains . like "Index.cshtml"
viewName = viewName.Substring(0, i + 1) + cultureName + viewName.Substring(i);
}
else
viewName += "." + cultureName; // e.g. "Index" ==> "Index.en-Us"
view.ViewName = viewName;
filterContext.Controller.ViewBag._culture = "." + cultureName;
base.OnActionExecuted(filterContext);
}
protected override void ExecuteCore()
{
string cultureName = null;
// Attempt to read the culture cookie from Request
HttpCookie cultureCookie = Request.Cookies["_culture"];
if (cultureCookie != null)
cultureName = cultureCookie.Value;
else
cultureName = Request.UserLanguages[0]; // obtain it from HTTP header AcceptLanguages
// Validate culture name
cultureName = CultureHelper.GetValidCulture(cultureName); // This is safe
// Modify current thread's culture
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(cultureName);
Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(cultureName);
base.ExecuteCore();
}
}
}
The base controller checks if the cookie exists, and sets the current thread culture to that cookie value. Of course, because cookie content can be manipulated on the client side, we should always validate its value using a helper class called "CultureHelper". If the culture name is not valid, the helper class returns the default culture. After that, when we call "Return" in controller action methods, the view name is modified by the base controller behind the scenes to reflect the correct culture in cookie or header field. This way we make sure that the user gets the right content based on their culture.
CultureHelper Class
The CultureHelper is basically a utility that allows us to store culture names we are implementing in our site:
{
public static class CultureHelper
{
// Include ONLY cultures you are implementing as views
private static readonly Dictionary<String, bool> _cultures = new Dictionary<string,bool> {
{"en-US", true}, // first culture is the DEFAULT
{"es-CL", true},
{"ar-JO", true}
};
/// <summary>
/// Returns a valid culture name based on "name" parameter. If "name" is not valid, it returns the default culture "en-US"
/// </summary>
/// <param name="name">Culture's name (e.g. en-US)</param>
public static string GetValidCulture(string name)
{
if (string.IsNullOrEmpty(name))
return GetDefaultCulture(); // return Default culture
if (_cultures.ContainsKey(name))
return name;
// Find a close match. For example, if you have "en-US" defined and the user requests "en-GB",
// the function will return closes match that is "en-US" because at least the language is the same (ie English)
foreach(var c in _cultures.Keys)
if (c.StartsWith(name.Substring(0, 2)))
return c;
// else
return GetDefaultCulture(); // return Default culture as no match found
}
/// <summary>
/// Returns default culture name which is the first name decalared (e.g. en-US)
/// </summary>
/// <returns></returns>
public static string GetDefaultCulture()
{
return _cultures.Keys.ElementAt(0); // return Default culture
}
/// <summary>
/// Returns "true" if view is implemented separatley, and "false" if not.
/// For example, if "es-CL" is true, then separate views must exist e.g. Index.es-cl.cshtml, About.es-cl.cshtml
/// </summary>
/// <param name="name">Culture's name</param>
/// <returns></returns>
public static bool IsViewSeparate(string name)
{
return _cultures[name];
}
}
}
We should populate "_cultures" manually. The "_cultures" dictionary stores the list of culture names our site supports. The first parameter indicates culture name (e.g. en-US), the second parameter indicates whether we are implementing separate views for that culture. If the second parameter is false, the default view is used (ie the one that has no special ending).
The nice part of this utility class is that it serves similar languages. For example, if a user is visiting our site from Argentina (es-ar), a culture which is not implemented in our site, he or she will see our site in Spanish using "es-cl" (Spanish, Chile) instead of English language. This way, you don't have to implement all cultures unless you really care about currency, date format, etc.
Controllers
Visual Studio has created a controller named "HomeCotnroller" for us, so we'll use it for simplicity. Modify the "HomeController.cs" so that it looks like below:
{
public class HomeController : BaseController
{
[HttpGet]
public ActionResult Index()
{
return View();
}
[HttpPost]
public ActionResult Index(Person per)
{
return View();
}
public ActionResult SetCulture(string culture)
{
// Validate input
culture = CultureHelper.GetValidCulture(culture);
// Save culture in a cookie
HttpCookie cookie = Request.Cookies["_culture"];
if (cookie != null)
cookie.Value = culture; // update cookie value
else
{
cookie = new HttpCookie("_culture");
cookie.HttpOnly = false; // Not accessible by JS.
cookie.Value = culture;
cookie.Expires = DateTime.Now.AddYears(1);
}
Response.Cookies.Add(cookie);
return RedirectToAction("Index");
}
public ActionResult About()
{
return View();
}
}
}
The "SetCulture" action allows the user to change their current culture and stores it in a cookie called "_culture". We are not restricted to cookies, we could instead save the culture name in Session or elsewhere, but cookies are really lightweight since they do not take any type of space on server side.
Creating a View Template
Now we will implement the View associated with the HomeController's Index action. First delete the existing Index.cshtml file under "Views/Home" folder. Now to implement the view right-click within the "HomeController.Index()" method and select the "Add View" command to create a view template for our home page:
We will need to modify some of the settings above. Choose the option "Create a strongly-typed view" and choose the "Person" class we created before. Also, choose the "Create" menu item from the "Scaffold template" drop-down box.
When we click the "Add" button, a view template of our "Create" view (which renders the form) is created. Modify it so it looks like below:
The javascript code simply post back the form to set the culture. The "selected" helper is used to mark the appropriate culture radio button as checked.
Now make two copies of "Index.cshtml" and rename them to "Index.es-CL.cshtml" and "Index.ar-JO.cshtml". These latter views represent the localized versions of Index.cshtml for two different cultures, so we can add whatever is necessary inside them. Make them look like below:
Spanish view
Arabic view
Of course, for simple partial views like "_LogOnPartial.cshtml" and which are not referenced by controllers, we can use resource strings perfectly.
Arabic view
Try It Out
Run the website now. Notice that client side validation is working nicely. Click on radio buttons to switch between cultures, and notice how right-to-left language is showing correctly. Using separate views allowed us to control how to position elements, and have made our views clean and readable.
English
Spanish
Arabic
Client-Side localization
What about client-side scripts?
For client-side, we should worry mainly about numbers, date and time, and messages since these change from culture to culture. There are many ways to implement client-side localization. But here are two common options:
- Creating standalone localized javascript files for every culture and language.
- Creating a standard common javascript file for all cultures by sticking to Microsoft Ajax Library.
For (1), we follow the same convention for views and resource files. For example, for the file "myscript.js", you need to create "myscript.es-CL.js", "myscript.ar-JO.js", etc. We can reference the javascript files easily from our views by appending culture name to the javascript file :
The variable "_culture" is already defined in the base controller and works nicely by ignoring default culture (returns null in this case).
Even if you want to use Microsoft Ajax Library, you may still need separate javascript files that define text messages to the end user. You can define a literal object that contains the list of messages, or if you are using separate views for every culture, you can use inline javascript instead of separate javascript files.
Summary
Building a multilingual web application is not an easy task. but it's worth it especially for web applications targeting users from all over the world, something which many sites do. It is true that globalization is not the first priority in site development process, however, it should be well planned early in the stage of development so it can be easily implemented in the future. Luckily, ASP.NET supports globalization and there are plenty of .NET classes that are handy. We have seen how to create an ASP.NET MVC 3 application that supports 3 different languages, including a right-to-left one, which requires a different UI layout. Anyway, here is a summary of how to globalize a site in ASP.NET MVC 3:
- Add a base controller from which all controllers inherit. This controller will intercept the view names returned and will adjust them depending on the current culture set.
- Add a helper class that stores the list of culture names that the site will support.
- Create a single view or set of views for every culture and language.
- Create resource files that contain translation of all string messages. (e.g. Resources.resx, Resources.es-CL.resx, Resources.ar-JO.resx, etc )
- Localize javascript files.
I hope this help!
Any questions or comments are welcome!
Trackback: