سؤال

I'm creating an ASP.net MVC / Entity Framework shopping cart to get more familiar with the technology. One of the features I wanted to have was URLs based of off unique slugs instead of embedding the entity ids into the URL. Some examples:

  • /
  • /information
  • /information/about-us
  • /information/contact-us
  • /mens-clothing
  • /mens-clothing/mens-shirts
  • /mens-clothing/mens-shirts/test-tshirt

The slugs are unique across all content types, but for example, test-tshirt could appear in multiple categories:

  • /mens-clothing/mens-shirts/test-tshirt
  • /mens-clothing/clearance/test-tshirt

I created a custom route that takes the last slug in the path and uses it to look up the current page.

public class SlugRoute : RouteBase
{
    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        string path = HttpContext.Current.Request.Path.TrimStart('/').TrimEnd('/');

        if (string.IsNullOrEmpty(path))
            path = "home";

        string[] slugs = path.Split('/');

        string slug = slugs[slugs.Length - 1];

        CatalogPage page = Token.Instance.DB.Pages.SingleOrDefault(p => p.UrlSlug == slug);
        if (page != null)
        {
            // Cache current page in context
            HttpContext.Current.Items["CurrentPage"] = page;

            // Set up route data
            RouteData data = new RouteData(this, new MvcRouteHandler());
            data.Values["action"] = "Index";
            data.Values["id"] = page.Id;
            data.DataTokens.Add("namespaces", new string[] { "MyProject.Presentation.Controllers" });

            // Set controller value if specified in db, or set based on entity type
            if (!string.IsNullOrEmpty(page.Controller))
                data.Values["controller"] = page.Controller;
            else if (page.GetUnproxiedType() == typeof(CategoryPage))
                data.Values["controller"] = "Category";
            else if (page.GetUnproxiedType() == typeof(ProductPage))
                data.Values["controller"] = "Product";
            else
                data.Values["controller"] = "Content";
            return data;
        }       
        return null;
    }

    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
    {
        return null;
    }
}

This works well and I have good flexibility in implementing custom logic and display templates (the content types have a "View" property as well so I can dynamically set the view in the controller).

However, I stumble a little bit when it comes to implementing breadcrumbs. The quick and dirty way would be to use the path from the URL, do a query for each slug in the path, and ignore whether or not the page is actually a child of the category. Another solution would be to use something like MvcSiteMapProvider and build up an XML tree as content is added on the backend... I'm unsure how well this specific implementation will work because it seems to be pretty focused on the standard {controller}/{action}/{id} route pattern.

What other types of implementations have you used or seen?

هل كانت مفيدة؟

المحلول

MvcSiteMapProvider v4 also works with URLs by setting the Url property rather than using {controller}/{action}/{id}. This is exactly the scenario I am using it for (database driven URLs/custom RoutBase derived routes) and it works great. However, you should implement reverse URL lookup in your route as well or your URL resolution won't work.

