When considering localisation/globalisation/internationalisation of an MVC website there are a lot of routes you can take. The easiest of these (and therefore the most tempting) is to use a Resources.resx file to handle string replacement, but there are scenarios where you need to do more than just replace some text.
You might need to rearrange the page to handle right-to-left languages, you might need to change a background image that is particularly offensive in some countries; either way, you’re looking at writing a specific view for a specific culture.
Custom Per-Culture Views
While googling for a solution I found quite a few examples (such as this blog post from Brian Reiter) that work something like this:
- Create a folder structure like the one below for your culture-specific views
- Subclass
WebFormViewEngine
orRazorViewEngine
to transform the view path to the new folder structure
In the example above, any visitor with a culture of en-GB
will see /Views/i18n/en-GB/Home/Index.cshtml
; anyone else will see the default view.
In many ways this is a very elegant solution: it doesn’t require you to create localised versions of every view; it elegantly falls back to sensible defaults; and you don’t have to write a lot of custom code. Unfortunately it does have a couple of problems:
- It requires that you subclass a specific
-ViewEngine
implementation - It doesn’t support localised versions of master pages
- It doesn’t work with the MVC mobile features introduced in MVC4
The first is not necessarily a show-stopper, but the mobile support issue is a pretty big deal for my scenario and the lack of master page localisation could cause problems.
“Borrowing” from Mobile Features
Seeing as the mobile features were the main driver behind my need to change, it makes sense to use the same mechanism for path-transformation that they use: display modes.
Display Modes (Simple Version)
Display modes allow you to set up special suffixes (suffices?) such as “Mobile” that, under certain circumstances, ASP.NET will look for before looking for the standard .cshtml
. For example, the following would set up an iPhone
suffix that would be considered whenever the user agent of a request includes the string “iPhone”:
DisplayModeProvider.Instance.Modes.Insert(0, new DefaultDisplayMode("iPhone")
{
ContextCondition = (context => context.Request.UserAgent
.IndexOf("iPhone", StringComparison.OrdinalIgnoreCase) >= 0)
});
This means that when a user browses to your site using an iPhone, ASP.NET will try to find a /Views/Controller/Index.iPhone.cshtml
before falling back to /Views/Controller/Index.cshtml
.
Display Modes (Slightly-More-Detailed Version)
So how does the example above actually work?
The DefaultDisplayMode
class in the example is part of MVC and is responsible for the transformation of a virtual path and for checking if it exists. By adding it to the DisplayModeProvider.Instance.Modes
collection at index zero, we are telling ASP.NET that it should check this display mode before any others.
To determine which mode to use, each IDisplayMode
instance that is added to the DisplayModeProvider.Instance.Modes
collection will have the GetDisplayInfo
method called in the order that they are added.
public DisplayInfo GetDisplayInfo(HttpContextBase httpContext, string virtualPath, Func<string, bool> virtualPathExists)
{
//...
}
If the instance is able to return a valid view path then it does so by returning an instance of DisplayInfo
; if not, it returns null
and the next element in the Modes collection is queried.
So how can we use this to handle localisation?
Handling Localisation with Display Modes
The first step is to subclass DefaultDisplayMode
so we have somewhere to implement our localisation path transformation. We could implement IDisplayMode
directly, but we get some things for free by using the existing implementation:
public class LocalisedDisplayMode : DefaultDisplayMode
{
private string _suffix;
public LocalisedDisplayMode(string suffix)
: base(suffix)
{
_suffix = suffix;
}
}
All we are doing here is accepting a suffix in the constructor that we pass to the base class as well as keeping a reference for ourselves - we’ll need it later.
Next up we want to override the implementation of GetDisplayInfo
so that we can transform the path to the localised equivalent.
public override DisplayInfo GetDisplayInfo(
HttpContextBase httpContext,
string virtualPath,
Func<string, bool> virtualPathExists)
{
//...
}
The 3 parameters are:
- The context of the current request
- The current virtual path
- An anonymous helper function to determine whether or not a given virtual path exists
Our implementation needs to:
- Get the appropriate culture for the user
- Check to see if a custom view exists for the specific culture (e.g.
en-GB
) - If not, check to see if a custom view exists for the 2-character culture (e.g.
en
) - If not, return null to indicate that this display mode cannot handle the request
Acquiring User Culture
We can get an appropriate culture for the user through a number of mechanisms - Accept-Language headers/Request.UserLanguages, storing a cookie, using CultureInfo.CurrentUICulture
etc. - and how this is sourced doesn’t really matter for this example. For now, I’m going to leave it as an abstract method:
protected abstract CultureInfo GetCulture();
Selecting a View Path
Let’s assume that a request for ~/Views/Home/Index.cshtml
is made from a user with a culture of es-ES
, and our display mode has been set up with a suffix of “suffix”. We want to check the following paths (in order) to see if they exist:
~/Views/i18n/es-ES/Home/Index.suffix.cshtml
~/Views/i18n/es/Home/Index.suffix.cshtml
We’re not worried about looking in /Shared
or in checking the non-localised paths - those will be covered by default display modes.
public override DisplayInfo GetDisplayInfo(
HttpContextBase httpContext,
string virtualPath,
Func<string, bool> virtualPathExists)
{
var culture = this.GetCulture();
return
TryGetDisplayInfo(virtualPath, culture.Name, virtualPathExists) ??
TryGetDisplayInfo(virtualPath, culture.TwoLetterISOLanguageName, virtualPathExists);
}
private DisplayInfo TryGetDisplayInfo(string virtualPath, string cultureString, Func<string,bool> virtualPathExists)
{
//use the base class to handle suffix transformation
var transformedPath = this.TransformPath(virtualPath, _suffix);
//use a regular expression to replace the view folder
var cultureSpecificPath = Regex.Replace(
transformedPath,
"^~/Views/",
string.Format("~/Views/i18n/{0}/", cultureString));
//return the virtual path if it exists...
if (virtualPathExists(cultureSpecificPath))
return new DisplayInfo(cultureSpecificPath, this);
//...or null if it doesn't
return null;
}
Here we have implemented a helper method that will transform and check a path, then return a DisplayInfo
instance if it exists. The null-coalescing operator in GetDisplayInfo
will fallback to each less-specific view path in turn.
Plugging it in
The final step is to insert the new display mode into the configuration. We do this in the Global.asax
file as below:
protected void Application_Start()
{
DisplayModeProvider.Instance.Modes.Insert(0, new LocalisedDisplayMode(DisplayModeProvider.DefaultDisplayModeId));
DisplayModeProvider.Instance.Modes.Insert(0, new LocalisedDisplayMode(DisplayModeProvider.MobileDisplayModeId)
{ ContextCondition = context => context.GetOverriddenBrowser().IsMobileDevice });
//...
}
We are inserting 2 instances so we can mimic the behaviour of the 2 default display modes (Default and Mobile), and we are inserting them at the top of the list so that they take precedence over the defaults.
Now we can create localised versions of master pages, views, partial views, mobile views…pretty much everything!
As an added bonus, this implementation is reusable between both Razor and Web Forms, and is much easier to unit test than the subclassed-view-engine approach.