Sunday, November 30, 2014

Localizing Razor Views


The Problem

Occasionally it is advantageous to move beyond resource files to support localization in an ASP.NET MVC application. Resource files contain localized phrases, blocks of text, images, and other resources that may be referenced from a view. The advantages of resource files are that there is a clear structure for defining localized data, the views incorporate pre-defined functionality to reference a resource key (name), and the views rely on the framework to select the correct localized resource file.

The disadvantage of resource files with large chunks of localized text, images, and other resources is that the view becomes just a template referring to the data. It is not possible to see the data while working on the page and the feel for the information on the page and how it is presented is lost in both the source (HTML) and WYSIWYG editors.


The Options

There are alternatives that we can lump together under the title View Localization. The deciding factor should be what the view supports: if the view is predominately text then it is a candidate for view localization. If the view has minimal text, and is predominately images, form elements, and other non-localizable components then it is not a candidate. This is because separating localized text supports the principle of single responsibility and the more general, encompassing principle of organization, while repeating non-localizable components across localized views violates the DRY principle: don't repeat yourself.

The alternatives are:

1.     Place the localized text in partial views and decide in the containing view which one to display. From the perspective of the containing view this is little better than a localized resource. Also a strong dependency between the containing views and selecting a localized partial view is created. Placing the selection logic in a shared helper may relieve that, but that in turn adds a strong dependency on the concrete helper implementation.
2.     Create a filter that runs after the controller action and changes the selected view to a localized version. Unfortunately this doesn't do anything for partial views and layouts referenced from that view. It also creates a tight coupling between the controller and the concrete filter.
3.     Use Unity to provide attribute dependency injection based on the thread locale as controllers are created. This seems to be overblown for selecting views and may bypass features intentionally exposed in alternative four.
4.     Change the Razor view engine to select the localized view as it is referenced. This also functions in the design as dependency injection: the controllers and views reference the abstractions and the view engine cuts across all the controllers and views and replaces the abstractions with concrete selections it chooses based on its rules.

Since overriding the view engine is an intentional design feature in ASP.NET MVC and does function as a.dependency injection layer option four appears to be the best choice. This will provide localized versions of views where there are differences in large sections of the page, support shared layouts and partial views, supports localized resources at the same time, and decouples the localized views from the controllers.

The Implementation

I have encountered several descriptions of how to implement this functionality, but beware that many of them do not complete the task or have some fundamental problems. The two biggest mistakes that I have encountered were to skip mapping a localized version of the layout view, and follow a non-standard pattern for the localized versions: create sub-folders for the the different locales. That latter mistake is disturbing. On the surface it looks like a good way to organize the files, by locale. But it makes it difficult to manage the localized versions of a particular resource when they are distributed across folders and all have the same name. It is better to keep them all in the same place with different names as resource files are organized in ASP.NET.

The Implementation is to extend the RazorViewEngine class. Exploring that class there is a tendency to gravitate toward overriding the methods to create the views. The simpler solution is to override the methods that find a view. All that is necessary is to check for a localized copy of the view and map to that instead of the file that normally would be selected.

The naming convention for the.localized view files will follow the pattern for localized resource:  a two character language name and two character culture in the form en-US, or just a two character language name in the form en. The extended view engine will continue to support both C# and VB.NET view files with the cshtml and vbhtml extensions.

The functions that map to view files have a parameter that indicates if a cached view mapping should be used. The cache only stores the resolution of the view name to a view file, and the cache is only used in release more. In debug mode there is too much of a chance that new views will be added changing the resolution of a view, while release mode should have a static set of views. The cache used by the view engine only holds the resolution for fifteen minutes, unless the configuration is changed by the developer.

The solution for using the cache with localized view names is to use the requested view name and locale together as the key that maps to the resolved view. There are only three possible resolutions: a view with a language and culture, a view with just a language, and the view without a language (or culture). The keys used in the extended class will not conflict with the keys used by the base class because the base class is overridden in all three circumstances.

So there are two methods that need to be overridden: FindPartialView and FindView. The first method is used to resolve partial views, and the second.to resolve main views and associated layout views. In the extended.class the only difference is that in the second the layout can be localized as well. In the implementation both methods leverage the same helper method "GetFilePath" to resolve a specific view be it a regular view, a partial view, or a layout.

