Adapting the Microsoft Chart WebControl to MVC

The Chart control for ASP.Net that comes baked into .NET 4 or as an add-on to .NET 3 is useful but it is built around the WebForms rendering model which is uncomfortable in MVC applications. There is no pretty way to embed the WebChart control out-of-the-box. Solutions I have seen revolve around either giving up on the MVC separation and embedding the control into a View or creating custom web user controls for each chart and treating these as partial views. The former solution will only work with the WebForm view engine and not Razor or other view engines and the latter is just messy. Either way it feels like walking around with toilet paper stuck to your shoe. A third way is to return the image bytes of from a Chart object created in the controller as a FileResult. This works but you lose the image map tooltips.

The crux of the problem is that Chart is a DataBoundControl which is designed to magically generate its own HTML and to serve an image resource to itself when the rendered HTML is interpreted. It isn’t really meant to have an external controller tell it what to do. However, the Chart control has some nice features:

  1. It generates cross-browser friendly output
  2. It generates mouseover tooltips
  3. It doesn’t require Flash, Silverlight or JavaScipt because it uses MAP and AREA elements to create the tooltips
  4. With a little coercion, it will generate its image resource for you as a byte array which means you can alternatively embed the chart in an alternate resource like a PDF or other document envelope

Chart the MVC Way

Having decided that the Chart control does something we want, can we bend it to the MVC rendering model while preserving the nice tooltip image maps? The answer is yes we can. I have created a handful of extension methods that turn Chart on its head and allow you to render it out of a Controller class as an ActionResult. I’ll show you how things look in the View and Controller and then walk you through how it works.

View

<% Html.RenderAction( "PerformanceHistoryChart", new { id = Model.Id, shareClass = Model.ShareClass } ); %>

Controller

[OutputCache( Duration = 10, VaryByParam = "id;shareClass" )]
public ActionResult PerformanceHistoryChart( Guid id, string shareClass )
{
    return this.RenderChartMap( "PerformanceHistoryImage", id, shareClass, NewPerformanceHistoryChart );
}

[OutputCache( Duration = 10, VaryByParam = "id;shareClass" )]
public ActionResult PerformanceHistoryImage( Guid id, string shareClass )
{
    return this.RenderChartImage( id, shareClass, NewPerformanceHistoryChart );
}

private static Chart NewPerformanceHistoryChart( Guid id, string shareClass )
{
    Chart chart = new Chart();
    var series = perfChart.Series.Add( "Default" );
    var area   = perfChart.ChartAreas.Add( "ChartArea" );
    var values = ReportUtility.GetNetReturnOnValuePercentageHistory( id, shareClass );
    series.Points.DataBindXY( values.Item1, values.Item2 );
    series.ToolTip = "Fiscal Year: #VALX, Growth: #VALY{P}";
    area.AxisY.Title = "%";
    area.AxisY.LabelStyle.Format = "{P2}";
    return chart;
}

What’s going on in the View is that we are rendering the result of calling PerformanceHistoryChart on the controller. PerformanceHistoryChart renders HTML which includes an IMG tag reference to the PerformanceHistoryImage action which streams back the chart image as a PNG.PerformanceHistoryChart and PerformanceHistoryImage make use of my Chart to MVC adapter extension methods to do their work. There are two sets of extension method overloads:

  • this.RenderChartMap
  • this.RenderChartImage

In order to render any chart to a browser you need to provide two action methods. One of them calls RenderChartMap which generates HTML and the other calls RenderChartImage which returns the image as a FileResult for the IMG tag generated by RenderChartMap. Both extension methods accept a factory Func delegate which they use to create the Chart object that they manipulate internally. I’ve created overloads which accept 1 to 5 arguments.

MvcChart Extension Method Adapter Class

I’m sure this could be made more elegant but my first cut is working well. One limitation of my implementation is that you need a pair of extension method overloads that has enough arguments for your Chart factory Func  to work. Another issue is that you need to have an explicit route defined for the Actions which return the chart image FileContentResult. The reason is that the Chart control always appends a ?<some-guid> which is part of the caching mechanism it uses for storing images when it is rendering itself as a WebForms control. Therefore, I need to have an explicit route without any query string and allow the query string the Chart control generates to dangle off the end. My assumption is that the arguments will be passed in order from left to right in the URL as they appear in the method call. Also, since we are taking over rendering the Chart control via our Controller we give up the baked-in image caching. This is probably not much of an issue but it makes sense to use OutputCaching attributes to reduce the hits on the Chart factory Funcs and therefore your database.

