Sunday, February 1, 2009

Mixing webforms with MVC

MVC offers a lot of nice features but switching from regular ASP.NET webforms to MVC on exisiting projects is not easy. Out of the box, MVC is very rigid about where you place your views (.aspx). There is little support for user controls and no support at all for custom controls. This makes mixing webforms with MVC next to impossible without breaking existing functionality.

With a couple of additions to MVC though you're pretty much free to do what you want. The functionality introduced in this post enables mixing of MVC and WebForms pages. You can reuse exisiting controls, both user controls and custom controls. You can even go as far as rendering pages from just a master page and controls.

This post will show you how to compose pages from controls using the same mechanisms ASP.NET uses when rendering regular pages from .aspx files. First we'll look at the way master pages and regular .aspx pages interact and then I'll show you how to uses this interaction to do the same from an MVC controller. For some background information the internals of pages you may also want to check an earlier post on the subject.

Master pages in a nutshell
Master pages in ASP.NET use content placeholders to indicate where the page may be customized. Normally, each page (.aspx) declares content within one or more content controls. At runtime (or when precompiling) ASP.NET will generate code that registers the contents with each template container. When the page is being processed during a web request, it instantiates the contents of each of the content controls in the templates available from the master page. The key method is in the Page.AddContentTemplate( string name, ITemplate template ) method. In pages compiled from an .aspx file this method will be invoked while building the page control structure. This is done from an override of the  Page.FrameworkInitialize method, containing most of the generated code for the page. We can use that to hook up whatever content we want to render in a master page.

Implementation
The code below shows a ViewPage that supports this mechanism:

class TemplateViewPage : System.Web.Mvc.ViewPage
{
  protected override void FrameworkInitialize()
  {
    base.FrameworkInitialize();
    foreach ( var template in Templates )
    {
      AddContentTemplate( template.Key, template.Value );
    }
  }

  public Dictionary<string, Template> Templates
  {
    get{ return _templates; }
  }

  Dictionary<string, Template> _templates = new Dictionary<string, Template>();
}
This class enables us to setup a template for each content placeholder in a master page. The Template class is a straight up implementation of the ITemplate interface that supports multiple controls to be added as either a virtual path or a control instance.
class Template : ITemplate
{
  public void AddControl( string virtualPath )
  {
    if ( string.IsNullOrEmpty( virtualPath ) )
      throw new ArgumentNullException( "virtualPath" );

    if ( virtualPath[ 0 ] != '~' && virtualPath[ 0 ] != '/' )
    {
      _items.Add( new TemplateItem { _virtualPath = string.Format( "~/{0}", virtualPath ) } );
    }
    else
    {
      _items.Add( new TemplateItem { _virtualPath = virtualPath } );
    }
  }

  public void AddControl( Control controlInstance )
  {
    if ( null == controlInstance )
      throw new ArgumentNullException( "controlInstance" );

    _items.Add( new TemplateItem { _control = controlInstance } );
  }

  void ITemplate.InstantiateIn( Control container )
  {
    foreach ( var item in _items )
    {
      var control = item._control;
      if ( null == control )
      {
        control = System.Web.Compilation.BuildManager.CreateInstanceFromVirtualPath( item._virtualPath, typeof( Control ) ) as Control;
      }

      container.Controls.Add( control );
      }
  }

  List<TemplateItem> _items = new List<TemplateItem>();
      

  private class TemplateItem
  {
    internal Control _control;
    internal string _virtualPath;   
  }
}
With these classes we have the plumming setup to render a page using a master page and inject any control we want into that. Next, this needs to be hooked up to MVC. The way to do this is create a custom ActionResult.
public class TemplateResult : ActionResult, IView
{
  public string Theme
  {
    get { return _page.Theme; }
    set { _page.Theme = value; }
  }

  public string MasterPageFile
  {
    get { return _page.MasterPageFile; }
    set
    {
      if ( string.IsNullOrEmpty( value ) )
         throw new ArgumentNullException( "value" );
      _page.MasterPageFile = value;
    }
  }

  public void AddControl( string templateName, string virtualPath )
  {
    if ( string.IsNullOrEmpty( templateName ) )
      throw new ArgumentNullException( "templateName" );

    if ( string.IsNullOrEmpty( virtualPath ) )
      throw new ArgumentNullException( "virtualPath" );

    Template template;
    if ( !_page.Templates.TryGetValue( templateName, out template ) )
    {
      template = new Template();
      _page.Templates.Add( templateName, template );
    }
    template.AddControl( virtualPath );
  }

  public void AddControl( string templateName, Control controlInstance )
  {
    if ( string.IsNullOrEmpty( templateName ) )
      throw new ArgumentNullException( "templateName" );

    if ( null == controlInstance )
      throw new ArgumentNullException( "controlInstance" );

    Template template;
    if ( !_page.Templates.TryGetValue( templateName, out template ) )
    {
      template = new Template();
      _page.Templates.Add( templateName, template );
    }
    template.AddControl( controlInstance );
  }

  public override void ExecuteResult( ControllerContext context )
  {
    if ( string.IsNullOrEmpty( MasterPageFile ) )
      throw new InvalidOperationException( 
        "MasterPageFile must not be null or empty." );

    var viewContext = new ViewContext( context, this, 
      context.Controller.ViewData, 
      context.Controller.TempData );
    
    Render( viewContext, context.HttpContext.Response.Output );
  }

  public void Render( ViewContext viewContext, System.IO.TextWriter writer )
  {
    _page.ViewData = viewContext.ViewData;
    _page.AppRelativeTemplateSourceDirectory = "~/";
    _page.AppRelativeVirtualPath = "~/";
    _page.RenderView( viewContext );
  }

  TemplateViewPage _page = new TemplateViewPage();
}
This class provides a controller with the means to create a view and execute it. For a simple HelloWorld example the controller would look like this:
public class HelloWorldController : Controller
{
  public void Default()
  {
    TemplateResult result = new TemplateResult { MasterPageFile = "~/masterpage.master" };
    view.AddControl( "Body", new LiteralControl( "Hello world!" ) );
    return result;
  }
}

This would require a master page named materpage.master with at least one content placeholder named Body.

Real world applications
For real world applications you'd probably declare extension methods for Controller that setup the MasterPageFile and Theme as used by the site. I've found that these classes make it much easier to migrate from pure webforms to MVC. It allows you to use MVC for new developments while enabling reuse of existing controls.

[Update: 1 May 2009]
Source code is now available for download. Please read my post on Alanta.Mvc.

2 comments:

vdhant said...

Have a look at this. It would seem you don't have to go to the trouble of what your describing....

http://www.packtpub.com/article/mixing-asp.net-webforms-and-asp.net-mvc

Marnix said...

Hi,

Thanks for the tip.
The article you're referring to is not quite the same thing; it describes how webforms and MVC can co-exist using routing. The solution I'm proposing here allows you to mix assets from WebForms based applications with MVC; it enables you to use MVC controllers with webforms controls in ways not supported by MVC itself.
This solution enables the controller to dictate which partial views (or controls) are to be loaded into what content template in a master page. This is a different way of constructing views compared to MVC's hierarchical views.

I'm using this solution for new developments on existing sites. It allows me to reuse existing controls developed for webforms-based pages while developing new functionality using MVC.