Customizing ASP.NET MVC View Results

A key benefit of the ASP.NET MVC Framework is extensibility. I'm constantly amazed at how easy this strength is to leverage when it comes to meeting my business needs. In this article I'll provide an overview of ways to provide search engine optimization (SEO)-friendly and user-friendly error messages by creating custom ActionResult implementations that leverage built-in support for rendering ASP.NET MVC Views. For more information on ASP.NET and SEO, see "A Simple Technique for Improving SEO with ASP.NET MVC" and "ASP.NET MVC and Search Engine Optimization."

ASP.NET MVC and SEO for Missing Content Redux

In "ASP.NET MVC, SEO, and NotFoundResults: A Better Way to Handle Missing Content," I covered an approach to managing missing content with ASP.NET MVC in a more SEO-friendly way than using built-in error handling. In that article, I pointed out that one of the problems with ASP.NET's built-in error handling (which is used by ASP.NET MVC) is that it results in client-side redirects. Redirecting works well enough for showing users “pretty” (or user-friendly) error messages when they're on your site, but it's a poor choice from an SEO standpoint. Consequently, in my previous article, I detailed how to throw custom HTTP Status Codes (such as 410, 404, 301, and so on) without needing client-side redirection while still rendering pretty error messages for end-users.

In using that approach in my own projects though, I've noticed that it had a couple of flaws. Specifically, with that approach the ExecuteResult method for my previous ErrorResult and NotFoundResult custom ActionResult implementations were “re-throwing” the request out to a new routing handler that went through the entire ASP.NET MVC pipeline to map the request  back onto a specialized Controller Action that emits HTTP Status Codes and render a custom view. While that approach worked, its biggest drawback was that it required a secondary form of mapping, or routing, that resulted in an ErrorController that didn't really do anything other than route errors.

Happily, in re-examining that approach I've come up with a better and more logical way to achieve the same semantics (of using an ErrorResult or NotFoundResult to more easily meet the need for both SEO-friendly and user-friendly responses), without needing all of that ugly mapping. Furthermore, the new approach I've adopted is much more extensible and will lend itself to a number of other needs and uses as well.

Building a CustomizedViewResult

To solve my problem, I don't need a new or custom ViewEngine. Instead, I just want to be able to leverage the way that ASP.NET MVC's built-in ViewResult handles results or views - while having it kick out custom Http Status codes. Consequently, I've implemented my own 'version' of ASP.NET MVC's ViewResult. Or, at least, I built a custom ActionResult implementation that behaves in roughly the same way. To do this, I just created a new, CustomizedViewResult, with two Properties (ViewName and MasterName) along with a more-or-less blank ExecuteResult method:

public override void ExecuteResult(ControllerContext context)

\\{

    this.ProcessResult(context);

\\}

Of course, as you can see, there is a call in there to a method called ProcessResult. That method, in turn, is as follows:

// 'glue' to bind this customAction Result to the 'ViewResult' functionality

//      provided by ASP.NET MVC.

protected void ProcessResult(ControllerContext context)

\\{

    ViewEngineResult result =

        this.FindView(context, this.ViewName, this.MasterName);

    ViewDataDictionary viewData = context.Controller.ViewData;

    TempDataDictionary tempData = context.Controller.TempData;

 

    ViewContext viewContext =

        new ViewContext(context, result.View, viewData, tempData);

 

    result.View.Render(viewContext, context.HttpContext.Response.Output);

    if (result != null)

        result.ViewEngine.ReleaseView(context, result.View);

\\}

As you can see, ProcessResult, in turn, calls FindView, which I've lifted from the source code for ASP.NET MVC available up on CodePlex.com. That code, in turn just acts as more glue that binds my current logic in to the Framework by tying into the way that the Framework finds views:

// more glue:

// lifted from MS/MVC Source Code on CodePlex.com (ActionResult.FindView)

private ViewEngineResult

    FindView(ControllerContext context, string viewName, string masterName)

\\{

    ViewEngineResult result =

        ViewEngines.Engines.FindView(context, viewName, masterName);

    if (result.View != null)

        return result;

 

    // generate an exception containing all the locations searched

    StringBuilder locationsText = new StringBuilder();

    foreach (string location in result.SearchedLocations)

    \\{

        locationsText.AppendLine();

        locationsText.Append(location);

    \\}

 

    throw new InvalidOperationException(

        String.Format(CultureInfo.CurrentUICulture,

        "The view '\\{0\\}' or its master could not be found. "  +

        "The following locations were searched:\\{1\\}",

        viewName, locationsText));

\\}