Here’s my MvcChart class which contains the adapter extension methods. Happy coding.

using System.IO;
using System.Web.UI;
using System.Web.UI.DataVisualization.Charting;

namespace System.Web.Mvc
{
    /// <summary>
    /// <para>This class adapts the System.Web.UI.DataVisualization.Charting.Chart API to something
    /// more friendly to ASP.Net MVC by attaching extension methods to the Controller type.Requires
    /// an explicit route mapping for all arguments. The arguments will be inserted into the Image 
    /// generating URL in order.</para>
    /// <para>In each controller we need a pair of ActionMethods. One generates the IMG, MAP and AREA tags.
    /// The other streams the chart PNG. The URL to the chart PNG method is referenced by the imageActionName
    /// parameter in the RenderChartMap() method call. We also need a factory method which generates the Chart
    /// object instance. The chart factory is passed as a delegate to these adapter extension methods.</para>
    /// <remarks>In order to render each chart in a browser, the factory method must be executed twice.
    /// Consider using the OutputCache attribute to reduce hits on the data backend.</remarks>
    /// </summary>
    public static class MvcChart
    {
        /// <summary>
        /// Render img tag and associated image map for a chart.
        /// </summary>
        /// <typeparam name="T">Type of the factory argument.</typeparam>
        /// <param name="imageActionName">Method name in this controller that streams the chart image.</param>
        /// <param name="arg">Id used by the chart factory to find the data to generate the chart.</param>
        /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param>
        /// <returns>ContentResult that generates HTML for the chart.</returns>
        public static ContentResult RenderChartMap<T>( this Controller controller, string imageActionName,
            T arg, Func<T, Chart> chartFactory )
        {
            Chart chart = chartFactory.Invoke( arg );
            //FWIW: ImageLocation URL will have ?{some-guid} appended which is part of its classic WebForms ASP.Net
            //web control rendering model
            chart.ImageLocation = string.Format( "{0}{1}/{2}/{3}/", 
                GetApplicationPath( controller ), 
                GetControllerName( controller ), 
                imageActionName, 
                arg );
            return RenderChartMap( chart );
        }

        /// <summary>
        /// Render img tag and associated image map for a chart.
        /// </summary>
        /// <typeparam name="T1">Type of the first factory argument.</typeparam>
        /// <typeparam name="T2">Type of the second factory argument.</typeparam>
        /// <param name="controller">Controller instance.</param>
        /// <param name="imageActionName">Method name in this controller that streams the chart image.</param>
        /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param>
        /// <param name="arg2">Second parameter used by the chart factory.</param>
        /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param>
        /// <returns>ContentResult that generates HTML for the chart.</returns>
        public static ContentResult RenderChartMap<T1,T2>( this Controller controller, string imageActionName, 
            T1 arg1, T2 arg2, Func<T1, T2, Chart> chartFactory )
        {
            Chart chart = chartFactory.Invoke( arg1, arg2 );
            chart.ImageLocation = string.Format( "{0}{1}/{2}/{3}/{4}/", 
                GetApplicationPath( controller ), 
                GetControllerName( controller ), 
                imageActionName, 
                arg1, 
                arg2 );
            return RenderChartMap( chart );
        }

        /// <summary>
        /// Render img tag and associated image map for a chart.
        /// </summary>
        /// <typeparam name="T1">Type of the first factory argument.</typeparam>
        /// <typeparam name="T2">Type of the second factory argument.</typeparam>
        /// <typeparam name="T3">Type of the third factory argument.</typeparam>
        /// <param name="controller">Controller instance.</param>
        /// <param name="imageActionName">Method name in this controller that streams the chart image.</param>
        /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param>
        /// <param name="arg2">Second parameter used by the chart factory.</param>
        /// <param name="arg3">Third parameter used by the chart factory.</param>
        /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param>
        /// <returns>ContentResult that generates HTML for the chart.</returns>
        public static ContentResult RenderChartMap<T1, T2, T3>( this Controller controller, string imageActionName,
            T1 arg1, T2 arg2, T3 arg3, Func<T1, T2, T3, Chart> chartFactory )
        {
            Chart chart = chartFactory.Invoke( arg1, arg2, arg3 );
            chart.ImageLocation = string.Format( "{0}{1}/{2}/{3}/{4}/{5}/",
                GetApplicationPath( controller ),
                GetControllerName( controller ),
                imageActionName,
                arg1,
                arg2,
                arg3 );
            return RenderChartMap( chart );
        }

