Today I’m going to show you how to use MVC helper methods in your ASP.NET WebForms markup. Why would you want to do this? As I explained and demonstrated in the previous two posts in this series, I’m working on a project that has an extensive investment in WebForms (of the Visual Basic .NET variety), and a wholesale migration to MVC is just not possible. With a little bit of black magic, I’ve shown you how to write C# MVC code and Razor views that can be consumed via a VB.NET WebForms app. With this last “spell,” I’ll show you how you can start bringing that content into WebForms pages.
[more]
The Basics – (Strongly-Typed) Linking to MVC Actions
ASP.NET MVC ships with a set of loosely-typed helpers for generating action links, like so:
<p>@Html.ActionLink("Click me!", "Index", "Home")</p>
I’m really not a fan at all of this string-based family of helpers. It completely ignores one of the main benefits of a statically-typed language like C#: compile-time safety! Why would anyone think RANT ABORTED. Ahem. Fortunately, thanks to the MVC Futures library, there are strongly-typed helpers that work based on lambda expressions:
<p>@(Html.ActionLink<HomeController>(c => c.Index(), "Click me!"))</p>
These strongly-typed helpers are refactor-friendly and can be easily validated at compile time. They work great for linking from an MVC view to an action, but what about from a WebForm to an MVC action? They’re implemented as extension methods off of HtmlHelper and cannot be reused directly in WebForms. What we’d like to do is write (VB.NET) WebForms code that looks something like this:
<p>Click <%=Me.BlackMagicMVC().ActionLink(Of HomeController)(Function(c) c.Index(), "here for MVC!") %></p>
But how can we do that? The easiest way is to utilize a little bit of trickery and to create an instance of HtmlHelper within WebForms. With that in hand, you can then use most of the existing HtmlHelper extension methods, with a few caveats. Instead of exposing the HtmlHelper to WebForms directly, I chose to expose only the helpers that I needed and that I knew worked correctly when invoked from WebForms. This abstraction also made it easier to bend things to my will, so to speak. I wrapped them up behind an new helper class, BlackMagicMVCHelper:
public static class BlackMagicMVCHelperExtension { public static BlackMagicMVCHelper BlackMagicMVC(this Page page) { //This could be cached in HttpContext for performance reasons... return new BlackMagicMVCHelper(); } } public class BlackMagicMVCHelper { private static HtmlHelper GetHtmlHelper() { //This looks complicated, because it is. There's a lot that has to be //wired up to take advantage of the more complex HtmlHelper methods. var controllerContext = new ControllerContext(); controllerContext.HttpContext = new HttpContextWrapper(HttpContext.Current); controllerContext.RouteData = new RouteData(); //The 'controller' route value is required, but it does not have to //point to a valid controller. controllerContext.RouteData.Values["controller"] = "does-not-exist"; var context = new ViewContext(controllerContext, new WebFormView(controllerContext, "does-not-exist", string.Empty), new ViewDataDictionary(), new TempDataDictionary(), TextWriter.Null); var helper = new HtmlHelper(context, new ViewPage()); return helper; } //...snip... }
The helper exposes a strongly-typed way of generating action links directly from WebForms markup:
public MvcHtmlString ActionLink<TController>(Expression<Action<TController>> action, string linkText) where TController : Controller { var helper = GetHtmlHelper(); return helper.ActionLink(action, linkText); }
Simple, but very effective, as you can see from the generated URL in this screenshot:
One issue I encountered was with the default MVC routing. Typically in an MVC app, you initialize your routes with a default controller and action:
routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapRoute("HomeRoute", "{controller}/{action}", new { controller = "Home", action = "Index" });
When mixing-and-matching WebForms though, this default route won’t behave correctly. It will create “~/” as the link to the HomeController’s Index action, but that URL will be handled by the WebForms Default.aspx page instead. To get around this, you can simply eliminate the default controller from the route:
routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapRoute("HomeRoute", "{controller}/{action}", new { action = "Index" });
This route will yield a working “~/Home” as the URL for the Index action on the HomeController.
Reusing Markup – RenderPartial
Great, so now we can link to our MVC action methods in a strongly-typed way from our WebForms code. “But what can I do about some of my reusable markup that might be stored in UserControls? Can I somehow migrate that to Razor?” Why sure! One way to achieve this is by switching to partial views, but to do that, we’ll need a way to actually render a Razor partial view from within WebForms. Once again, there’s a helper for that, or at least there will be once we write it.
The hard part, creating the HtmlHelper, is already done. All we need to do is invoke the appropriate extension method from our new helper:
public MvcHtmlString Partial(string partialName) { var helper = GetHtmlHelper(); return helper.Partial(partialName); }
Now we can render partial views from WebForms:
<h2>And now... a partial view!</h2> <%=Me.BlackMagicMVC().Partial("HomepageWidget") %>
Since a dummy controller name is being passed in, the view engines will, as you might expect, only be able to locate your partial views if they’re in the ~/Views/Shared folder.
We’re almost finished. Let’s look at how we can enrich a WebForm not just with Razor markup, but with behavior backed by MVC as well.
Migrating Large WebForms with RenderAction
Linking from our WebForms to our MVC actions is a great way to migrate entire pages and sections to MVC, but what can we do about complex pages? It’s (unfortunately) not uncommon to see very complex WebForms pages that are composed from many UserControls that each contain a mix of markup and behavior. Such pages can be difficult to migrate to MVC all at once, but it might be possible to begin migrating pieces of them over at a time. One way to achieve this is by switching to partial views, but partial views don’t really help us with behavior. Instead, we can render entire actions within our WebForms markup!
And yet again, let’s make a WebForms helper to enable RenderAction calls:
public MvcHtmlString RenderAction<TController>(Expression<Action<TController>> action) where TController : Controller { var helper = GetHtmlHelper(); var routeValues = ExpressionHelper.GetRouteValuesFromExpression(action); return helper.Action((string) routeValues["action"], (string)routeValues["controller"], routeValues); }
This helper is doing a little more work. I wanted the helper to return a string, not write directly to the response stream, so I used the HtmlHelper’s Action extension method that returns an MvcHtmlString. Unfortunately there is no overload for this method that accepts a strongly-typed lambda expression instead of a string (what in the world were you guys thinking, ASP.NET MVC team???), so I made one by using the ExpressionHelper class from the MVC Futures project.
Calling the helper from a VB.NET WebForm is almost the same as rendering an action link:
<h2>Finally... render action!</h2> <%=Me.BlackMagicMVC().RenderAction(Of HomeController)(Function(c) c.SayHello()) %>
And the output is… wait, what???
Hmm, looks like the view result was still wrapped up with our WebForms master page. Let’s go back to our RazorBridgeActionInvoker…
public class RazorBridgeActionInvoker : ControllerActionInvoker { protected override ActionResult CreateActionResult(ControllerContext controllerContext, ActionDescriptor actionDescriptor, object actionReturnValue) { var result = base.CreateActionResult(controllerContext, actionDescriptor, actionReturnValue); if (result is ViewResult) { result = new RazorBridgeViewResult((ViewResult) result); } return result; } }
Ah-ha! It’s applying the wrapper to all ViewResults. We really only want to do that for top-level ones, not for child ViewResults. The fix is simple enough:
public class RazorBridgeActionInvoker : ControllerActionInvoker { protected override ActionResult CreateActionResult(ControllerContext controllerContext, ActionDescriptor actionDescriptor, object actionReturnValue) { var result = base.CreateActionResult(controllerContext, actionDescriptor, actionReturnValue); if (result is ViewResult && !controllerContext.IsChildAction) { result = new RazorBridgeViewResult((ViewResult) result); } return result; } }
We also need to remember to disable the default layout for the view, otherwise it will pick up the standard layout. You can do that by setting the Layout property to an empty string:
@model dynamic @{ Layout = string.Empty; } <div style="border: dotted 1px gray"> <p>Hello, world!</p> </div>
And with that, we now get the expected output:
One thing to note: WebForms only allows a single ‘form’ tag on the page. It will very happily strip out the form tags that are included in your Razor views. That means you cannot easily use HtmlHelper’s BeginForm extension methods nor post back directly. While there are hacks to work around this, I’ve always relied heavily on AJAX for the bits of MVC functionality that needed to talk about to a controller from within a WebFroms page.
The End… For Now?
That’s really about all the tricks I have up my sleeve for this particular problem. As usual, the code for BlackMagicMVC is on Github, so feel free to clone it to play around with. I’ve thought about wrapping up the infrastructure as a NuGet package. (If there’s interest in that, let me know.)
In this series of posts, I’ve shown you how to embed a C# MVC application seamlessly within a VB.NET WebForms application. I’ve shown you how to reuse your existing investment in WebForms by leveraging VB.NET master pages from Razor views. Finally, I’ve shown you how to bring MVC-like helper methods into WebForms for rendering action links, rendering partial views, and rendering complete action results. I hope others that find themselves with legacy VB.NET applications find these posts useful. If anyone finds a better way to overcome these challenges, please do share!