public class ProductRoute
    : RouteBase, IRouteWithArea
{
    private readonly string area;
    private readonly IApplicationContext appContext;
    private readonly IRouteUrlProductListFactory routeUrlProductListFactory;
    private readonly IRouteUtilities routeUtilities;

    public ProductRoute(
        string area,
        IApplicationContext appContext,
        IRouteUrlProductListFactory routeUrlProductListFactory,
        IRouteUtilities routeUtilities
        )
    {
        if (appContext == null) { throw new ArgumentNullException("appContext"); }
        if (routeUrlProductListFactory == null) { throw new ArgumentNullException("routeUrlProductListFactory"); }
        if (routeUtilities == null) { throw new ArgumentNullException("routeUtilities"); }

        this.area = area;
        this.appContext = appContext;
        this.routeUrlProductListFactory = routeUrlProductListFactory;
        this.routeUtilities = routeUtilities;
    }

    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        RouteData result = null;
        var tenant = this.appContext.CurrentTenant;

        if (tenant.TenantType.ToString().Equals(this.area, StringComparison.OrdinalIgnoreCase))
        {
            var localeId = this.appContext.CurrentLocaleId;

            // Get all of the pages
            var path = httpContext.Request.Path;
            var pathLength = path.Length;

            var page = this.routeUrlProductListFactory
                .GetRouteUrlProductList(tenant.Id)
                .Where(x => x.UrlPath.Length.Equals(pathLength))
                .Where(x => x.UrlPath.Equals(path))
                .FirstOrDefault();

            if (page != null)
            {
                result = this.routeUtilities.CreateRouteData(this);

                this.routeUtilities.AddQueryStringParametersToRouteData(result, httpContext);

                result.Values["controller"] = "Product";
                result.Values["action"] = "Details";
                result.Values["localeId"] = localeId;
                result.DataTokens["area"] = this.area;

                // TODO: May need a compound key here (ProductXTenantLocaleID and 
                // CategoryId) to allow product to be hosted on pages that are not 
                // below categories.
                result.Values["id"] = page.CategoryXProductId;
            }
        }

        return result;
    }

    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
    {
        VirtualPathData result = null;

        if (requestContext.RouteData.IsAreaMatch(this.area))
        {
            var tenant = this.appContext.CurrentTenant;

            // Get all of the pages
            var pages = this.routeUrlProductListFactory.GetRouteUrlProductList(tenant.Id);
            IRouteUrlProductInfo page = null;

            if (this.TryFindMatch(pages, values, out page))
            {
                if (!string.IsNullOrEmpty(page.VirtualPath))
                {
                    result = this.routeUtilities.CreateVirtualPathData(this, page.VirtualPath);
                    result.DataTokens["area"] = tenant.TenantType.ToString();
                }
            }
        }

        return result;
    }

    private bool TryFindMatch(IEnumerable<IRouteUrlProductInfo> pages, RouteValueDictionary values, out IRouteUrlProductInfo page)
    {
        page = null;
        Guid categoryXProductId = Guid.Empty;
        var localeId = (int?)values["localeId"];

        if (localeId == null)
        {
            return false;
        }

        if (!Guid.TryParse(Convert.ToString(values["id"]), out categoryXProductId))
        {
            return false;
        }

        var controller = Convert.ToString(values["controller"]);
        var action = Convert.ToString(values["action"]);

        if (action == "Details" && controller == "Product")
        {
            page = pages
                .Where(x => x.CategoryXProductId.Equals(categoryXProductId))
                .Where(x => x.LocaleId.Equals(localeId))
                .FirstOrDefault();
            if (page != null)
            {
                return true;
            }
        }

        return false;
    }

    #region IRouteWithArea Members

    public string Area
    {
        get { return this.area; }
    }

    #endregion
}

public class RouteUtilities
    : IRouteUtilities
{
    #region IRouteUtilities Members

    public void AddQueryStringParametersToRouteData(RouteData routeData, HttpContextBase httpContext)
    {
        var queryString = httpContext.Request.QueryString;
        if (queryString.Keys.Count > 0)
        {
            foreach (var key in queryString.AllKeys)
            {
                routeData.Values[key] = queryString[key];
            }
        }
    }

    public RouteData CreateRouteData(RouteBase route)
    {
        return new RouteData(route, new MvcRouteHandler());
    }

    public VirtualPathData CreateVirtualPathData(RouteBase route, string virtualPath)
    {
        return new VirtualPathData(route, virtualPath);
    }

    #endregion
}

I use caching to load all of the URLs into a data structure (my final application will probably use file caching), so the database is not hit for every URL lookup.

MvcSiteMapProvider is also set up to use multiple paths to a single page by creating multiple nodes to the page (one for each unique URL). You can fix the SEO aspect of using multiple URLs for the same content by implementing the canonical tag using the CanonicalUrl or CanonicalKey properties. See this article for a complete example.

You can also drive MvcSiteMapProvider nodes from a database by implementing IDynamicNodeProvider or ISiteMapNodeProvider.

Do note that the URL matching in MvcSiteMapProvider is case sensitive. It would be best if you ensure your incoming URLs are always lowercase by doing a 301 redirect.

مرخصة بموجب: CC-BY-SA مع الإسناد
لا تنتمي إلى StackOverflow
scroll top