        /// <summary>
        /// Render img tag and associated image map for a chart.
        /// </summary>
        /// <typeparam name="T1">Type of the first factory argument.</typeparam>
        /// <typeparam name="T2">Type of the second factory argument.</typeparam>
        /// <typeparam name="T3">Type of the third factory argument.</typeparam>
        /// <typeparam name="T4">Type of the fourth factory argument.</typeparam>
        /// <param name="controller">Controller instance.</param>
        /// <param name="imageActionName">Method name in this controller that streams the chart image.</param>
        /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param>
        /// <param name="arg2">Second parameter used by the chart factory.</param>
        /// <param name="arg3">Third parameter used by the chart factory.</param>
        /// <param name="arg4">Fourth parameter used by the chart factory.</param>
        /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param>
        /// <returns>ContentResult that generates HTML for the chart.</returns>
        public static ContentResult RenderChartMap<T1, T2, T3, T4>( this Controller controller, string imageActionName,
            T1 arg1, T2 arg2, T3 arg3, T4 arg4, Func<T1, T2, T3, T4, Chart> chartFactory )
        {
            Chart chart = chartFactory.Invoke( arg1, arg2, arg3, arg4 );
            chart.ImageLocation = string.Format( "{0}{1}/{2}/{3}/{4}/{5}/{6}/",
                GetApplicationPath( controller ),
                GetControllerName( controller ),
                imageActionName,
                arg1,
                arg2,
                arg3,
                arg4);
            return RenderChartMap( chart );
        }

        /// <summary>
        /// Render img tag and associated image map for a chart.
        /// </summary>
        /// <typeparam name="T1">Type of the first factory argument.</typeparam>
        /// <typeparam name="T2">Type of the second factory argument.</typeparam>
        /// <typeparam name="T3">Type of the third factory argument.</typeparam>
        /// <typeparam name="T4">Type of the fourth factory argument.</typeparam>
        /// <typeparam name="T5">Type of the fifth factory argument.</typeparam>
        /// <param name="imageActionName">Method name in this controller that streams the chart image.</param>
        /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param>
        /// <param name="arg2">Second parameter used by the chart factory.</param>
        /// <param name="arg3">Third parameter used by the chart factory.</param>
        /// <param name="arg4">Fourth parameter used by the chart factory.</param>
        /// <param name="arg5">Fifth parameter used by the chart factory.</param>
        /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param>
        /// <returns>ContentResult that generates HTML for the chart.</returns>
        public static ContentResult RenderChartMap<T1, T2, T3, T4, T5>( this Controller controller, string imageActionName,
            T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, Func<T1, T2, T3, T4, T5, Chart> chartFactory )
        {
            Chart chart = chartFactory.Invoke( arg1, arg2, arg3, arg4, arg5 );
            chart.ImageLocation = string.Format( "{0}{1}/{2}/{3}/{4}/{5}/{6}/{7}/",
                GetApplicationPath( controller ),
                GetControllerName( controller ),
                imageActionName,
                arg1,
                arg2,
                arg3,
                arg4,
                arg5 );
            return RenderChartMap( chart );
        }

        /// <summary>
        /// Render chart image byte stream as a PNG file.
        /// </summary>
        /// <typeparam name="T">Type of the id.</typeparam>
        /// <param name="controller">Controller instance.</param>
        /// <param name="arg">Id used by the chart factory to find the data to generate the chart.</param>
        /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param>
        /// <returns></returns>
        public static FileContentResult RenderChartImage<T>( this Controller controller, T arg, Func<T, Chart> chartFactory )
        {
            Chart chart = chartFactory.Invoke( arg );
            return RenderChartImage( chart );
        }

        /// <summary>
        /// Render chart image byte stream as a PNG file.
        /// </summary>
        /// <typeparam name="T1">Type of the first factory parameter.</typeparam>
        /// <typeparam name="T2">Type of the second factory parameter.</typeparam>
        /// <param name="controller">Controller instance.</param>
        /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param>
        /// <param name="arg2">Second parameter used by the chart factory.</param>
        /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param>
        /// <returns></returns>
        public static FileContentResult RenderChartImage<T1, T2>( this Controller controller, 
            T1 arg1, T2 arg2, Func<T1, T2, Chart> chartFactory )
        {
            Chart chart = chartFactory.Invoke( arg1, arg2 );
            return RenderChartImage( chart );
        }

