Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Creating a Bilingual ASP.NET MVC 3 Application – Part 2

0.00/5 (No votes)
30 Jan 2012 2  
This artcle extends Part 1 to allow overriding of culture through the URL in a lightweight fashion.

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 RouteConstraints 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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:

EnglishDefault.jpg

The browser default is set Arabic:

ArabicDefault2.jpg

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:

ArabicShortEnglish.jpg

And the long form:

ArabicLongEnglish.jpg

Now we test that the application defaults to English if an unsupported culture is specified in the URL:

ArabicUnsupported.jpg

Finally, a basic test that the application still processes the route by manually specifying the default values for controller and action:

ArabicEnglishAndDefaultsSupplied.jpg

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
{

    //Snip……

    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:

ArabicLink.jpg

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:

EnglishLink.jpg

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.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here