November 27, 2011

ASP.NET MVC - Issuing multiple 301 Redirects

I recently completed an ASP.NET MVC web site for Channel Pumps. To maintain their existing search engine rankings, we needed to issue 301 (permanent) redirects for all their legacy urls.

The old site was built using a ASP.NET Web Forms CMS, with an “article” for each page on the site. The CMS used UrlRewriting.net to create search engine “friendly” urls and map these to the correct article:

http://www.channelpumps.com/a24_Series_D_Rotary_Lobe_Pumps.aspx

The new site uses ASP.NET MVC’s routing system to provide even nicer, extensionless urls. Instead of storing the Id in the url we generate a slug based on the page title, and use this to retrieve the correct content. The new url for the above page becomes:

http://www.channelpumps.com/products/ssp-series-d-cast-iron-rotary-lobe-pumps

If there was a common pattern between the legacy and new url structures I would have used UrlRewriting.net, which can handle regex based mapping.

Instead I just hand-rolled my own solution that uses an XML file to store the mappings:

<?xml version="1.0" encoding="utf-8" ?>
<redirects>
  <redirect from="pumps.aspx" to="/products"/>
  <redirect from="a61_Universal_Spare_Parts.aspx" to="/products/progressing-cavity-spares"/>
  <redirect from="a34_Submersible_Sewage_Pumps.aspx" to="/products/calpeda-submersible-drainage-and-sewage-pumps"/>
  <redirect from="a39_Service_and_Support.aspx" to="/pages/service-support"/>
</redirects>

We then have a corresponding Redirect class:

public class Redirect
{
    public string From { get; set; }
    public string To { get; set; }
}

Matching a Redirect against the incoming Url is handled by our RedirectManager:

public interface IRedirectManager
{
    Redirect GetRedirect(Uri uri);
}

public class RedirectManager : IRedirectManager
{
    private const string RedirectFilename = "redirects.xml";

    private readonly ICacheProvider cache;

    public RedirectManager(ICacheProvider cacheProvider)
    {
        this.cache = cacheProvider;
    }
    
    public Redirect GetRedirect(Uri uri)
    {
        var redirect = LoadRedirects()
            .SingleOrDefault(x => x.From.Equals(uri.AbsolutePath.Trim('/'), StringComparison.InvariantCultureIgnoreCase));
        return redirect;
    }

    private IEnumerable<Redirect> LoadRedirects()
    {
        return cache.Get("redirects", 5, () =>
        {
            var redirectsPath = HostingEnvironment.MapPath("~/app_data/" + RedirectFilename);

            if (!File.Exists(redirectsPath))
            {
                return Enumerable.Empty<Redirect>();
            }

            var doc = XDocument.Load(redirectsPath);
            return GetRedirectsFromXml(doc);
        });
    }

    private static IEnumerable<Redirect> GetRedirectsFromXml(XDocument doc)
    {
        var redirects = (from e in doc.Descendants("redirect")
                         select new Redirect
                         {
                             From = e.Attribute("from").Value,
                             To = e.Attribute("to").Value
                         }).ToList();
        return redirects;
    }
}

Here we load the redirects from the XML file and match based on the current Uri AbsolutePath. We’re also caching the redirects rather than hitting the file system on every request.

Catching the legacy urls

To catch the legacy urls we can define a “catch-all” route (at the bottom of our route table) that catches anything not matched by our other routes. We already had one in place that goes to our custom 404 (Page Not Found) action:

routes.MapRoute(
	"", 
	"{catchall*}", 
	new { controller = "Error", action = "NotFound" }
);

Next we updated the NotFound action to check for a Redirect:

public class ErrorController : BaseController
{
    private readonly IRedirectManager redirectManager;

    public ErrorController(IRedirectManager redirectManager)
    {
        this.redirectManager = redirectManager;
    }
    
    [HttpGet]
    public ActionResult NotFound()
    {
        var redirect = redirectManager.GetRedirect(Request.Url);
        if (redirect != null)
        {
            return RedirectPermanent(redirect.To);
        }

        return HttpNotFoundView();
    }
}

If a Redirect is found we use MVC’s RedirectPermanent method to issue a 301 redirect. If no match is found, we returns a 404 using a custom ActionResult, HttpStatusCodeViewResult:

public class BaseController : Controller
{
    protected HttpStatusCodeViewResult HttpNotFoundView(string description = null)
    {
        return new HttpStatusCodeViewResult("NotFound", 404, description ?? "Page not found");
    }
}

public class HttpStatusCodeViewResult : ViewResult
{
    private readonly int statusCode;
    private readonly string description;

    public HttpStatusCodeViewResult(int statusCode, string description = null) :
        this(null, statusCode, description) { }

    public HttpStatusCodeViewResult(string viewName, int statusCode, string description = null) {
        this.statusCode = statusCode;
        this.description = description;
        this.ViewName = viewName;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        var httpContext = context.HttpContext;
        var response = httpContext.Response;

        response.StatusCode = statusCode;
        response.StatusDescription = description;
        
        base.ExecuteResult(context);
    }
}

With this done, you can safely launch your new site without destroying legacy site search engine rankings.

Update

As requested in the comments, here is the implementation of ICacheProvider I am using (uses System.Runtime.Caching.MemoryCache):

public interface ICacheProvider {
    T Get<T>(string key);
    void Set(string key, object data, int cacheTime);
    bool IsSet(string key);
    void Invalidate(string key);
    void Clear();
}

public class MemoryCacheProvider : ICacheProvider
{
    private readonly ObjectCache cache = MemoryCache.Default;

    public T Get<T>(string key) {
        return (T)cache[key];
    }

    public void Set(string key, object data, int cacheTime) {
        var policy = new CacheItemPolicy();
        policy.AbsoluteExpiration = DateTime.Now + TimeSpan.FromMinutes(cacheTime);
        cache.Add(new CacheItem(key, data), policy);
    }

    public bool IsSet(string key) {
        return (cache.Contains(key));
    }

    public void Invalidate(string key) {
        cache.Remove(key);
    }

    public void Clear() {
        foreach (var item in cache)
            Invalidate(item.Key);
    }
}

© 2022 Ben Foster