Table of Contents
Since the first release of the .NET framework developers are given the chance to easily configure any kind of project - be it a Website, a Web Application, a Windows Forms or XPF/XAML client and such - in order to support multiple languages. This can be achieved using the well-known Resource Files (.resx) approach. We won't explain them here (if you're interested, read the official walkthrough), but we'll remember a couple key concepts. A Resource Files is basically a key/value array of content resources (mostly images and text) for each supported language. All the developers have to do is to create a .resx file for the main language (let's say english) and another one for each of these languages using the same name plus the ISO 639-1 two-letters language code of the language itself, i.e.:
- a Global.resx file to store text and images for english, assuming it'll be our default & fallback language.
- a Global.it.resx file to store text and images for italian language
- a Global.de.resx file to store text and images for german language
and so on. Once we did that, we'll only have to write our code using the key specified in these files instead of the actual content (if you don't know how, read the walkthrough above): ASP.NET will look up the keys in our Resource Files, starting from the one with the Localization closest to the one set for the current thread and then going backwards until it founds something to show.
Kickass feature, indeed: let's see how we can use it to build our very own multi-language MVC ASP.NET Web Application.
Resource Files in MVC
The first question would be: where do we put the .resx files? The answer is all but granted: our first choice would be adding the App_GlobalResources ASP.NET Folder to our project, just like we always did since .NET Framework 1.1: where else?
You could be surprised, but if you're working with MVC this is not the right choice. If you want to know why, you can read the whole story in this great article by K. Scott Allen sul tema. To summarize it, let's just say that the Framework will compile that folder in a separate assembly, thus making them unaccessible to our Controllers, Unit Tests, etc.: we don't want this, that's why it's better to just place them in a separate, dedicated standard folder which we'll just call /Resources/ .
Once we added there our .resx files there's another thing we need to do: we need to change some default settings by opening each file's Properties window:
First thing we need to do is to change the Custom Tool, which is the code-generator engine used by the Framework to build the Resource File strongly-typed class. The default tool, ResXFileCodeGenerator, would generate a private class, which is not what we want. That's why we'll replace it with the PublicResXFileCodeGenerator, which will be able to generate a public class with public methods & properties: just like what we need.
Second settings to change is the Custom Tool Namespace: default value, an empty string, means no namespace, which is far from ideal. That's why we'll replace it with something like Resources, so the custom tool will generate a code consistent with our folder structure: our resources will be located in the /Resources/ folder and will also have the Resources namespace: that's good enough.
Notice that these small modifications will be required for each and every .resx we'll add to our project: you cannot setup these values as default, but you can still duplicate your Resource Files so you'll always have these settings set, since each .resx copy comes with the same properties as the source file.
How to serve the proper language to each Request
As we said before, .NET Framework automatically select the Resource Files closest to the localization of the actual thread, which is the one that will process the http request and serve the proper http response accordingly. The localization info are stored in the response thread's CultureInfo object, which is usually set against the language specified by the request's browser/client. Meaning that we'll be able to serve the same contents in different languages - depending by the browser's client language settings - upon the same URL.
Same URL, different content. Would that be a proper behaviour? We could be tempted to say yes - after all, allowing each user to read the exact same URL in their home/native language seems like a good thing. Problem is, from a SEO perspective it's a complete failure.
Now we should ask ourselves La domanda che dobbiamo porci è: si tratta di un comportamento corretto? Da un punto di vista di funzionalità, sicuramente si: è indubbio che leggere una pagina in italiano o in inglese a seconda della localizzazione dell'utente possa essere molto comodo. Il problema è che da un punto di vista SEO si tratta di un fallimento completo. Chiunque abbia un minimo di infarinatura nel campo sa bene che è opportuno fare in modo che ogni traduzione di ciascuna pagina disponga di una URL specifica: se avete dei dubbi in proposito potete chiarirvi le idee su questa breve guida di Google che illustra una serie di best-practices da utilizzare per gestire siti multi-lingua, della quale ci limiteremo a citare la frase più significativa: Keep the content for each language on separate URLs.
This basically means that we can't just set the thread language taking into accont things like:
- the user's browser language
- cookie values (if set)
- user session values (if present)
- any other Request-based field (HEADER, POST data, etc.) different from the URL
But we need to build a proper URL-based localization mechanism, where each language answers to its very own set of URL patterns, such as:
- http://www.example.com/page (for default language contents - english in our example)
- http://www.example.com/it/page (for all italian contents)
- http://www.example.com/de/page (for all german contents)
... And so on. Once we do that, we're free to use some or all the above info to route the requests we receive to the most suited language - i.e. sending the english browsers to the english contents and stuff like that. This basically means that we will use the URL to set the thread localization, and any other info - browser language, cookie values and such - to choose the user's default route.
Let's see how we can build our Web Application to make sure it will behave in such way.
Set-up a Multi-Language Route
First thing we have to do is to setup an ASP.NET Route to handle these kind of requests and store the localization-related info. If our web-application implements the standard ASP.NET MVC route-pattern, such as {controller}/{action}/{id}, you can use the following example:
1 2 3 4 5 6 |
routes.MapRoute( name: "DefaultLocalized", url: "{lang}/{controller}/{action}/{id}", constraints: new { lang = @"(\w{2})|(\w{2}-\w{2})" }, // en or en-US defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional } ); |
You'll need to put this route in the /App_Start/RouteConfig.cs file just before the last one, which is the pre-defined route: {controller}/{action}/{id}. If you're Web Application used a different routing approach, you'll need to adopt a similar, yet suitable strategy according to that.
The main goal of this "localized" Route is to isolate and store a lang variable corresponding to the requested language. This information, if present, will be used to set the localization of the current Thread so it will use the corresponding Resource Files.
Using a LocalizationAttribute to handle Multi-Language Requests
Now that we have a Localization Route to catch our multi-language URL calls and store our language info in a handy variable, we need to find a way to programmatically handle it. To fullfill this task we can create a LocalizationAttribute by creating a new LocalizationAttribute.cs class: the attribute will be executed upon each Action and it will set the current Thread's Culture and the UICulture with the value stored into the lang variable by the route.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public class LocalizationAttribute : ActionFilterAttribute { private string _DefaultLanguage = "en"; public LocalizationAttribute(string defaultLanguage) { _DefaultLanguage = defaultLanguage; } public override void OnActionExecuting(ActionExecutingContext filterContext) { string lang = (string)filterContext.RouteData.Values["lang"] ?? _DefaultLanguage; if (lang != _DefaultLanguage) { try { Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = new CultureInfo(lang); } catch (Exception e) { throw new NotSupportedException(String.Format("ERROR: Invalid language code '{0}'.", lang)); } } } } |
This class can be put everywhere in our Web Application, such as a /Classes/ or /AppCode/ folder. Once we add it to our project we also need to make sure it will be executed upon each request/Actions: we can do that by registering it as a Global Filter by using the RegisterGlobalFilters method in the /App_Start/FilterConfig.cs class:
1 2 3 4 5 6 7 8 |
public class FilterConfig { public static void RegisterGlobalFilters(GlobalFilterCollection filters) { filters.Add(new HandleErrorAttribute()); filters.Add(new LocalizationAttribute("en"), 0); } } |
The marked line shows the line we need to add to the default implementation to make our LocalizationAttribute kick in upon each and every response. We can - and actually should - also choose a default Localization - "en" in our sample, corresponding to the English language: that will ensure that we'll serve the default language for any URL not containing Localization info, i.e. any time the lang variable will be set to null because the request will be handled by a route other than our LocalizationRoute.
Conclusions
We don't need to do anything else, except creating the relevant .resx files for any language we want to support. As soon as we do that, we'll be able to test the results of our work by calling the following pages of our Web Application:
- http://www.example.com/page , seeing our contents in the default language (which is english in our example).
- http://www.example.com/it/page , seeing our contents in italian language (with a fallback to the default language if not present).
- http://www.example.com/de/page , seeing our contents in german language (with a fallback to the default language if not present).
... and so on.