6

I would like to create custom slugs for pages in my CMS, so users can create their own SEO-urls (like Wordpress).

I used to do this in Ruby on Rails and PHP frameworks by "abusing" the 404 route. This route was called when the requested controller could not be found, enabling me te route the user to my dynamic pages controller to parse the slug (From where I redirected them to the real 404 if no page was found). This way the database was only queried to check the requested slug.

However, in MVC the catch-all route is only called when the route does not fit the default route of /{controller}/{action}/{id}.

To still be able to parse custom slugs I modified the RouteConfig.cs file:

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapHttpRoute(
            name: "DefaultApi",
            routeTemplate: "api/{controller}/{id}",
            defaults: new { id = RouteParameter.Optional }
        );

        RegisterCustomRoutes(routes);

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { Controller = "Pages", Action = "Index", id = UrlParameter.Optional }
        );
    }

    public static void RegisterCustomRoutes(RouteCollection routes)
    {
        CMSContext db = new CMSContext();
        List<Page> pages = db.Pages.ToList();
        foreach (Page p in pages)
        {
            routes.MapRoute(
                name: p.Title,
                url: p.Slug,
                defaults: new { Controller = "Pages", Action = "Show", id = p.ID }
            );
        }
        db.Dispose();
    }
}

This solves my problem, but requires the Pages table to be fully queried for every request. Because a overloaded show method (public ViewResult Show(Page p)) did not work I also have to retrieve the page a second time because I can only pass the page ID.

  1. Is there a better way to solve my problem?
  2. Is it possible to pass the Page object to my Show method instead of the page ID?
christiaanderidder
  • 655
  • 1
  • 8
  • 18
  • 2
    Isn't it initialized only on startup of the application? Just on a side note: `db.Dispose();`? Edit: Sorry I was not reading your question very well. Perhaps you could put the pages in the Global Cache? – Silvermind Jul 15 '12 at 18:10
  • Thanks for pointing in the right direction! The function is indeed only called on startup. I guess I was looking at it as if it was an interpreted language (like PHP). Considering this is code is only executed on startup I guess the performance impact is negligible. However, I'm still not sure if this is the way to go, or if this is already achievable by using built in functionality. I'm also still wondering if it is possible to pass the Model instead of the ID (Question no. 2). – christiaanderidder Jul 15 '12 at 18:22

1 Answers1

4

Even if your route registration code works as is, the problem will be that the routes are registered statically only on startup. What happens when a new post is added - would you have to restart the app pool?

You could register a route that contains the SEO slug part of your URL, and then use the slug in a lookup.

RouteConfig.cs

routes.MapRoute(
    name: "SeoSlugPageLookup",
    url: "Page/{slug}",
    defaults: new { controller = "Page", 
                    action = "SlugLookup",
                  });

PageController.cs

public ActionResult SlugLookup (string slug)
{
    // TODO: Check for null/empty slug here.

    int? id = GetPageId (slug);

    if (id != null) {    
        return View ("Show", new { id });
    }

    // TODO: The fallback should help the user by searching your site for the slug.
    throw new HttpException (404, "NotFound");
}

private int? GetPageId (string slug)
{
    int? id = GetPageIdFromCache (slug);

    if (id == null) {
        id = GetPageIdFromDatabase (slug);

        if (id != null) {
            SetPageIdInCache (slug, id);
        }
    }

    return id;
}

private int? GetPageIdFromCache (string slug)
{
    // There are many caching techniques for example:
    // http://msdn.microsoft.com/en-us/library/dd287191.aspx
    // http://alandjackson.wordpress.com/2012/04/17/key-based-cache-in-mvc3-5/
    // Depending on how advanced you want your CMS to be,
    // caching could be done in a service layer.
    return slugToPageIdCache.ContainsKey (slug) ? slugToPageIdCache [slug] : null;
}

private int? SetPageIdInCache (string slug, int id)
{
    return slugToPageIdCache.GetOrAdd (slug, id);
}

private int? GetPageIdFromDatabase (string slug)
{
    using (CMSContext db = new CMSContext()) {
        // Assumes unique slugs.
        Page page = db.Pages.Where (p => p.Slug == requestContext.Url).SingleOrDefault ();

        if (page != null) {
            return page.Id;
        }
    }

    return null;
}

public ActionResult Show (int id)
{
    // Your existing implementation.
}

(FYI: Code not compiled nor tested - haven't got my dev environment available right now. Treat it as pseudocode ;)

This implementation will have one search for the slug per server restart. You could also pre-populate the key-value slug-to-id cache at startup, so all existing page lookups will be cheap.

Joel Purra
  • 24,294
  • 8
  • 60
  • 60
  • Nice solution, but I'm trying to get rid of the /Page/ part. Should I just route all request to one controller and check if the requested name already exists as a controller there, I tried to avoid this way because itmeans I don't make any use of the built in routing to controllers. However, if this is the only way to achieve my rewriting, does MVC provide a way to look up the existing controllers? – christiaanderidder Jul 16 '12 at 11:04
  • @christiaanderidder: if you add the route last (yes, order matters), as `url: "{slug}"`, it will basically act as the usual 404 hack. – Joel Purra Jul 16 '12 at 11:46
  • 1
    @christiaanderidder: If you implement a custom [`IRouteConstraint`](http://msdn.microsoft.com/en-us/library/system.web.routing.irouteconstraint.aspx) instead, you can perform the slug lookup and just `return false` if it doesn't `.Match(...)`. I like this [testable validator implementation](http://stackoverflow.com/a/9019603/). – Joel Purra Jul 16 '12 at 11:51
  • The first solution is what I started with but using `url: "{slug}"` the default routing `/{Controller}/{Action}/{id}` will still catch all URLs. For example, `/custom-slug` will be caught by the default route before reaching the catch-all route, and because the controller does not exist it will be routed to the default 404 page. – christiaanderidder Jul 16 '12 at 14:30
  • I assume the second solution enables me to check the slug by putting it above my default route and if the RouteConstraint returns false it will go to the next route (default route). – christiaanderidder Jul 16 '12 at 14:33
  • @christiaanderidder: You're right, you'll end up with two catch-all routes. I try to keep my pages (and other "objects") in `/page/` subfolders even when I'm not using MS MVC, so that's less of a problem for me. I usually assume there is or will be more than one object type (future-proofing), coming from an e-commerce viewpoint. – Joel Purra Jul 16 '12 at 14:40
  • I'm trying to do this because most websites do not use /page/ for their pages, and by checking the default route first (like I did in ruby/PHP) I could still use things like /pictures or /forum for controllers, while enabling custom page slugs. – christiaanderidder Jul 16 '12 at 14:53