MVC 路由模板来表示无限的自引用层次类别结构 [英] MVC Routing template to represent infinite self-referential hierarchical category structure

本文介绍了MVC 路由模板来表示无限的自引用层次类别结构的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我有一个产品类别表来表示层次结构的类别结构,数据库中典型的Parent-Child关系表.

I have a product category table to represent a hierarchical category structure, a typical Parent-Child relationship table in the database.

以 Guitar Center 的数据为例:

Fill it with Guitar Center's data as an example:

如果您将它们渲染到带有

  • 的页面:

    If you render them to a page with <ul> and <li>:

    蓝色文本是我想要生成的 URL.对于任何给定的类别,链接由其 slug 及其父母的 slug 组成.

    Texts in blue are the URLs I would like to generate. For any given category, the link consists of its slug and its parents' slugs.

    请注意,我列出的示例只有 2 个父子级别.从理论上讲,通过自我参照结构,任何孩子都可以拥有无​​限的父母.

    1. 如何设置路由模板来实现这一点?
    2. 如果路由模板设置为支持,如何检索叶类别?例如,从 URL categories/guitars/acoustic-guitars 中,我想检索 acoustic-guitars 作为叶类别,并且能够获取该 acoustic-guitars 下的所有产品代码>原声吉他类别.注意:我不想手动解析 URL.理想情况下,最好是通过模型绑定来绑定潜在客户类别.
    1. How to set up routing template to achieve that?
    2. If the routing template is set up to support that, how to retrieve the leaf category? For example, from the URL categories/guitars/acoustic-guitars, I would like to retrieve acoustic-guitars as the leaf category, and able to get all products under that acoustic-guitars category. Note: I don't want manual parsing on the URL. Ideally it would be the best if the lead category is binded through model binding.

    推荐答案

    如何设置路由模板来实现?

    How to set up routing template to achieve that?

    你不能.但是你可以降到一个较低的层次并制作一个数据驱动的 IRouter 实现 CMS 风格的路由管理.

    You can't. But you can drop to a lower level and make a data-driven IRouter implementation for CMS-style route management.

    这是一个跟踪和缓存主键到 URL 的 1-1 映射的示例.它是一个泛型类,我已经测试过它无论主键是 int 还是 Guid 都有效.

    Here is an example that tracks and caches a 1-1 mapping of primary key to URL. It is a generic class and I have tested that it works whether the primary key is int or Guid.

    必须注入一个可插入的部分,ICachedRouteDataProvider,可以在其中实现对数据库的查询.您还需要提供控制器和操作,因此该路由足够通用,可以使用多个实例将多个数据库查询映射到多个操作方法.

    There is a pluggable piece that must be injected, ICachedRouteDataProvider where the query for the database can be implemented. You also need to supply the controller and action, so this route is generic enough to map multiple database queries to multiple action methods by using more than one instance.

    public class CachedRoute<TPrimaryKey> : Microsoft.AspNetCore.Routing.IRouter
    {
        private readonly string _controller;
        private readonly string _action;
        private readonly ICachedRouteDataProvider<TPrimaryKey> _dataProvider;
        private readonly IMemoryCache _cache;
        private readonly IRouter _target;
        private readonly string _cacheKey;
        private object _lock = new object();
    
        public CachedRoute(
            string controller,
            string action,
            ICachedRouteDataProvider<TPrimaryKey> dataProvider,
            IMemoryCache cache,
            IRouter target)
        {
            if (string.IsNullOrWhiteSpace(controller))
                throw new ArgumentNullException("controller");
            if (string.IsNullOrWhiteSpace(action))
                throw new ArgumentNullException("action");
            if (dataProvider == null)
                throw new ArgumentNullException("dataProvider");
            if (cache == null)
                throw new ArgumentNullException("cache");
            if (target == null)
                throw new ArgumentNullException("target");
    
            _controller = controller;
            _action = action;
            _dataProvider = dataProvider;
            _cache = cache;
            _target = target;
    
            // Set Defaults
            CacheTimeoutInSeconds = 900;
            _cacheKey = "__" + this.GetType().Name + "_GetPageList_" + _controller + "_" + _action;
        }
    
        public int CacheTimeoutInSeconds { get; set; }
    
        public async Task RouteAsync(RouteContext context)
        {
            var requestPath = context.HttpContext.Request.Path.Value;
    
            if (!string.IsNullOrEmpty(requestPath) && requestPath[0] == '/')
            {
                // Trim the leading slash
                requestPath = requestPath.Substring(1);
            }
    
            // Get the page id that matches.
            //If this returns false, that means the URI did not match
            if (!GetPageList(context.HttpContext).TryGetValue(requestPath, out TPrimaryKey id))
            {
                return;
            }
    
            //Invoke MVC controller/action
            var routeData = context.RouteData;
    
            // TODO: You might want to use the page object (from the database) to
            // get both the controller and action, and possibly even an area.
            // Alternatively, you could create a route for each table and hard-code
            // this information.
            routeData.Values["controller"] = _controller;
            routeData.Values["action"] = _action;
    
            // This will be the primary key of the database row.
            // It might be an integer or a GUID.
            routeData.Values["id"] = id;
    
            await _target.RouteAsync(context);
        }
    
        public VirtualPathData GetVirtualPath(VirtualPathContext context)
        {
            VirtualPathData result = null;
    
            if (TryFindMatch(GetPageList(context.HttpContext), context.Values, out string virtualPath))
            {
                result = new VirtualPathData(this, virtualPath);
            }
    
            return result;
        }
    
        private bool TryFindMatch(IDictionary<string, TPrimaryKey> pages, IDictionary<string, object> values, out string virtualPath)
        {
            virtualPath = string.Empty;
            TPrimaryKey id;
    
            if (!values.TryGetValue("id", out object idObj))
            {
                return false;
            }
    
            id = SafeConvert<TPrimaryKey>(idObj);
            values.TryGetValue("controller", out object controller);
            values.TryGetValue("action", out object action);
    
            // The logic here should be the inverse of the logic in 
            // RouteAsync(). So, we match the same controller, action, and id.
            // If we had additional route values there, we would take them all 
            // into consideration during this step.
            if (action.Equals(_action) && controller.Equals(_controller))
            {
                // The 'OrDefault' case returns the default value of the type you're 
                // iterating over. For value types, it will be a new instance of that type. 
                // Since KeyValuePair<TKey, TValue> is a value type (i.e. a struct), 
                // the 'OrDefault' case will not result in a null-reference exception. 
                // Since TKey here is string, the .Key of that new instance will be null.
                virtualPath = pages.FirstOrDefault(x => x.Value.Equals(id)).Key;
                if (!string.IsNullOrEmpty(virtualPath))
                {
                    return true;
                }
            }
            return false;
        }
    
        private IDictionary<string, TPrimaryKey> GetPageList(HttpContext context)
        {
            if (!_cache.TryGetValue(_cacheKey, out IDictionary<string, TPrimaryKey> pages))
            {
                // Only allow one thread to poplate the data
                lock (_lock)
                {
                    if (!_cache.TryGetValue(_cacheKey, out pages))
                    {
                        pages = _dataProvider.GetPageToIdMap(context.RequestServices);
    
                        _cache.Set(_cacheKey, pages,
                            new MemoryCacheEntryOptions()
                            {
                                Priority = CacheItemPriority.NeverRemove,
                                AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(this.CacheTimeoutInSeconds)
                            });
                    }
                }
            }
    
            return pages;
        }
    
        private static T SafeConvert<T>(object obj)
        {
            if (typeof(T).Equals(typeof(Guid)))
            {
                if (obj.GetType() == typeof(string))
                {
                    return (T)(object)new Guid(obj.ToString());
                }
                return (T)(object)Guid.Empty;
            }
            return (T)Convert.ChangeType(obj, typeof(T));
        }
    }
    

    CategoryCachedRouteDataProvider

    这里我们从数据库中查找类别并递归地将 slug 连接到一个 URL 中.

    CategoryCachedRouteDataProvider

    Here we lookup the categories from the database and recursively join the slugs into a URL.

    public interface ICachedRouteDataProvider<TPrimaryKey>
    {
        IDictionary<string, TPrimaryKey> GetPageToIdMap(IServiceProvider serviceProvider);
    }
    
    public class CategoryCachedRouteDataProvider : ICachedRouteDataProvider<int>
    {
        // NOTE: I wasn't able to figure out how to constructor inject ApplicationDbContext
        // because there doesn't seem to be a way to access the scoped services there,
        // so we are using a service locator here. If someone could let me know how
        // that is done in Startup.Configure() of .NET Core 2.0, please leave a comment.
        public IDictionary<string, int> GetPageToIdMap(IServiceProvider serviceProvider)
        {
            using (var dbContext = serviceProvider.GetRequiredService<ApplicationDbContext>())
            {
                // Query the categories so we can build all of the URLs client side
                var categories = dbContext.Categories.ToList();
                var scratch = new StringBuilder();
    
                return (from category in categories
                        select new KeyValuePair<string, int>(
                            GetUrl(category, categories, scratch),
                            category.CategoryId)
                        ).ToDictionary(pair => pair.Key, pair => pair.Value);
            }
        }
    
        private string GetUrl(Category category, IEnumerable<Category> categories, StringBuilder result)
        {
            result.Clear().Append(category.Slug);
            while ((category = categories.FirstOrDefault(c => c.CategoryId == category.ParentCategoryId)) != null)
            {
                result.Insert(0, string.Concat(category.Slug, "/"));
            }
            return result.ToString();
        }
    }
    

    类别控制器

    控制器中没有什么特别之处,除了我们此时根本不需要处理 URL 或 slug.我们只接受映射到记录主键的 id 参数,然后你就知道从那里做什么......

    CategoryController

    There is nothing special going on in the controller, except for the fact that we don't need to deal with a URL or a slug at this point at all. We simply accept the id parameter that maps to the primary key of the record and then you know what to do from there...

    public class CategoryController : Controller
    {
        public IActionResult Index(int id)
        {
            // Lookup category based on id...
    
            return View();
        }
    }
    

    用法

    我们在Startup.cs中配置如下:

    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }
    
        public IConfiguration Configuration { get; }
    
        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            // Add framework services.
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
    
            services.AddMvc();
    
            services.AddSingleton<CategoryCachedRouteDataProvider>();
        }
    
        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseBrowserLink();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
            }
    
            app.UseStaticFiles();
    
            app.UseMvc(routes =>
            {
                routes.Routes.Add(
                    new CachedRoute<int>(
                        controller: "Category",
                        action: "Index",
                        dataProvider: app.ApplicationServices.GetRequiredService<CategoryCachedRouteDataProvider>(),
                        cache: app.ApplicationServices.GetRequiredService<IMemoryCache>(),
                        target: routes.DefaultHandler)
                    {
                        CacheTimeoutInSeconds = 900
                    });
    
                routes.MapRoute(
                    name: "default",
                    template: "{controller=Home}/{action=Index}/{id?}");
            });
        }
    }
    

    请注意,CachedRoute 可以重复使用来为其他表创建额外的路由.因此,如果您愿意,您还可以通过在 Category 表上使用连接并使用类似的方法来构建您的产品 URL,例如 guitars/acoustic-guitars/some-fancy-acoustic-guitar网址.

    Note that the CachedRoute<TPrimaryKey> can be reused to create additional routes for other tables. So, if you wanted to, you could also make your product URLs like guitars/acoustic-guitars/some-fancy-acoustic-guitar by using a join on the Category table and using a similar method to build the URL.

    可以使用 Tag Helpers 或任何其他基于 UrlHelper 的方法.例如:

    The URLs can be generated and added to the UI using Tag Helpers or any of the other UrlHelper based methods. For example:

    <a asp-area="" asp-controller="Category" asp-action="Index" asp-route-id="12">Effects</a>
    

    生成为

    <a href="/amps-and-effects/effects">Effects</a>
    

    然后,您当然可以使用模型的主键来生成 URL 和链接的文本 - 使用具有主键和名称的模型完全自动且直接.

    You can of course then use the primary key of the model to generate the URL and the text for the link - it is all automatic and straightforward using models that have the primary key and name.

    您唯一需要做的额外事情就是为链接显示创建层次结构.但这超出了路由的范围.

    The only thing extra you would need to do is to create the hierarchy for the link display. But that is outside of the scope of routing.

    请注意,路由中根本没有层次结构的概念——它只是在每个请求上从上到下匹配的路由列表.

    Note that there is no concept of hierarchy at all in routing - it is simply a list of routes that is matched from top to bottom on each request.

    如果路由模板设置为支持,如何检索叶子类别?例如,从 URL/guitars/acoustic-guitars 中,我想检索 sound-guitars 作为叶子类别,并且能够获取该原声吉他类别下的所有产品.注意:我不想手动解析 URL.理想情况下,最好通过模型绑定来绑定潜在客户类别.

    If the routing template is set up to support that, how to retrieve the leaf category? For example, from the URL /guitars/acoustic-guitars, I would like to retrieve acoustic-guitars as the leaf category, and able to get all products under that acoustic-guitars category. Note: I don't want manual parsing on the URL. Ideally it would be the best if the lead category is binded through model binding.

    不清楚为什么需要叶子类别",因为这与传入或传出路由无关,也不需要查找数据库数据.同样,主键是您根据路由生成整个 URL 所需的全部内容,并且应该是查看所有产品所需的全部内容.但是如果你真的需要访问它,你可以在你的控制器中查找它.

    It is unclear why you would need a "leaf category" as this has nothing to do with incoming or outgoing routes, nor is it required to lookup the database data. Again, the primary key is all you need to generate the entire URL based on routing and it should be all that is required to view all of the products. But if you really need access to it you can look it up in your controller.

    您可能需要根据您的特定要求更改缓存策略.

    You may need to change the caching strategy depending on your specific requirements.

    1. 您可能希望使用在 RAM 中具有固定最大链接数的 LRU 缓存策略
    2. 您可能希望跟踪 URL 被点击的频率并将最常访问的 URL 移至列表顶部
    3. 您可能希望在路由和更新操作方法之间共享缓存,这样当 URL 在数据库中成功更新时,它们也会同时在缓存中更新为实时"URL
    4. 您可能希望单独缓存每个 URL 并一次查找一个,而不是一次缓存整个列表

    这篇关于MVC 路由模板来表示无限的自引用层次类别结构的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