GetFilePath checks a series of candidate paths in turn and returns the first matching candidate. At the same time it records each path checked and throws a message showing the paths if no candidate matches. It is not documented in superclass VirtualPathProviderViewEngine that FileView and FindPartialView throw an InvalidOperationException with a list of paths checked if no candidate matches, but the superclass RazorViewEngine is implemented this way and the LocalizedRazorViewEngine follows the pattern.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Web;
using System.Web.Mvc;

namespace Jactura.Support
{

@tab;/// <summary>
@tab;/// A Razor view engine that looks for localized versions of the view and layout files.
@tab;/// </summary>
@tab;/// <remarks>
@tab;/// This view engine uses the cultural information from the Thread to locate a view using the same rules that
@tab;/// are applied for resource localization. Views, partial views, and layouts are all localized.
@tab;/// </remarks>
@tab;public class LocalizedRazorViewEngine : RazorViewEngine
@tab;{

@tab;@tab;/// <summary>
@tab;@tab;/// The default locale if a specific locale is not requested.
@tab;@tab;/// </summary>
@tab;@tab;public const String DEFAULTLANG = "en";

@tab;@tab;/// <summary>
@tab;@tab;/// Initialize with the default language code.
@tab;@tab;/// </summary>
@tab;@tab;public LocalizedRazorViewEngine() {
@tab;@tab;@tab;
@tab;@tab;@tab;DefaultLanguageCode = DEFAULTLANG;
@tab;@tab;}

@tab;@tab;/// <summary>
@tab;@tab;/// The default language code.
@tab;@tab;/// </summary>
@tab;@tab;public string DefaultLanguageCode { get; set; }

@tab;@tab;/// <summary>
@tab;@tab;/// Resolve a localized view with a localized master view.
@tab;@tab;/// </summary>
@tab;@tab;/// <param name="controllerContext">The controller context.</param>
@tab;@tab;/// <param name="viewName">The name of the view.</param>
@tab;@tab;/// <param name="masterName">The name of the master view.</param>
@tab;@tab;/// <param name="useCache">True to use the cached view.</param>
@tab;@tab;/// <returns>The view or null if the view cannot be resolved.</returns>
@tab;@tab;public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache) {

@tab;@tab;@tab;ViewEngineResult result = null;
@tab;@tab;@tab;String viewPath = null;
@tab;@tab;@tab;String masterPath = null;
@tab;@tab;@tab;String message = null;

@tab;@tab;@tab;try {

@tab;@tab;@tab;@tab;viewPath = GetFilePath(controllerContext, viewName, useCache);

@tab;@tab;@tab;@tab;if (!String.IsNullOrEmpty(masterName)) {

@tab;@tab;@tab;@tab;@tab;masterPath = GetFilePath(controllerContext, masterName, useCache);
@tab;@tab;@tab;@tab;}
@tab;@tab;@tab;}

@tab;@tab;@tab;catch (InvalidOperationException e) {

@tab;@tab;@tab;@tab;message = e.Message;
@tab;@tab;@tab;}

@tab;@tab;@tab;if (String.IsNullOrEmpty(viewPath) || (String.IsNullOrEmpty(masterPath) && !String.IsNullOrEmpty(masterName))) {

@tab;@tab;@tab;@tab;throw new InvalidOperationException(String.Format("The view '{0}' or its master was not found or no view engine supports the searched locations. The following locations were searched: {1}", viewName, message));
@tab;@tab;@tab;}

@tab;@tab;@tab;result = new ViewEngineResult(CreateView(controllerContext, viewPath, masterPath), this);
@tab;@tab;@tab;return result;
@tab;@tab;}

@tab;@tab;/// <summary>
@tab;@tab;/// Resolve a localized partial view.
@tab;@tab;/// </summary>
@tab;@tab;/// <param name="controllerContext">The controller context.</param>
@tab;@tab;/// <param name="viewName">The name of the view.</param>
@tab;@tab;/// <param name="useCache">True to use the cached view.</param>
@tab;@tab;/// <returns>The partial view or null if the partial view cannot be resolved.</returns>
@tab;@tab;public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string viewName, bool useCache) {

@tab;@tab;@tab;ViewEngineResult result = null;
@tab;@tab;@tab;String viewPath = GetFilePath(controllerContext, viewName, useCache);

@tab;@tab;@tab;if (viewPath != null) {

@tab;@tab;@tab;@tab;result = new ViewEngineResult(CreatePartialView(controllerContext, viewPath), this);
@tab;@tab;@tab;}

@tab;@tab;@tab;return result;
@tab;@tab;}

@tab;@tab;/// <summary>
@tab;@tab;/// Return the path to a localized view.
@tab;@tab;/// </summary>
@tab;@tab;/// <remarks>
@tab;@tab;/// There is incentive to fall through to the base class view creation of a non-localized name if this function fails to
@tab;@tab;/// resolve a localized path, it will reduce the path checking that must occur here. Resist the temptation, because to
@tab;@tab;/// resolve a view (not a partial view) we need the path to the view and the path to the master view and either one of
@tab;@tab;/// those may not be localized. This method must use the entire search criteria to find the correct path.
@tab;@tab;/// </remarks>
@tab;@tab;/// <param name="controllerName">The name of the controller.</param>
@tab;@tab;/// <param name="viewName">The name of the view.</param>
@tab;@tab;/// <param name="useCache">Check the cache for the path in release mode.</param>
@tab;@tab;/// <returns>The path to the localized view file or null if there is no localized view file.</returns>
@tab;@tab;private String GetFilePath(ControllerContext controllerContext, String viewName, bool useCache) {

@tab;@tab;@tab;String result = null;
@tab;@tab;@tab;String controllerName = controllerContext.RouteData.GetRequiredString("controller");
@tab;@tab;@tab;String culture = Thread.CurrentThread.CurrentUICulture.Name;
@tab;@tab;@tab;StringBuilder message = new StringBuilder();

@tab;@tab;@tab;// Define the cache key.

@tab;@tab;@tab;String key = Path.Combine(controllerName, viewName, culture);

#if (!DEBUG)
@tab;@tab;@tab;
@tab;@tab;@tab;// The cache is only use in the release build. That makes it difficult to check in unit testing, but those are the rules.

@tab;@tab;@tab;if (useCache) {

@tab;@tab;@tab;@tab;result = ViewLocationCache.GetViewLocation(controllerContext.HttpContext, key);@tab;@tab;@tab;@tab;
@tab;@tab;@tab;}

#endif

@tab;@tab;@tab;if (result == null) {

@tab;@tab;@tab;@tab;// Try to find the matching view file.

@tab;@tab;@tab;@tab;String language = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName;
@tab;@tab;@tab;@tab;String format1 = "~/Views/{0}/{1}.{2}.{3}";
@tab;@tab;@tab;@tab;String format2 = "~/Views/{0}/{1}.{2}";
@tab;@tab;@tab;@tab;
@tab;@tab;@tab;@tab;// This array contains the structure for each candidate path used in the loop below.

@tab;@tab;@tab;@tab;PathComponent[] components = {
@tab;@tab;@tab;@tab;@tab;new PathComponent(controllerName, culture, "cshtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent(controllerName, culture, "vbhtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent(controllerName, language, "cshtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent(controllerName, language, "vbhtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent("Shared", culture, "cshtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent("Shared", culture, "vbhtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent("Shared", language, "cshtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent("Shared", language, "vbhtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent(controllerName, null, "cshtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent(controllerName, null, "vbhtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent(controllerName, null, "cshtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent(controllerName, null, "vbhtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent("Shared", null, "cshtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent("Shared", null, "vbhtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent("Shared", null, "cshtml"),
@tab;@tab;@tab;@tab;@tab;new PathComponent("Shared", null, "vbhtml"),
@tab;@tab;@tab;@tab;};

@tab;@tab;@tab;@tab;// Stop at the first candidate that can be resolved.

@tab;@tab;@tab;@tab;for (int i = 0; i < components.Length && result == null; i++) {

@tab;@tab;@tab;@tab;@tab;String virtualPath;

@tab;@tab;@tab;@tab;@tab;if (components[i].CultureIdentifier != null) {

@tab;@tab;@tab;@tab;@tab;@tab;virtualPath = String.Format(format1, components[i].ControllerName, viewName, components[i].CultureIdentifier, components[i].Extension);

@tab;@tab;@tab;@tab;@tab;} else {

@tab;@tab;@tab;@tab;@tab;@tab;virtualPath = String.Format(format2, components[i].ControllerName, viewName, components[i].Extension);
@tab;@tab;@tab;@tab;@tab;}

@tab;@tab;@tab;@tab;@tab;if (VirtualPathProvider.FileExists(virtualPath)) {

@tab;@tab;@tab;@tab;@tab;@tab;result = virtualPath;
@tab;@tab;@tab;@tab;@tab;}

@tab;@tab;@tab;@tab;@tab;// Append the candidate to the message to throw if no candidate matches.

@tab;@tab;@tab;@tab;@tab;message.Append(virtualPath);
@tab;@tab;@tab;@tab;@tab;message.Append(Environment.NewLine);
@tab;@tab;@tab;@tab;}
@tab;@tab;@tab;}

#if (!DEBUG)

@tab;@tab;@tab;// Insert the resolved path into the cache, or the empty string to indicate that we didn't find it.

@tab;@tab;@tab;if (result != null) {

@tab;@tab;@tab;@tab;ViewLocationCache.InsertViewLocation(controllerContext.HttpContext, key, result == null ? "" : result);
@tab;@tab;@tab;}

#endif

@tab;@tab;@tab;if (result == null) {

@tab;@tab;@tab;@tab;throw new InvalidOperationException(message.ToString());
@tab;@tab;@tab;}

@tab;@tab;@tab;return result;
@tab;@tab;}

@tab;@tab;/// <summary>
@tab;@tab;/// This structure is used to hold the components of a path to a view implementation,
@tab;@tab;/// used by the GetFilePath method above.
@tab;@tab;/// </summary>
@tab;@tab;private struct PathComponent
@tab;@tab;{

@tab;@tab;@tab;public String ControllerName;
@tab;@tab;@tab;public String CultureIdentifier;
@tab;@tab;@tab;public String Extension;

@tab;@tab;@tab;public PathComponent(String controllerName, String cultureIdentifier, String extension) {

@tab;@tab;@tab;@tab;this.ControllerName = controllerName;
@tab;@tab;@tab;@tab;this.CultureIdentifier = cultureIdentifier;
@tab;@tab;@tab;@tab;this.Extension = extension;
@tab;@tab;@tab;}
@tab;@tab;}
@tab;}
}

Configuration

The final piece is declaring the view to the MVC framework. All of the different configurations have been moved to individual files in the later framework versions, so this extends that pattern with a new file, ViewEngineConfig. This file replaces the view engines with the new class. Since the ASP.NET form view engines are removed at this point there is a significant performance boost by only relying on the one engine:

public class ViewEngineConfig
{
@tab;public static void RegisterViewEngines()
@tab;{

@tab;@tab;// Two goals are accomplished here: first the view engine is replaced with one that handles localized Razor views, and
@tab;@tab;// second the ASP.NET Web Form engine is removed from contention which significantly speeds up the views.

@tab;@tab;ViewEngines.Engines.Clear();
@tab;@tab;ViewEngines.Engines.Add(new LocalizedRazorViewEngine());
@tab;}
}

The file must be included alongside the others in the file Global.asap:

public class MvcApplication : System.Web.HttpApplication
{
@tab;protected void Application_Start()
@tab;{
@tab;@tab;AreaRegistration.RegisterAllAreas();
@tab;@tab;FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
@tab;@tab;RouteConfig.RegisterRoutes(RouteTable.Routes);
@tab;@tab;BundleConfig.RegisterBundles(BundleTable.Bundles);
@tab;@tab;ViewEngineConfig.RegisterViewEngines();
@tab;}
}

Conclusion

The replacement view engine supports localized views and still falls back to the base view file if a localized version is not found. To localize any specific view create parallel files with culture or language names using the same rules as used for resource files.

1 comment: