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
- Views validation
- Simple culture switching mechanism
- Model Validation messages localization
- DisplayName attribute localization
- 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
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.
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.
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
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
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)
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
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
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
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 :)
Hi Alex,
ReplyDeleteThank you for the second article for this localization series.
This approach is much better than the first one. Because now the Search Engines can index the pages by language. And there is no need to do tricky things for output cache.
Thanks again
Hi Alex, Great work!
ReplyDeleteHow do you solve the problem with loginUrl in web.config?
ASP MVC uses LoginUrl:
1) When a User is not autenticated
2) when you use AuthorizeAttribute in Controllers and user has enough user rights.
I writed a SingleCulture Action /Account/LogOnNeutral that redirect to the Culture Dependent url {culture}/Account/LogOn, but I needed the last valid culture that I stored in session.
Thanks
Marco
Hi Marco,
ReplyDeleteGood question. I didn't find any solution for setting automatically culture in loginUrl attribute.
But I can suggest you to take current culture from param returnUrl in your LogOnNeutral action and set it to RouteData.Values["culture"] and redirect again to the culture dependent action RedirectToAction("LogOn"),
This works for me, hope it'll work for you
Hi Alex, I have the same problem, can you explain step by step that "solution"? I don't understand how to do that.
DeleteThanks
Alex,
ReplyDeleteA great pair of posts here. I have implemented things just as you laid out but I am having a problem. Noting to do with your code. It is working great. I am trying to localize for Tagalog (tl), which is not a built in culture in windows. So have created it using the CultureAndRegionInfoBuilder class. I have created .resx and .de.resx and .tl.resx files. When I go to http://localhost:1907/de I get german resources. When I go to http//localhost/tl I get the default (english) resources.
Any thoughts? I have a more complete explanation on StackOverflow (http://stackoverflow.com/questions/3743801), but its not getting any bites.
Any ideas?
--Ken
This comment has been removed by the author.
ReplyDeleteHi Alex, thank for the answer.
ReplyDeleteJust another question. What about culture switching after a Post that have failed validation?
The problem was this.Request.RawUrl in CultureSwitchControl.ascx.
If you come from a post that have failed validation, typically in your Controller you return the View. In the view Request.RawUrl correctly has only the parameter ?Length=XX, while the last GET Url can have several parameters.
For example:
GET Url: /en/Account/Edit/999
After POST Validation Failed Url: /en/Account/Edit?Length=YY.
You cannot use Request.UrlReferrer (if mehod is POST) because it is correct only the first time you fails validation.
Marco
Looks familiar... http://blog.jeroenverhulst.be/post/2010/08/14/Extending-MvcRouteHandler-to-enable-localization-of-an-ASPNET-MVC2-application.aspx
ReplyDeleteHi Alex,
ReplyDeleteVery helpful post. I have one question. If I want to rewrite an Url, for example www.host.com/section/id to call the "section" action in the "home" controller how would I write the MapRoute? Normally it would be something like this:
routes.MapRoute("Name","Section/{id}", new {controller= "Home", action = "Section", id=UrlParameter.Optional});. But this only works for SingleCulture routes. Is there a solution for MultiCulture? For ex: www.host.com/en/section/id to call the "section" action in the "home" controller.
Thanks,
Dinu
Hi Alex,
ReplyDeleteThan you for the post. Could you validate link to the source code? It looks like not functional anymore.
Thank you.
Sean
Great post.
ReplyDeleteYep, looks like link to source code is no longer working.
Thnx , very helpful article.
ReplyDeleteThe download link is doesnt work may you reupload please.
Namik SHehu
Thnx , very helpful article.
ReplyDeleteThe download link is doesnt work may you reupload please.
Antonio
i would like to download the code too but the link is broken. Can it be fixed please?
ReplyDeletethank you!
it's works great tutorial thanks for share!
ReplyDeletehey dude this is really interesing blog and its described routes information and how its works and please continue posted this type of another blog.great tutorial and also improving my knowledge and thanks for sharing information
ReplyDeletegreat! but the link following to download sourcecode don't work...please fix it so i can download the sourcecode
ReplyDeleteThanks, great article.
ReplyDeleteBut I found that this approach doesn't work properly with ignored pathes, i.e.
routes.IgnoreRoute("{file}.html");
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
IgnoreRoute doesn't return mapped route. Need a workaround here.
Thanks¡ This solutions looks great, can you update the link please to download the project? It seems broken.
ReplyDeleteThanks¡ This solutions looks great, can you update the link please to download the project? It seems broken.
ReplyDeleteHi, as say Alessandro, can you reupdate the broken link?
ReplyDeleteThanks Alex.
Sorry guys, I was quite busy for a long time... But there are good news :) I'm currently working on new blog post covering localization of ASP.NET MVC 4 and the source code will be hosted on github.com.
ReplyDeleteThank you to everyone for following and reading my posts.
Any update on this statement? Can we find an example out on github yet?
Deletesource code plz...
ReplyDeleteHi,
ReplyDeleteThis article was of great use. Thanx a lot Alex.
Today i tried it with mvc 4 razor and it worked like a charm.
So I wanted to share it to you guys : http://sdrv.ms/UxN9lD
Hope this skydrive link works.
Hi Frank,
DeleteYes your skydrive link works.
Thanks for your code.
roger
This comment has been removed by the author.
DeleteHi Frank, thanks.
DeleteThanks Alex very much.
thanx for the code
DeleteChange the route to "{culture}/{controller}/{action}/{id}"
ReplyDeleteand you don't need all those code to dynamically change the route.Only following code will be required
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(
"Default", // Route name
"{culture}/{controller}/{action}/{id}", // URL with parameters
new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
).RouteHandler=new MyRouteHandler();
}
and
public class MyRouteHandler:MvcRouteHandler
{
protected override IHttpHandler GetHttpHandler(System.Web.Routing.RequestContext requestContext)
{
var culture = requestContext.RouteData.Values["culture"].ToString();
var ci = new CultureInfo(culture);
Thread.CurrentThread.CurrentUICulture = ci;
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(ci.Name);
return base.GetHttpHandler(requestContext);
}
}
You may have constraint though
Thanks for the great article and solution,
ReplyDeleteI have tried the previous approach with session and it work fine, but when I have applied the routing approach every page is not found
I have errors when running the app, it says that The resource cannot be found.
can you help please
I have raised a question on stack overflow
ReplyDeletehttp://stackoverflow.com/questions/18674859/routing-to-webapi-with-localization
I am trying to route to a WebApi controller and the culture code is being added before the hyperlink. I have tried using the SingleCultureRouteHandeler and it is not removing the culture code. I would great appreciate any help.
Other than that thanks for the great posts on Globalization/Localization your solution works great
-Tim
hello,
ReplyDeletelooks like source code is not available at any place.
Hi Alex, thanks for the great article.
ReplyDeleteI have a problem here.
if I go to address mydomain.com/en/home/index or mydomain.com/fa/home/index there is no problem. but if i go to mydomain.com/en/ or mydomain.com/fa/ it cannot find the page.
please help me
Project Link dosent work you can update link
ReplyDeleteHi, Alex! If you're interested in a good localization tool to help you manage software localization projects, I recommend you this software translation app: https://poeditor.com/
ReplyDeleteIt's especially useful for collaborative work and crowdsourcing translations.
Beginner C#, jQuery, SQL Server, ASP.NET MVC, LINQ and much more..
ReplyDeleteBeginner Programming Tutorials
Alex, I would also like to suggest a website where people interested in translation and localization can read the latest news in these areas, thought it might be helpful. http://l10nhub.com/ Thanks!
ReplyDeleteAlex,
ReplyDeleteThank very much. Good work
Thank you for the solution, I am trying to adapt it to my needs but I have a hard time. Can you help me please with http://stackoverflow.com/questions/33468136/why-am-i-receiving-the-exception-a-public-action-method-was-not-found-on-contro? Thanks.
ReplyDeleteLink is broken for source code(project created in VS2010)
ReplyDeleteGreat Article
ReplyDeleteASP.NET MVC Training
MVC Online Training
Online MVC Training India
Dot Net Training in Chennai