        /// <summary>
        /// Render chart image byte stream as a PNG file.
        /// </summary>
        /// <typeparam name="T1">Type of the first factory parameter.</typeparam>
        /// <typeparam name="T2">Type of the second factory parameter.</typeparam>
        /// <typeparam name="T3">Type of the third factory argument.</typeparam>
        /// <param name="controller">Controller instance.</param>
        /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param>
        /// <param name="arg2">Second parameter used by the chart factory.</param>
        /// <param name="arg3">Third parameter used by the chart factory.</param>
        /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param>
        /// <returns></returns>
        public static FileContentResult RenderChartImage<T1, T2, T3>( this Controller controller, 
            T1 arg1, T2 arg2, T3 arg3, Func<T1, T2, T3, Chart> chartFactory )
        {
            Chart chart = chartFactory.Invoke( arg1, arg2, arg3 );
            return RenderChartImage( chart );
        }

        /// <summary>
        /// Render chart image byte stream as a PNG file.
        /// </summary>
        /// <typeparam name="T1">Type of the first factory parameter.</typeparam>
        /// <typeparam name="T2">Type of the second factory parameter.</typeparam>
        /// <typeparam name="T3">Type of the third factory argument.</typeparam>
        /// <typeparam name="T4">Type of the fourth factory argument.</typeparam>
        /// <param name="controller">Controller instance.</param>
        /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param>
        /// <param name="arg2">Second parameter used by the chart factory.</param>
        /// <param name="arg3">Third parameter used by the chart factory.</param>
        /// <param name="arg4">Fourth parameter used by the chart factory.</param>
        /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param>
        /// <returns></returns>
        public static FileContentResult RenderChartImage<T1, T2, T3, T4>( this Controller controller,
            T1 arg1, T2 arg2, T3 arg3, T4 arg4, Func<T1, T2, T3, T4,  Chart> chartFactory )
        {
            Chart chart = chartFactory.Invoke( arg1, arg2, arg3, arg4 );
            return RenderChartImage( chart );
        }

        /// <summary>
        /// Render chart image byte stream as a PNG file.
        /// </summary>
        /// <typeparam name="T1">Type of the first factory parameter.</typeparam>
        /// <typeparam name="T2">Type of the second factory parameter.</typeparam>
        /// <typeparam name="T3">Type of the third factory argument.</typeparam>
        /// <typeparam name="T4">Type of the fourth factory argument.</typeparam>
        /// <typeparam name="T5">Type of the fifth factory argument.</typeparam>
        /// <param name="controller">Controller instance.</param>
        /// <param name="arg1">Id used by the chart factory to find the data to generate the chart.</param>
        /// <param name="arg2">Second parameter used by the chart factory.</param>
        /// <param name="arg3">Third parameter used by the chart factory.</param>
        /// <param name="arg4">Fourth parameter used by the chart factory.</param>
        /// <param name="arg5">Fourth parameter used by the chart factory.</param>
        /// <param name="chartFactory">Delegate capable of generating a new Chart instance.</param>
        /// <returns></returns>
        public static FileContentResult RenderChartImage<T1, T2, T3, T4, T5>( this Controller controller,
            T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, Func<T1, T2, T3, T4, T5, Chart> chartFactory )
        {
            Chart chart = chartFactory.Invoke( arg1, arg2, arg3, arg4, arg5 );
            return RenderChartImage( chart );
        }
        
        private static ContentResult RenderChartMap( Chart chart )
        {
            chart.RenderType = RenderType.ImageMap;
            //Make sure that each chart rendered into the page has a unique ID in HTML by
            //assigning a Guid to the ID property. Otherwise, they will all have the same
            //ID and the image map will only work for the first chart at best.
            chart.ID = Guid.NewGuid().ToString( "N" );
            using( var writer = new StringWriter() )
            using( var htmlTextWriter = new HtmlTextWriter( writer ) )
            {
                chart.RenderControl( htmlTextWriter );
                return new ContentResult() { Content = writer.ToString() };
            }
        }

        private static FileContentResult RenderChartImage( Chart chart )
        {
            using( MemoryStream chartStream = new MemoryStream() )
            {
                chart.SaveImage( chartStream, ChartImageFormat.Png );
                return new FileContentResult( chartStream.ToArray(), "image/png" ) { FileDownloadName = "chart.png" };
            }
        }

        private static string GetApplicationPath( Controller controller )
        {
            var applicationPath = controller.HttpContext.Request.ApplicationPath;
            applicationPath = applicationPath.EndsWith( "/" ) ? applicationPath : applicationPath + "/";
            return applicationPath;
        }

        private static string GetControllerName( Controller controller )
        {
            var controllerName = controller.GetType().Name;
            if( !controllerName.EndsWith( "Controller" ) )
                throw new InvalidOperationException( "Controller names must end with \"Controller\"" );

            controllerName = controllerName.Substring( 0, controllerName.Length - 10 ); ;
            return controllerName;
        }
    }
}