Copying and pasting code from CodePlex isn't an ideal solution, but it does work, and I've got some more comments on this approach at the conclusion of this article. However, with this new CustomizedViewResult, I can now leverage ASP.NET MVC functionality to render a view by passing in a ViewName and the name of a MasterPage that goes with it. (Which is, of course, exactly what a ViewResult does, but the extensibility that this new approach provides become obvious in the next few paragraphs.)

Handling Errors

With a new customized ActionResult that can render ASP.NET MVC views, handling errors is now much easier. For example, an ErrorResult (that inherits from CustomizedViewResult) can be decorated with a couple of properties and a number of different constructors, as follows:

public string ErrorMessage \\{ get; set; \\}

private Exception _ex;

 

public ErrorResult() \\{ \\}

 

public ErrorResult(string message)

\\{

    this.ErrorMessage = message;

\\}

 

public ErrorResult(string message, Exception ex)

\\{

    this.ErrorMessage = message;

    this._ex = ex;

\\}

Then, with those details taken care of, a new implementation of ExecuteResult can be put in place that will handle the tasks of throwing customized Http Status Codes (in this case HTTP 500s), loading details into ViewData, and then handing processing off to ASP.NET MVC's built-in ViewEngine in order to render a user-friendly error page that will sit on top of an HTTP 500 (instead of redirecting to an HTTP 200).

public override void ExecuteResult(ControllerContext context)

\\{

    // Write Headers:

    context.HttpContext.Response.StatusCode = 500;

    context.HttpContext.Response.TrySkipIisCustomErrors = true;

 

    // Load error content into ViewData:

    context.Controller.ViewData\\["ErrorResult.ErrorMessage"\\] = ErrorMessage;

    context.Controller.ViewData\\["ErrorResult.Exception"\\] = this._ex;

 

    // Render a View:

    if (base.ViewName.IsNullOrEmpty())

        base.ViewName = SiteKeys.Views.ServerError;

 

    if (base.MasterName.IsNullOrEmpty())

        base.MasterName = SiteKeys.Views.DefaultMasterName;

 

    base.ProcessResult(context);

\\}

Note too that part of the logic in the ExecuteResult method is to define both a default MasterPage and View if one hasn't been specified. To do this I'm just using a custom set of SiteKeys to make it easier to modify these values in the future (instead of hard-coding them), as follows:

public static class Views

\\{

    public static string DefaultMasterName = "Site";

    public static string ServerError = "ServerError";

    public static string NotFound = "NotFound";

    public static string Removed = "Removed";

\\}

The implied benefit here though, is that there is the opportunity to have an ErrorResult specify its own View or Master if needed or desired. ServerError Results are super easy to use within your site, as the following example shows:

public ActionResult ServerError()

\\{

    try

    \\{

        throw new Exception("woops!");

    \\}

    catch(Exception ex)

    \\{

        return new ErrorResult("Uh oh!", ex);

    \\}

\\}

And the great thing about this approach, is you can easily test for exceptions or error conditions as well - but end-users who encounter errors will see 'pretty' or fully-skinned error messages while robots or spiders that encounter these problems will also know that they shouldn't index this page or url.

Handling Moved or Missing Content

Using this same approach makes it trivial to create custom 404 errors. Just create a new NotFoundResult (that derives from CustomizedViewResult) with an ExecuteResult implementation as follows:

public override void ExecuteResult(ControllerContext context)

\\{

    // Set Headers:

    context.HttpContext.Response.StatusCode = 404;

    context.HttpContext.Response.TrySkipIisCustomErrors = true;

 

    // Define which view to render (i.e. visual output)

    if (base.ViewName.IsNullOrEmpty())

        base.ViewName = SiteKeys.Views.NotFound;

 

    if (base.MasterName.IsNullOrEmpty())

        base.MasterName = SiteKeys.Views.DefaultMasterName;

 

    // Render/output the view:

    base.ProcessResult(context);

\\}

Now if you have database lookups or other dynamic content, you can easily throw NotFoundResults as needed:

public ActionResult NotFound(int id)

\\{

    // TODO: use IoC/Constructor Injection

    // instead of 'new-ing' up repo here:

    FakeRepository repo = new FakeRepository();

 

    List<string> values = repo.GetSomeStrings(id);

    if(values == null)

        return new NotFoundResult();

 

    return View();

\\}

And, as before, the benefit of this approach is that you can easily test for Not Found content, and you still get SEO-friendly and user-friendly error pages.

