Wednesday, 4 November 2009

A URL Resolver Module for ASP.NET MVC

Update: An improved version of this module, along with some performance stats, is available here. The original version posted here was very, very slow. Probably not a good idea to use it for anything. Ever.

One of the few things I actually liked about ASP.NET WebForms was that you could do things like

<a href=”~/my/account.aspx” runat=”server”>My Account</a>

and ASP.NET would magically turn the tilde character (~) into the current relative application root – so you could debug your apps on http://localhost:4567/ and then deploy them to, and your links wouldn’t break.

ASP.NET MVC doesn’t like things that are runat=”server” – and with good reason, I think – but this does mean you can end up with rather a lot of calls to ResolveUrl() sprinked throughout your code.

To get around this, I’ve hacked together an HTTP module that basically rewrites the output stream on the fly. It wraps the HTTP output stream (the thing you're writing to when you Response.Write stuff) in a 'smart' stream wrapper, and the magic naively optimistic part looks like this:

public override void Write(byte[] buffer, int offset, int count) {
  if (HttpContext.Current.Handler is System.Web.Mvc.MvcHandler) {
    HttpContext.Current.Trace.Warn("Resolving URLs in output stream...");
    byte[] data = new byte[count];
    Buffer.BlockCopy(buffer, offset, data, 0, count);
    string html = Encoding.ASCII.GetString(data);

    // Don't try and use Regex transformations on your 
    // entire output stream. It is slow. Like, really, really slow.
    // Take a look at this updated version instead.

    var re = new Regex("(?src|href|action)=\"~/", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.ExplicitCapture);
    html = re.Replace(html, "${attr}=\"" + VirtualPathUtility.ToAbsolute("~/"));
    data = Encoding.ASCII.GetBytes(html);
    sink.Write(data, 0, html.Length);
    HttpContext.Current.Trace.Warn("Resolved URLs in output stream.");
  } else {
    sink.Write(buffer, offset, count);

Basically, it looks for HTML SRC, ACTION and HREF attributes whose value begins with ~/, and replaces the ~ with the application’s virtual path on the fly. I haven’t tested this code for performance, so I don’t know what kind of impact it’ll have on your page response times, This code is something like 200 times slower than a straight stream copy, but it’s running in a couple of demo apps I’m working on and it seems to work pretty nicely.

The full implementation is over on Google Code if you’re interested.


Paul said...

This looks good, have you got any performance data for pages / sites that implement this logic?

NickG said...

You keep posting articles which put me off using MVC... Keep them coming as I have no free time anyway :)

Dylan Beattie said...

@NickG This one will work with ASP.NET WebForms as well - just modify the line that inspects the handler type and look for a System.Web.PageHandler, IIRC.

<a href="~/default.aspx>"home</a> will just work, anywhere, with no ResolveUrl and no runat="server"


Steve said...

That's a great idea but the possible performance hit does make me nervous.

What would be great is if it did the same thing, but when the page is compiled rather than on every 'display'. Then performance is no longer an issue. Is that feasible, do you think?

Dylan Beattie said...

Yep - as you guys suspected, the performance is likely to cause problems. Modified version that's quicker - but still not really ideal - is linked from this follow-up post.

I'll also look into resolving the URLs when the page is compiled - obviously this wouldn't work for pages where URLs are being generated programmatically based on database values or user input, but it might still offer some performance improvement.