Introduction and recap
Part 1 outlined how to use resources to internationalize an MVC3 application so
it is bilingual, it also acknowledges three sites I found useful which helped form the basis of the parts to this article. If you are unfamiliar with Part 1,
or have not used resx files with an MVC application or in general, it might be worth reviewing (especially the introduction) as I will not re-cover
its ground in this part. It is also re-iterating Part 1’s caveat about needing a basic understanding of the following in MVC 3: routing strategy, controllers
actions and views; the Route
class; the purpose of RouteHandlers
; what RouteConstraint
s do. I will not be
explaining any of these in great depth, in the interests of clarity and brevity. Unlike Part 1, this article is not targeted at MVC3 beginners as some of the
aspects are unavoidably technical. I do hope that beginners persevere and find picking the code apart useful to their understanding of MVC 3 anyway!
In Part 1, the MVC 3 application was able to display English (as default) or Arabic according to the “Language Preference” set in the user’s browser.
This article describes how to add the ability to override this using the URL; further, it will add how to change languages manually and end in a discussion of
the pros and cons of the methodology used.
Part 2 Application overview
The first thing that must be decided is the routing strategy for the URL, the default for MVC 3 is: http://MyHost/Contoller/Action/Id.
“Controller” is the controller class, “Action” is the method to be called on that controller, there is an optional third part, ID. The defaults for these are
“Home” and “Index”, respectively; in the default application, the ID is not needed for this method. It is necessary to decide where to put the language
discriminator in the URL. As the URL already goes from general to specific, we shall follow the same pattern: http://MyHost/Culture/Contoller/Action.
If culture is missing, the strategy is to default to the brower’s language settings, making the application work like Part 1 of this article, using the
browser default. MSDN
has a similar routing: culture is in the format “en-us” or “en-sa”. This article ignores the second part of the culture format, so the language could be “ar-zz”
or “en-xx” and it will still show the appropriate language version. Because we do not care about language sub-divisions, I will also allow the short form two
letter ISO code “en” and “ar”, and we will use the short form by default. Here are some sample URLs:
URL |
Result |
http://localhost |
Calls Home/Index (defaults) and uses the browser’s
language |
http://localhost/ar |
Calls Home/Index (defaults), specifies Arabic,
overriding the browser language |
http://localhost/Home/Index |
As per first URL, but explicitly sets Controller
and Action |
http://localhost/ar/Home/Index |
As per second URL, but explicitly sets Controller
and Action |
http://localhost/en/Home/Index |
As previous, but overrides browser language with
English |
http://localhost/en-gb/Home/Index |
As previous (valid sub-culture) |
http://localhost/en-zz/Home/Index |
As previous (invalid sub-culture, but sill
overrides with English) |
Placing the language first has the benefit of leaving the rest of the URL “as is”, so the vast majority of existing routing strategies can be accommodated.
There is one pitfall here, and it is not obvious: the default strategy has an optional final parameter ID so the following have the potential to be matched the same way:
- http://localhost/Home/Index/1
- http://localhost/ar/Home/Index
Could be broken matched in the following ways:
URL |
Language |
Controller |
Action |
Id |
http://localhost/Home/Index/1 |
[Default] |
Home |
Index |
1 |
http://localhost/ar/Home/Index |
ar |
Home |
Index |
N/A |
http://localhost/ar/Home/Index |
[Default] |
ar |
Home |
Index |
The worst cast scenario is the last: the MVC 3 framework will throw an error when it tries to use a controller called “ar”. To determine which scenario is
correct for a given URL, we will create a RouteConstraint
which effectively returns true if the first part of the URL is a language
specification. The code will pattern match either “XX” or “XX-XX” where X is any letter. The constraint code will not check to see if the language code is valid.
If the language code is invalid or not supported (i.e., not English or Arabic), the site’s default will be shown (English). This design decision was partly
taken to increase robustness; if this decision was not made and the user actually tries the language code “xx-xx”, the website would throw an error as it
would determine that “xx-xx” is the controller, which will not exist. In the sample code, there is a commented method (with simple instructions) to allow you
to only match supported cultures if this fits your scenario better. Note that we do lose a little flexibility: we cannot create controllers with names
that match our pattern (though a controller class named “en” or “ar-jo” is not very descriptive :).
If you are confused about the routing strategy, please take a look at the section “Results
(So Far!)” below, it shows how most of the various URLs are intended to work!
How will we achieve these?
This involves several steps:
- Add a route that takes the default route and pre-pends it with a culture value, to try to ensure the application works as normal when globalised.
It will use the normal application defaults. The route handler will add a constraint to ensure that the globalisation classes are used if a culture
matching our pattern is added to the URL.
- Create a route handler to call the code that sets the culture of the UI and main threads. Other than that, it should have the same functionality as
the default
MvcRouteHandler
.
- There will be a culture manager maintaining a dictionary where the two-letter ISO code is the key mapping on to a
CultureInfo
instance that represents the language to be used. It will do the work of setting the thread’s culture, and could be used by the
constraint to constrain to only supported cultures. The manager class should also make multi-lingual (as opposed to bi-lingual) support easier to implement.
- Code to abstract the pattern matching of the culture away from the other classes.
Note that the design is intended to “get out of the way” and be as self-contained as possible. To add globalised URL support to the application as
it was left in part 1 (or almost any MVC 3 application):
- A reference to the globalisation support library containing the above classes is added.
- An instance of the route described in step 1 is plumbed in via the MVC application’s global.asax.
The default handler is left in place so that if no culture code is specified (i.e., the route constraint fails), the application just uses the browser’s
default language, falling back to the behaviour in part 1 of this article.
The code
A class library called MvcGlobalisationSupport was added to the solution from part 1, with the classes described above. I will describe the code
plumbing the mechanism in, then drilling down to explain what each class does and describe some of the design decisions/trade-offs.
Now all we need do is replace the original registration code in Application_Start()
with:
public static void RegisterRoutes(RouteCollection routes)
{
const string defautlRouteUrl = "{controller}/{action}/{id}";
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
RouteValueDictionary defaultRouteValueDictionary = new RouteValueDictionary(
new { controller = "Home", action = "Index", id = UrlParameter.Optional });
Route defaultRoute = new Route(defautlRouteUrl,
defaultRouteValueDictionary, new MvcRouteHandler());
routes.Add("DefaultGlobalised",
new GlobalisedRoute(defaultRoute.Url, defaultRoute.Defaults));
routes.Add("Default", new Route(defautlRouteUrl,
defaultRouteValueDictionary, new MvcRouteHandler()));
}
The default route (without the culture) is defined for the application; this will be used by the GlobalisedRoute
(pre-pended with “{culture}/”)
and by the default fallback route. The ignore clause for resources is added as usual. Next a dictionary of default values is created with the same values as
the “normal” application. Note that the code does not add a default for culture. It is possible to do this, but this will result in the globalised version always
being called, overriding the browser default language. This would provide a poorer user experience (though it would make the application simpler after a
refactor) so we will not supply the default.
The globalised route is created and added to the routes collection first so that globalised constraint created within it can determine
whether the URL matches the “globalised” version containing a culture value. If the non-globalised default was added first, it would match any supplied
culture as a controller
value, and an error would occur as it tried to call a non-existent controller based on the culture value supplied. Note that
the globalised route takes the unglobalised route and defaults in its constructor, so it can add a culture value at the beginning of the route,
keeping the rest of the route and defaults the same as the normal strategy.
GlobalisedRoute
The globalised route class abstracts code that could have been easily added to global.asax, this helps keep the global.asax
clear of code, and also keeps most of the globalisation support work in a separate assembly.
public class GlobalisedRoute : Route
{
public const string CultureKey =
"culture"; static string CreateCultureRoute(string unGlobalisedUrl)
{
return string.Format("{{" + CultureKey + "}}/{0}", unGlobalisedUrl);
} public GlobalisedRoute(string unGlobalisedUrl, RouteValueDictionary defaults) :
base(CreateCultureRoute(unGlobalisedUrl),
defaults,
new RouteValueDictionary(new { culture = new CultureRouteConstraint() }),
new GlobalisationRouteHandler())
{
}
}
This class creates the route {culture}/{controller}/{action}
, and adds CultureRouteConstraint
to ensure that the globalised
handler is only called if a culture code in a valid format is provided. It also instantiates the GlobalisationRouteHandler
ready for use. I have
also taken the decision not to overload the constructor to mirror those in the base class; implementing versions of the base class’ constructors does not seem
to make much sense as we supply the values in this class. If you put this code into production, constructors similar to those in the base class might be necessary.
GlobalisationRouteHandler
The route handler does the work of creating an IHttpandler
to provide the response to the request.
public class GlobalisationRouteHandler : MvcRouteHandler
{
string CultureValue
{
get
{
return (string)RouteDataValues[GlobalisedRoute.CultureKey];
}
}
RouteValueDictionary RouteDataValues { get; set; }
protected override IHttpHandler GetHttpHandler(RequestContext requestContext)
{
RouteDataValues = requestContext.RouteData.Values;
CultureManager.SetCulture(CultureValue);
return base.GetHttpHandler(requestContext);
}
}
The properties are just there to provide clearer access to the value stored in the culture part of the route.
CultureManager.SetCulture(CultureValue);
does the first bit of real work, it uses the culture manager to set the UI and main thread cultures.
Finally, it calls the base [default] handler’s GetHttpHandler
method, ensuring the application’s route handling continues the same way as if
the application is unglobalised. Note that I have left out the constructors in the snippet above.
CultureRouteConstraint
The first part of the URL after the host name is assumed to be the culture value by GlobalisedRoute
, this constraint is added by it to
pattern-match to see if it is in the format mentioned at the beginning of this article (“xx” or “xx-xx”).
public class CultureRouteConstraint : IRouteConstraint
{
public bool Match(HttpContextBase httpContext,
Route route,
string parameterName,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if (!values.ContainsKey(parameterName))
return false;
string potentialCultureName = (string)values[parameterName];
return CultureFormatChecker.FormattedAsCulture(potentialCultureName);
}
}
This class extracts the potential “culture” value and uses the format checker to return true if it matches the pattern. By returning true, the
GlobalisedRoute
is used, otherwise the default route specified in global.asax is used. As was stated in the application overview, the
culture may not be supported or even valid, it just needs to have the correct format. The sample code has a second, commented Match
method
that can replace the above to allow matching of supported cultures only.
CultureManager and CultureFormatChecker
The code for these can be seen in the download, they do not do anything complicated, so a description of the main functionality of these classes will be simpler than the code itself:
CultureManager
- Has a private “
SupportedCultures
” dictionary mapping the two letter ISO code onto an equivalent CultureInfo
object.
- A private property returning the
CultureInfo
of the default culture; use when a culture formatted but invalid culture is requested in the URL.
- A public
SetCulture(string code)
method that attempts to find the culture from the dictionary. It ignores the case and parses down to the
short, two letter form. If the culture is supported, it sets the UI and main thread to the culture; if not, it sets these to the default culture.
The culture should only normally be set to the default if the URL contains a valid but unsupported culture (such as “fr-fr”/ “fr”) or an invalid but correctly
formatted culture string (such as “xx-yy” / “xx”).
CultureFormatChecker
This is the simplest class to describe, its single method bool FormattedAsCulture(string code)
returns true if it matches a regex
pattern that matches “xx” or “xx-xx” where x is a letter character. For those of you who like such things, the actual regex is: ^([a-zA-Z]{2})(-[a-zA-Z]{2})?$
.
Results (so far!)
First, for confirmation that, without the URL overriding the behaviour, the application still uses the browser default language (see part 1 if you do not
know how to do this!), here is the page with the browser default set to English:
The browser default is set Arabic:
So far so good! From this point forward, the browser default is kept to Arabic (the images are less wide to accommodate the page and the URL!). Here the
a culture is specified in the URL, using the short form:
And the long form:
Now we test that the application defaults to English if an unsupported culture is specified in the URL:
Finally, a basic test that the application still processes the route by manually specifying the default values for controller and action:
Adding UI support to switch languages
As briefly discussed in part 1, it is not desirable to force the user to use their browser’s default language. It is quite possible, for example, that they
are abroad and using a locked-down machine in an Internet café; less technical users might not know how to switch it back to something they can read (trust me,
I used to work in tech support :)). This final section outlines adding a hyperlink that returns the current page with the “other” language. As we are
going to display the opposite language (a link for Arabic in the English page and a link for English in the Arabic page), we need to add the content to our
resx files. These will be common values, so they are added to Common.resx and Common.ar.resx. For the English resource file:
OtherLanguageKey |
ar |
OtherLanguageName |
عربي |
ReverseTextDirection |
rtl |
ReverseTextDirection
is there to support marking the dir
attribute of the link to the same direction as the text it
contains, in Arabic’s case, right-to-left. This helps keep the browser select behaviour consistent.
The next step is to add the hyperlink moving to the current page, but with the addition of the opposite culture’s language key. To do this, a static class
was added to the library, which provides an extension method on HtmlHelper
; this is called GlobalisedRouteLink
:
public static class GlobalisationHtmlHelperExtensions
{
public static MvcHtmlString GlobalisedRouteLink(this HtmlHelper htmlHelper,
string linkText, string targetCultureName, RouteData routeData)
{
RouteValueDictionary globalisedRouteData = new RouteValueDictionary();
globalisedRouteData.Add(GlobalisedRoute.CultureKey, targetCultureName);
AddOtherValues(routeData, globalisedRouteData);
return htmlHelper.RouteLink(linkText, globalisedRouteData);
}
}
The helper makes a new route dictionary and adds the culture first; the AddOtherValues
method (snipped out) iterates over the route passed
into the method, adding the remaining values (skipping the culture if already present). It then uses the normal RouteLink
method to generate the full, globalised link.
Plumbing in
For the ASPX view engine, import directives were added:
<%@ Import Namespace=" InternationalizedMvcApplication.Resources" %>
<%@ Import Namespace="MvcGlobalisationSupport" %>
The first statement removes the need to qualify the namespace when adding the resource from Common.resx, the second adds the namespace containing the
GlobalisedRouteLink
extension method. Then the code to provide the link is added like this to the top of the body from Part 1 of the article:
<div dir="<%= Common.ReverseTextDirection %>">
<%= Html.GlobalisedRouteLink(Common.OtherLanguageName,
Common.OtherLanguageKey, ViewContext.RouteData)%>
</div>
Similarly, for Razor, a using
markup is added:
@using MvcGlobalisationSupport
@using InternationalizedMvcApplicationRazor.Resources;
Then this div
is added to the top of the body:
<div dir="@Common.ReverseTextDirection">
@Html.GlobalisedRouteLink(Common.OtherLanguageName,
Common.OtherLanguageKey, ViewContext.RouteData)
</div>
Razor users should note that the above markup is the only thing different between the Razor and the ASPX view engines in this article!
Testing the UI changes
First, the browser default language is set to English, at the project run:
The HTML source for the link is generated as follows:
<div dir="rtl">
<a href="http://www.codeproject.com/ar">عربي</a>
</div>
Notice that we have supported the language direction and the helper method has generated the reference. Clicking the link results in:
Here is the English link’s HTML:
<div dir="ltr">
<a href="http://www.codeproject.com/en">English</a>
</div>
Note that the language is explicitly specified. The eagle-eyed amongst you will notice that the controller and action of the current page are not
specified, the MVC 3 framework is intelligent enough to know that the defaults are in use, so it does not provide them explicitly. To test if the URL is
generated correctly when values other than default are used, enter the URL providing a value for an unused (but available!) ID, such as
http://localhost/ar/Home/Index/1. When rendered, the required HTML is rendered to the browser for the link:
<div dir="ltr">
<a href="http://www.codeproject.com/en/Home/Index/1">English</a>
</div>
Great success!
Points of interest
- This is one strategy, other architectures will fit! Hopefully mine is clean (if complicated to explain…).
- I have tested this strategy with the optional ID parameter and other controllers and actions in a mock-up app for my employer. To keep the code
simple, I have not done this in the download, but you should be able to add new actions and controllers as in a normal application. The only time you need
to worry about the bilingual status is for actual display differences (which would be necessary anyway) or if you want to provide other ways of switching language.
- This mechanism can be extended for multilingual apps. The biggest problem will probably be replacing the language link with a combo box and working out
how this is populated. Not a great deal of work should be needed (famous last words!). Can use regional variations of languages, but it would be sensible to
do the extra work needed to validating those supported, and returning the default for variations that are not, replacing my naive pattern matching mechanism.
- It should be possible to add a section to the web.config that states which languages are supported by providing a list of two or even
four-letter codes. To do this, the coder would need to replace the hardcoded addition of cultures to the dictionary in the
CultureManager
and
change the code that sets the default language. Again, the code to populate the language switching mechanism is likely to be the biggest headache.
- If there is enough call, I will add a third part making the application multi-lingual.
Conclusion
Although we have gone into some technical detail and much work is required, globalising an MVC 3 application can be achieved with a very light touch
from the perspective of the web application:
- Add the RESX files and use them in the views as outlined in part 1
- Add the globalisation support assembly described here
- Make the changes to global.asax
- Add a control to allow the user to manually override the default browser setting
The real and heavy work is done in the globalisation support assembly. The whole experience of globalising an MVC application is far less smooth and
intuitive than a standard ASP.NET app. I hope as the MVC framework matures and becomes more mainstream (as it seems to be doing now), better
Internationalization support is added.
Notice also that the language is discriminated against by the URL proper, not parameters, as sometimes is the case with a straight ASP application, and you
can easily add language-specific metadata. The two together can be used, along with a sitemap to generate better language-specific search engine rankings.
As always, if you have any comments or feedback, please feel free to ask.
History
If you edit this article, please keep a running update of any changes or improvements you've made here.
- 7 June 2011: Initial article.