The same approach works equally well for handling moved content. By creating an enumeration to define different kinds of moved content such as Moved (302), Moved Permanently (301), or Removed (410), it's easy to create a new MovedResult that inherits from CustomizedViewResult.

This custom ActionResult implementation needs a couple of properties and helper constructors as follows: Where the properties are used to specify the new location (if there is one) and the type of moved content we're dealing with:

public MovedResultType MovedResultType \\{ get; private set; \\}

public string NewLocation \\{ get; private set; \\}

 

public MovedResult(string newLocation)

\\{

    this.MovedResultType = MovedResultType.Temporary;

    this.NewLocation = newLocation;

\\}

 

public MovedResult(MovedResultType type, string newLocation)

\\{

    this.MovedResultType = type;

    this.NewLocation = newLocation;

\\}

Then, in terms of the ExecuteResult implementation, we're just left with dealing with processing which kind of move we're dealing with, handling the HTTP Response Headers as needed, and then deferring to the base class (the CustomizedViewResult) to render a user-friendly view:

public override void ExecuteResult(ControllerContext context)

\\{

    switch (MovedResultType)

    \\{

        case MovedResultType.Temporary:

            context.HttpContext.Response.StatusCode = 302;

            context.HttpContext.Response.Write("302 Found");

            context.HttpContext.Response.AppendHeader("Location",

                this.NewLocation);

 

            // assign a view to execute:

            base.ViewName = SiteKeys.Views.NotFound;

            context.Controller.ViewData\\["Message"\\] =

                string.Format("Resource Not Found.<br />" +

                "New Location: <a href=\"\\{0\\}\">\\{0\\}</a>.", NewLocation);

            break;

        case MovedResultType.Permanent:

            context.HttpContext.Response.StatusCode = 301;

            context.HttpContext.Response.Write("301 Moved Permanently");

            context.HttpContext.Response.AppendHeader("Location",

                this.NewLocation);

 

            // assign a view to execute:

            base.ViewName = SiteKeys.Views.NotFound;

            context.Controller.ViewData\\["Message"\\] =

                string.Format("Resource Not Found.<br />" +

                "New Location: <a href=\"\\{0\\}\">\\{0\\}</a>.", NewLocation);

            break;

        case MovedResultType.Removed:

            context.HttpContext.Response.StatusCode = 401;

            context.HttpContext.Response.TrySkipIisCustomErrors = true;

 

            // assign a view to execute:

            base.ViewName = SiteKeys.Views.ServerError;

            break;

        default:

            break;

    \\}

 

    // Render a user-friendly view for end-users:

    if (base.MasterName.IsNullOrEmpty())

        base.MasterName = SiteKeys.Views.DefaultMasterName;

 

    base.ProcessResult(context);

\\}

The trick to using this MovedResult as a way of letting search engines (and end-users) know when something has moved is to either keep some sort of config file or database table with a mapping of old URLs or resource locations with their new locations or removed status. Setting up this kind of functionality is beyond the scope of this article, but if you have that logic in place, then the benefit of using a MovedResult (that inherits from CustomizedViewEngine) is that you'll get both SEO-friendly and user-friendly results that will help you stay on top of meeting user needs while keeping your site more efficiently indexed.

A Cleaner Approach, Eventually

The primary drawback of the approach that I've outlined in this article is that it re-uses code from the ASP.NET MVC framework using the cut-paste-tweak approach. Typically, this isn't recommended. I've rationalized much of my sin in this regard because most of what I'm doing is “gluing” my way into the core functionality provided by ASP.NET MVC for rendering views. But to get there, I had to copy-paste-tweak two methods out of the source-code itself. However, I've also abstracted that “glue” away from the actual implementation of my intended outcomes by having ErrorResult, NotFoundResult, and MovedResult all inherit from CustomizedViewResult, where I could provide a cleaner or modified approach later on.

My approach won't work in all scenarios and does carry some limitations, but it's much cleaner than my previous approach, and it provides some really great benefits both from an end-user as well from an SEO perspective. I also enjoy the semantics of being able to return ErrorResults, NotFoundResults, and MoveResults from wherever they make sense within my application. And if this approach interests you (or you'd like to poke holes in it and provide a better implementation) contact me at [email protected] and I'll be happy to drop you a sample application with these extensions in play.

Hide comments

Comments

  • Allowed HTML tags: <em> <strong> <blockquote> <br> <p>

Plain text

  • No HTML tags allowed.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Lines and paragraphs break automatically.
Publish