Advertisements

Submitting an MVC Ajax.BeginForm Using JavaScript and jQuery

The Ajax.BeginForm() helper method in ASP.Net MVC generates a bit of inline JavaScript attached to the onsubmit event of the form. When the form is submitted in the usual manner with a submit button the request is sent using XMLHttpRequest and the result is returned into the <DIV /> of your choice. However, if you want try to submit the form using JavaScript things are less tidy.

For my example, imagine a user account management tool that displays a grid of accounts and has a number batch of operations that you can perform against the grid data in an Ajax-y manner.account-mgmt

The idea here is that you could, for example, check a number of accounts and then click disable and those accounts would be disabled. The result of your operation gets written into a notification <DIV />.

Here’s the naïve implementation for submitting an MVC Ajax form.

jQuery().ready(function () {
	//other stuff including jqGrid initialization here...

	//'grid' is the id of the jqGrid table element
	//'disableKeys' is the id of the Ajax form we are submitting.
	$('form#disableAccounts').find('a.submit-link').click(function () {
	    //work to set the state of the form before sending it to the server here...
	    $('form#disableAccounts').submit();
	});
)};

Unfortunately, this doesn’t do at all what we want. What you end up with is the text that was intended for the notification <DIV /> replacing the entire window contents. In other words, a classic POST. But wait, there’s more.

debug-double-post

What actually happens is that the request is submitted twice! The first version is Ajax and the second is classic POST, interrupting the Ajax response.

The First Solution

My initial approach to solving this double-submit problem hinged on leveraging the fact that Ajax.BeginForm() generates a <FORM /> tag with some JavaScript attached to the onsubmit event to handle the Ajax behavior. Why not just trigger the onsubmit event directly?

jQuery().ready(function () {
	//other stuff including jqGrid initialization here...

	//'grid' is the id of the jqGrid table element
	//'disableKeys' is the id of the Ajax form we are submitting.
	$('form#disableAccounts').find('a.submit-link').click(function () {
	    //work to set the state of the form before sending it to the server here...
	    $('form#disableAccounts').trigger('onsubmit');
	});
)};

This works great except in Safari where nothing happens at all.

The Final Solution

I tried a number of different techniques to suppress the default form POST behavior. The one that worked in IE, Firefox, Chrome and Safari is to attach a new event handler to the submit event that always returns false.

jQuery().ready(function () {
	//other stuff including jqGrid initialization here...

	//prevent the form from submitting in a non-Ajax manner for all browsers
	$('form#disableAccounts').submit(function (event) { eval($(this).attr("submit")); return false; });

	//'grid' is the id of the jqGrid table element
	//'disableKeys' is the id of the Ajax form we are submitting.
	$('form#disableAccounts').find('a.submit-link').click(function () {
	    //work to set the state of the form before sending it to the server here...
	    $('form#disableAccounts').submit();
	});
)};

This works to suppress the second standard POST and allow the ASP.Net Ajax behavior to complete as expected.

Configuring ASP.Net MVC for the Microsoft Charting Control

There are a couple of gotchas when using either the .NET 3.5 add-on charting control or the one that ships with .NET 4.0 with ASP.Net MVC. These can lead to some head-scratching and consternation at deployment time if you don’t know about them.

MVC Paths Break Chart Images

MVC paths are RESTful which means that a lot of the path in a URL is likely to be arguments to a controller rather than an actual path to a resource. Unfortunately, by default these paths confuse the charting control which assumes that the path in the URL leads to the resource hosting the control. The net result is that invoking RESTful methods on your controller causes your chart images go missing. The solution is to use a little bit of regular expression to create a “ignore” routing rule in Global.asax.cs for ChartImg.axd which is the virtual file that generates the charting control images.

public static void RegisterRoutes( RouteCollection routes )
{
    //MSFT charting control image genertor
    RouteTable.Routes.IgnoreRoute( "{*chartimg}", new { chartimg = @".*/ChartImg\.axd(/.*)?" } ); 
    //standard default route ignore rule
    RouteTable.Routes.IgnoreRoute( "{resource}.axd/{*pathInfo}" );

    routes.MapRoute(
        "Default", // Route name
        "{controller}/{action}/{id}", // URL with parameters
        new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
    );
}

Charting Control Wants to Write to Your Application Root

Another problem that will show up at deployment time is that the charting control wants to write cached images to the root of your application by default and it wants to do this in the security context of the invoking user. That is, in order for this default to work, anonymous web users need write access to the root of your application. This is a bad, bad, bad idea.

Fortunately this behavior can be configured in the Web.config. You can change the cache directory or use global application memory or session to store the images. Using memory has the advantage that you don’t need to give anybody permission to write into the server file system.

<configuration>
  <appSettings>
    <add key="ChartImageHandler" value="storage=memory;timeout=20;privateImages=false" />
  </appSettings>
<configuration>

The full set of configuration options are described on MSDN.

Simple ASP.Net MVC Globalization with Graceful Fallback

We recently began work on a website project using ASP.Net MVC 3.0 where one of the requirements was that the site will be globalized into different languages. However, one of the design considerations is that the content is going to be originally produced in English and then translated and published as available into the other supported languages. What the site needs to do is gracefully degrade to English when the desired globalized content is not available.

Other issues are that some languages are wordier or  more compact than English which can affect the graphic design. For example “Structured Investment Vehicles” (30 characters) translates in French to “Véhicules d’Investissement Structurés” (37 characters). The French version requires 23% characters which is—roughly speaking (because of proportional fonts)—about 20% more space than the English version. It’s pretty typical for French phrases to contain 20-30% more characters than the English equivalent which means that resource file strings and just swapping out text can ruin the visual design layout. What is needed is re-layout of parts of views to accommodate the space consumed by different languages.

The idea is that we have English as an invariant which has every view the site uses in the normal place. We then have a /Views/Globalization directory which contains a subdirectory with the ISO 639-1 two-letter macro-language code ( ar = Arabic, fr = French, es = Spanish, etc.) which contain whatever translated content is available. If the browser requests a particular non-English language translation, we want to views to come from the appropriate Globalization directory  but if the content doesn’t exist, we fall back to English.

Our solution is to use views, partial views and master pages as the unit of globalization. In order to do this we created a new IViewEngine which extends whatever view engine we would otherwise be using. Our team is comfortable with WebForms so we extended WebFormViewEngine.

Example View Layout

/Views
    /Globalization
        /ar
            /Home
                /Index.aspx
            /Shared
                /Site.master
                /Navigation.aspx
        /es
            /Home
                /Index.aspx
            /Shared
                /Navigation.aspx
        /fr
            /Home
                /Index.aspx
            /Shared
    /Home
        /Index.aspx
    /Shared
        /Error.aspx
        /Footer.aspx
        /Navigation.aspx
        /Site.master

In the sample above, French is not fully globalized so when French views are served, the Navigation.ascx partial view should be served from /Views/Shared/Navigation.ascx, the Arabic views declare themselves as using the /Views/Globalization/ar/Shared/Site.master rather than /Views/Shared/Site.master and the default English Error.aspx view is always served.

jQuery Script to Switch Languages

/// <reference path="jquery-1.4.4.js" />
/// <reference path="jquery.cookie.js" />

//declare namespace to avoid collisions with extension js in the browser
var mySiteNamespace = {};

mySiteNamespace.switchLanguage = function (lang) {
    $.cookie('language', lang);
    window.location.reload();
};

$(document).ready(function () {
    // attach mySiteNamespace.switchLanguage to click events based on css classes
    $('.lang-english').click(function () { mySiteNamespace.switchLanguage('en'); });
    $('.lang-french').click(function () { mySiteNamespace.switchLanguage('fr'); });
    $('.lang-arabic').click(function () { mySiteNamespace.switchLanguage('ar'); });
    $('.lang-spanish').click(function () { mySiteNamespace.switchLanguage('es'); });
});

This little JavaScript attaches a switchLanguage function to elements (such as <a/>) which declare the specified css classes. It sets a language cookie which our custom IViewEngine is looking for.

Extending WebFormViewEngine

The key methods to override when extending WebFormViewEngine are CreatePartialView and CreateView. What we need to do is detect what language the browser is requesting and then choose the correct globalized view if it exists and fall back to the default behavior if it doesn’t. For simplicity, I’m showing code that uses a cookie to determine what language the end user wants but this detection can be as sophisticated as you like.

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

namespace System.Web.Mvc
{
    public sealed class WebFormGlobalizationViewEngine : WebFormViewEngine
    {
        protected override IView CreatePartialView( ControllerContext controllerContext, string partialPath )
        {
            partialPath = GlobalizeViewPath( controllerContext, partialPath );
            return new WebFormView( controllerContext, partialPath, null, ViewPageActivator );
        }

        protected override IView CreateView( ControllerContext controllerContext, string viewPath, string masterPath )
        {
            viewPath = GlobalizeViewPath( controllerContext, viewPath );
            return base.CreateView( controllerContext, viewPath, masterPath );
        }

        private static string GlobalizeViewPath( ControllerContext controllerContext, string viewPath )
        {
            var request = controllerContext.HttpContext.Request;
            var lang = request.Cookies["language"];
            if( lang != null &&
                !string.IsNullOrEmpty(lang.Value) &&
                !string.Equals( lang.Value, "en", StringComparison.InvariantCultureIgnoreCase ) )
            {
                string localizedViewPath = Regex.Replace(
                    viewPath,
                    "^~/Views/",
                    string.Format( "~/Views/Globalization/{0}/",
                    lang.Value
                    ) );
                if( File.Exists( request.MapPath( localizedViewPath ) ) )
                { viewPath = localizedViewPath; }
            }
            return viewPath;
        }
    }
}

Registration in Global.asax.cs

The final step is to register WebFormsGlobalizationViewEngine when the Application.Start event fires.

protected void Application_Start()
{

    ViewEngines.Engines.Clear();
    ViewEngines.Engines.Add( new WebFormGlobalizationViewEngine() );

    // rest of application start stuff
}

Summing Up

There are a lot of ways to globalize content. This solution has the advantage of being straightforward and flexible. The main disadvantage is that we have to maintain translations of all the views and partial views.

Custom JsonResult Class for ASP.Net MVC to Avoid MaxJsonLength Exceeded Exception

Shortly before Christmas, I was working on an application that sent a large data set to jqGrid using an Ajax JSON stream in ASP.Net MVC. We were trying out using a “get everything once” model where all of the I/O happens when jqGrid is initialized or reset and then all of the data is available on the client for fast sort and filter. It was working well with test data of ~5-6K rows until a colleague checked in a change that added a new  column. Suddenly my jqGrid was blank while hers (with a different, somewhat smaller, set of test data) was fine. Usually this sort of behavior from jqGrid indicates that the JSON was broken. Sure enough when I fired up my Fiddler HTTP debugger, I saw an error 500 for the JSON ajax query.

Server Error in ‘/’ Application.


Error during serialization or deserialization using the JSON JavaScriptSerializer. The length of the string exceeds the value set on the maxJsonLength property.

Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code. 

Exception Details: System.InvalidOperationException: Error during serialization or deserialization using the JSON JavaScriptSerializer. The length of the string exceeds the value set on the maxJsonLength property.

Source Error: 

An unhandled exception was generated during the execution of the current web request. Information regarding the origin and location of the exception can be identified using the exception stack trace below.

Stack Trace: 

[InvalidOperationException: Error during serialization or deserialization using the JSON JavaScriptSerializer. The length of the string exceeds the value set on the maxJsonLength property.]
   System.Web.Script.Serialization.JavaScriptSerializer.Serialize(Object obj, StringBuilder output, SerializationFormat serializationFormat) +551497
   System.Web.Script.Serialization.JavaScriptSerializer.Serialize(Object obj, SerializationFormat serializationFormat) +74
   System.Web.Script.Serialization.JavaScriptSerializer.Serialize(Object obj) +6
   System.Web.Mvc.JsonResult.ExecuteResult(ControllerContext context) +341
   System.Web.Mvc.ControllerActionInvoker.InvokeActionResult(ControllerContext controllerContext, ActionResult actionResult) +10
   System.Web.Mvc.<>c__DisplayClass14.<InvokeActionResultWithFilters>b__11() +20
   System.Web.Mvc.ControllerActionInvoker.InvokeActionResultFilter(IResultFilter filter, ResultExecutingContext preContext, Func`1 continuation) +251
   System.Web.Mvc.<>c__DisplayClass16.<InvokeActionResultWithFilters>b__13() +19
   System.Web.Mvc.ControllerActionInvoker.InvokeActionResultWithFilters(ControllerContext controllerContext, IList`1 filters, ActionResult actionResult) +178
   System.Web.Mvc.ControllerActionInvoker.InvokeAction(ControllerContext controllerContext, String actionName) +314
   System.Web.Mvc.Controller.ExecuteCore() +105
   System.Web.Mvc.ControllerBase.Execute(RequestContext requestContext) +39
   System.Web.Mvc.ControllerBase.System.Web.Mvc.IController.Execute(RequestContext requestContext) +7
   System.Web.Mvc.<>c__DisplayClass8.<BeginProcessRequest>b__4() +34
   System.Web.Mvc.Async.<>c__DisplayClass1.<MakeVoidDelegate>b__0() +21
   System.Web.Mvc.Async.<>c__DisplayClass8`1.<BeginSynchronous>b__7(IAsyncResult _) +12
   System.Web.Mvc.Async.WrappedAsyncResult`1.End() +59
   System.Web.Mvc.MvcHandler.EndProcessRequest(IAsyncResult asyncResult) +44
   System.Web.Mvc.MvcHandler.System.Web.IHttpAsyncHandler.EndProcessRequest(IAsyncResult result) +7
   System.Web.CallHandlerExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute() +8682542
   System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously) +155


Version Information: Microsoft .NET Framework Version:2.0.50727.4952; ASP.NET Version:2.0.50727.4955

 

The error is inside of a call from MVC to BCL

It turns out that the exception happens inside of JsonResult.ExecuteResult(ControllerContext context). What is going on in there? Well, fortunately, ASP.Net MVC code is open source (MS-PL) and the .NET Framework class library source code is available for reference as well. Let’s take a look.

The meat of JsonResult.ExecuteResult(ControllerContext)

if (Data != null) {
    JavaScriptSerializer serializer = new JavaScriptSerializer();
    response.Write(serializer.Serialize(Data));
}

The meat of JavaScriptSerializer.Serialize(object obj)

public string Serialize(object obj) {
    return Serialize(obj, SerializationFormat.JSON);
}

private string Serialize(object obj, SerializationFormat serializationFormat) {
    StringBuilder sb = new StringBuilder(); 
    Serialize(obj, sb, serializationFormat); 
    return sb.ToString();
}

internal void Serialize(object obj, StringBuilder output, SerializationFormat serializationFormat) {
    SerializeValue(obj, output, 0, null, serializationFormat); 
    // DevDiv Bugs 96574: Max JSON length does not apply when serializing to Javascript for ScriptDescriptors
    if (serializationFormat == SerializationFormat.JSON && output.Length > MaxJsonLength) {
        throw new InvalidOperationException(AtlasWeb.JSON_MaxJsonLengthExceeded);
    } 
}

JavaScriptSerializer completely serializes object obj into StringBuilder output and then, having allocated that memory, checks the size of the StringBuilder and if it is larger than the MaxJsonLength property it throws an InvalidOperationException. JsonResult just creates a new JavaScriptSerializer and uses it so there is no way to change the default MaxJsonLength when using JsonResult in MVC. Since the memory is allocated before the InvalidOperationException is thrown, I’m not really clear what the point of MaxJsonLength is this deep in the framework. Surely whatever is going to use the JSON string would be in a better position to decide if the string returned by JavaScriptSerializer.Serialize() was too long to use?

Anyway, we have the problem isolated now for a solution. We need to implement our own ActionResult that will generate JSON while allowing the caller to twiddle the knobs on JavaScriptSerializer.

LargeJsonResult ActionResult class

using System;
using System.Web.Script.Serialization;

namespace System.Web.Mvc
{
    public class LargeJsonResult : JsonResult
    {
        const string JsonRequest_GetNotAllowed = "This request has been blocked because sensitive information could be disclosed to third party web sites when this is used in a GET request. To allow GET requests, set JsonRequestBehavior to AllowGet.";
        public LargeJsonResult()
        {
            MaxJsonLength = 1024000;
            RecursionLimit = 100;
        }

        public int MaxJsonLength { get; set; }
        public int RecursionLimit { get; set; }

        public override void ExecuteResult( ControllerContext context )
        {
            if( context == null )
            {
                throw new ArgumentNullException( "context" );
            }
            if( JsonRequestBehavior == JsonRequestBehavior.DenyGet &&
                String.Equals( context.HttpContext.Request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase ) )
            {
                throw new InvalidOperationException( JsonRequest_GetNotAllowed );
            }

            HttpResponseBase response = context.HttpContext.Response;

            if( !String.IsNullOrEmpty( ContentType ) )
            {
                response.ContentType = ContentType;
            }
            else
            {
                response.ContentType = "application/json";
            }
            if( ContentEncoding != null )
            {
                response.ContentEncoding = ContentEncoding;
            }
            if( Data != null )
            {
                JavaScriptSerializer serializer = new JavaScriptSerializer() { MaxJsonLength = MaxJsonLength, RecursionLimit = RecursionLimit };
                response.Write( serializer.Serialize( Data ) );
            }
        }
    }
}

You can use return new LargeJsonResult(){ Data = data } from any Action method where you would have used return Json(data). Also, you have direct control over the MaxJsonLength and RecursionLimit properites of JavaScriptSerializer.

return new LargeJsonResult() { Data = output, MaxJsonLength = int.MaxValue };
%d bloggers like this: