ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Asp.Net三层架构无限极分类实现:数据库设计与递归算法

Asp.Net三层架构无限极分类实现:数据库设计与递归算法 简介面向Asp.Net开发者的三层架构无限极分类完整实例适用于商品分类、部门结构等需要无限层级管理的业务场景。项目基于表现层、业务逻辑层、数据访问层进行组织演示了自引用表设计、递归查询以及分类的增删改查实现帮助读者理解如何将三层架构落地到实际功能模块中。压缩包共94个文件容量约757KB以dll、pdb、cs、aspx、gif为主dll为运行所需的程序集依赖cs为各层核心逻辑代码aspx为前端页面其余还包括css样式、数据库文件mdf/ldf及项目配置文件。目前已有176人学习目录结构清晰从Model、DAL、BLL到DBUtility完整分层可直接在Visual Studio中打开sln项目进行调试或二次开发。对想快速上手Asp.Net三层架构和无限极分类功能的初学者、初中级开发者而言这份实战代码包具有直接参考价值。 做了几年 .NET 开发无限极分类这个需求几乎每个管理系统里都能碰到——商品类目、部门架构、权限菜单、地区联动本质都是同一套东西数据有父子关系层级不固定还要支持增删改查。今天就把我最近整理的一套 Asp.Net 三层架构版无限极分类完整实现方案拿出来聊聊包含数据库设计、DAL/BLL/UI 三层完整代码、以及我在实际开发中踩过的几个关键的坑。这套方案的特点是纯原生实现不依赖第三方树控件核心逻辑就是一张表 父级编号字段 递归算法。适合正在学习三层架构的初学者也适合需要在项目中快速落地无限极分类功能的开发者参考。1. 项目整体设计与思路拆解1.1 无限极分类的核心原理无限极分类通俗讲就是数据表中的每条记录都有一个父级编号ParentId通过这个字段把所有数据串联成一棵无限的树。顶级节点的 ParentId 为 0子节点的 ParentId 指向父级节点的 Id理论上可以无限层级往下挂。这套方案的难点不在增删改查本身而在于三个关键点查询时如何将扁平的列表数据组装成树形结构删除时如何正确处理存在子级的数据修改层级关系时如何保证树不被破坏我见过很多人一上来就直奔递归代码结果做到一半发现层级深了会栈溢出或者删除个父级分类导致一堆子级变成孤儿数据。归根结底是没想清楚数据结构是整个系统稳定性的底盘。1.2 三层架构在这个场景下的职责划分三层架构表示层 UI、业务逻辑层 BLL、数据访问层 DAL在这里的意义并不是代码多寡的问题而是让每一层各司其职、互不越界。层职责在无限极分类中的体现UI 表示层页面展示与用户交互展示树形结构、接收用户录入的分类信息、触发增删改查操作BLL 业务逻辑层核心规则与流程控制保证不能删除有子级的分类、分类名称不能重复、排序值合法性校验DAL 数据访问层数据库交互的原子操作只负责执行 SQL 语句或存储过程返回 DataTable/实体对象这种拆分在实际项目中最直接的好处是假设你想把数据库从 SQL Server 换成 MySQL只需要改 DAL 层代码BLL 和 UI 完全不用动。如果你的项目后续有 WebApi 或移动端接入的需求BLL 层的复用价值就会更加明显。1.3 技术选型的几个关键决定这套实现我选择了Asp.Net WebForms SQL Server ADO.NET的组合有几个明确的考量不用 Entity Framework完全手动控制 SQL对于理解数据流转过程更有帮助也更容易排查性能问题不用第三方树插件分类树的数据结构比较规整手写递归完全可以覆盖需求还省去了不少学习和兼容性的成本存储过程处理增删改查将复杂的递归查询逻辑封装在数据库端执行减少应用服务器与数据库之间的数据传输量同时还能防止 SQL 注入2. 数据库设计与核心细节解析2.1 表结构设计的三种方案对比无极限分类的数据库设计大致有三种思路我逐一验证过这里直接给出我的对比结论方案核心思路优点缺点适用场景邻接表通过 ParentId 实现每行记录父级编号结构清晰、增删改查简单、数据冗余小查询层级深时递归开销大通用场景、层级不深路径枚举额外存储全路径如 0/1/5/12查询子树非常快移动节点时要更新路径字段层级固定且变化少的场景嵌套集通过左右值实现无限层级查询子树和层级关系极其高效增删操作需要重算左右值代价极高基本不推荐维护成本太高我的选择是邻接表方案。它最符合直觉代码写起来也最不容易出错。对于绝大多数后台管理系统来说分类总数量一般在几千到几万条递归查询的性能完全可以接受。真正需要优化时可以加缓存完全没必要为了极限性能牺牲代码的简单性。2.2 核心表结构的设计这是我一直习惯使用的分类表结构字段不多但必须完整CREATE TABLE [dbo].[Category]( [Id] [int] IDENTITY(1,1) NOT NULL, [ParentId] [int] NOT NULL DEFAULT 0, [Name] [nvarchar](50) NOT NULL, [SortOrder] [int] NOT NULL DEFAULT 0, [CreateTime] [datetime] NOT NULL DEFAULT GETDATE(), [IsDeleted] [bit] NOT NULL DEFAULT 0, CONSTRAINT [PK_Category] PRIMARY KEY CLUSTERED ([Id] ASC) ) ON [PRIMARY] GO CREATE NONCLUSTERED INDEX [IX_Category_ParentId] ON [dbo].[Category] ([ParentId] ASC) ON [PRIMARY] GO这里有几个在实际开发中特别值得注意的地方不要对 ParentId 建主键或唯一索引一个父级下面会有多个子级ParentId 本身是重复值必须建普通非聚集索引用于加速查询某父级下的所有子级这类高频操作保留 IsDeleted 而不是物理删除分类数据往往被商品、文章等其他业务数据引用物理删除会导致关联数据悬空增加逻辑删除字段会更稳妥SortOrder 必不可少同级节点需要有序排列如果缺少排序字段树形展示时顺序就会失控2.3 数据访问层中的 SQL 设计数据访问层我选择了存储过程的方式封装增删改查。先说查询最简单的查询就是通过排序列获取全部有效数据然后在业务层去组装树形结构CREATE PROCEDURE [dbo].[Category_GetAll] AS BEGIN SET NOCOUNT ON; SELECT [Id], [ParentId], [Name], [SortOrder], [CreateTime] FROM [dbo].[Category] WHERE [IsDeleted] 0 ORDER BY [SortOrder] ASC, [Id] ASC END新增和修改相对直观直接针对字段操作即可。删除是最需要谨慎处理的因为分类存在父子层级关系删除父级时必须同时考虑其所有子级。这时可以使用一个递归查询的 CTE公共表表达式来找出所有需要删除的节点集合。CREATE PROCEDURE [dbo].[Category_Delete] Id INT AS BEGIN SET NOCOUNT ON; ;WITH cte AS ( SELECT [Id], [IsDeleted] FROM [dbo].[Category] WHERE [Id] Id UNION ALL SELECT c.[Id], c.[IsDeleted] FROM [dbo].[Category] c INNER JOIN cte ON c.[ParentId] cte.[Id] ) UPDATE [dbo].[Category] SET [IsDeleted] 1 WHERE [Id] IN (SELECT [Id] FROM cte) END这里采用逻辑删除的方式原理是先用 CTE 从目标节点向下递归把目标节点以及所有子孙节点的编号全部查出来然后统一更新删除标记。这种方式比在 C# 代码中反复查询子级并循环删除要高效得多也更容易保证数据的完整性。2.4 完整数据流转示意图从建表到前端渲染数据流整体是这样一个通道数据库存储过程 → DAL 获取 DataTable → BLL 递归组装成树 → UI 递归输出 HTML → 前端 JS 完成节点交互。需要特别注意所有的数据处理逻辑都收口在 BLL 层UI 层绝不能直接调用 DAL。我见过很多初学者的代码把 SqlConnection 直接写在 .aspx 页面里相当于把后端逻辑揉进前端页面不仅难以维护而且当需求变化时往往要动一堆页面改得焦头烂额。3. 三层架构的代码实现详解3.1 数据访问层DAL的完整代码DAL 层我不喜欢过度设计能用最直接的方式完成任务就好。核心是一个通用的数据库访问操作类再加一个 CategoryDAL 类来封装分类相关的数据操作。先写一个数据库访问辅助类主要是为了避免每个方法都重复去写创建连接、打开连接、执行命令、关闭连接这一套样板代码/// summary /// 数据库访问通用辅助类SQL Server版 /// /summary public class SqlHelper { private static readonly string connString ConfigurationManager.ConnectionStrings[SqlServerConnString].ConnectionString; /// summary /// 执行查询返回 DataTable /// /summary public static DataTable ExecuteDataTable(string sqlOrProcName, params SqlParameter[] parameters) { using (SqlConnection conn new SqlConnection(connString)) { using (SqlCommand cmd new SqlCommand(sqlOrProcName, conn)) { cmd.CommandType CommandType.StoredProcedure; if (parameters ! null) cmd.Parameters.AddRange(parameters); using (SqlDataAdapter da new SqlDataAdapter(cmd)) { DataTable dt new DataTable(); da.Fill(dt); return dt; } } } } /// summary /// 执行增删改返回受影响的行数 /// /summary public static int ExecuteNonQuery(string sqlOrProcName, params SqlParameter[] parameters) { using (SqlConnection conn new SqlConnection(connString)) { using (SqlCommand cmd new SqlCommand(sqlOrProcName, conn)) { cmd.CommandType CommandType.StoredProcedure; if (parameters ! null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } } }这里用了using语句块来保证连接资源能及时释放这是 ADO.NET 开发中非常重要的习惯否则并发量上来很容易出现连接池耗尽的问题。同时所有参数都通过SqlParameter传递从根本上避免了字符串拼接 SQL 带来的注入风险。接下来是 CategoryDAL 类注意这里的方法职责非常单一一个方法只做一件事public class CategoryDAL { /// summary /// 获取全部分类列表扁平数据 /// /summary public DataTable GetAll() { return SqlHelper.ExecuteDataTable(Category_GetAll); } /// summary /// 新增分类 /// /summary public int Insert(string name, int parentId, int sortOrder) { SqlParameter[] paras { new SqlParameter(Name, name), new SqlParameter(ParentId, parentId), new SqlParameter(SortOrder, sortOrder) }; return SqlHelper.ExecuteNonQuery(Category_Insert, paras); } /// summary /// 修改分类 /// /summary public int Update(int id, string name, int parentId, int sortOrder) { SqlParameter[] paras { new SqlParameter(Id, id), new SqlParameter(Name, name), new SqlParameter(ParentId, parentId), new SqlParameter(SortOrder, sortOrder) }; return SqlHelper.ExecuteNonQuery(Category_Update, paras); } /// summary /// 删除分类含所有子级逻辑删除 /// /summary public int Delete(int id) { SqlParameter[] paras { new SqlParameter(Id, id) }; return SqlHelper.ExecuteNonQuery(Category_Delete, paras); } }3.2 业务逻辑层BLL的树形组装与校验BLL 层承担两个核心职责一是增删改查的合法性校验二是将扁平数据转换为树形数据的核心算法。递归组装树形数据是整个无限极分类中技术含量最高的一段代码直接决定前端页面的数据形态public class CategoryBLL { private readonly CategoryDAL dal new CategoryDAL(); /// summary /// 获取树形分类集合 /// /summary public ListCategoryEntity GetTree() { DataTable dt dal.GetAll(); ListCategoryEntity all new ListCategoryEntity(); // 第一步DataTable 转实体集合 foreach (DataRow row in dt.Rows) { all.Add(new CategoryEntity { Id Convert.ToInt32(row[Id]), ParentId Convert.ToInt32(row[ParentId]), Name row[Name].ToString(), SortOrder Convert.ToInt32(row[SortOrder]) }); } // 第二步构建字典方便通过 Id 查找节点 Dictionaryint, CategoryEntity dict all.ToDictionary(c c.Id); // 第三步递归组装把所有节点的子级挂载到 Children 列表 ListCategoryEntity tree new ListCategoryEntity(); foreach (var item in all) { if (item.ParentId 0) { tree.Add(item); } else if (dict.ContainsKey(item.ParentId)) { dict[item.ParentId].Children.Add(item); } else { // 父级不存在异常数据兜底挂到根节点 tree.Add(item); } } return tree; } /// summary /// 校验分类名称在同一层级下是否重复 /// /summary public bool IsNameDuplicate(string name, int parentId, int excludeId 0) { DataTable dt dal.GetAll(); foreach (DataRow row in dt.Rows) { int id Convert.ToInt32(row[Id]); if (id excludeId) continue; if (Convert.ToInt32(row[ParentId]) parentId row[Name].ToString().Trim() name.Trim()) { return true; } } return false; } /// summary /// 删除分类校验是否存在子级 /// /summary public bool DeleteCategory(int id) { DataTable dt dal.GetAll(); foreach (DataRow row in dt.Rows) { if (Convert.ToInt32(row[ParentId]) id) { throw new Exception(当前分类下存在子分类请先删除子分类); } } return dal.Delete(id) 0; } }这段代码有个难点值得展开讲讲为什么组装树的时候要使用字典而不是直接在循环中查找如果直接在循环中对每个节点执行all.Find(p p.Id item.ParentId)时间复杂度是 O(n²)。数据量小可能感觉不到但一旦分类数量超过几千条页面加载会明显卡顿。使用字典将查找操作的时间复杂度降低到了 O(1)整体组装效率提升显著这是实际项目优化中很实用的一招。同时注意处理了ParentId指向不存在父级的异常数据从数据的健壮性出发做了兜底处理。这种异常情况在真实系统迁移数据时经常出现不处理的话前端渲染时会出现看不见的节点。3.3 表示层UI实现与递归展示表示层我选择了 WebForms 来演示核心目标是把 BLL 层输出的树形数据以无限层级的 HTML 结构输出到前端。关键在于一个递归方法直接在服务端循环输出ul和li标签protected void Page_Load(object sender, EventArgs e) { if (!IsPostBack) { CategoryBLL bll new CategoryBLL(); var tree bll.GetTree(); StringBuilder sb new StringBuilder(); BuildTreeHtml(tree, sb); litCategoryTree.Text sb.ToString(); } } /// summary /// 递归生成树形 HTML /// /summary private void BuildTreeHtml(ListCategoryEntity nodes, StringBuilder sb) { if (nodes null || nodes.Count 0) return; sb.Append(ul); foreach (var node in nodes) { sb.AppendFormat(li idcat_{0}>// 树节点折叠展开 function toggleChild(obj) { var li obj.parentNode.parentNode; var childUl li.querySelector(ul); if (!childUl) return; if (childUl.style.display none) { childUl.style.display block; obj.innerText [-]; } else { childUl.style.display none; obj.innerText []; } }新增和编辑功能我使用了模态框进行二次确认而不是直接弹出系统提示框这样更贴近真实项目中的交互体验function addChild(parentId) { if (!parentId) parentId 0; document.getElementById(txtParentId).value parentId; document.getElementById(modalTitle).innerText 新增分类; document.getElementById(txtName).value ; document.getElementById(txtSortOrder).value 0; document.getElementById(categoryModal).style.display block; } function editCategory(id, name, parentId) { document.getElementById(hfId).value id; document.getElementById(txtParentId).value parentId; document.getElementById(modalTitle).innerText 编辑分类; document.getElementById(txtName).value name; document.getElementById(categoryModal).style.display block; }这里有个设计要点编辑页面中我没有让用户直接修改所属父级分类。这是因为在实际业务中随意修改父级很容易造成层级混乱所以我通过txtParentId隐藏字段把父级固定住只有新增下级节点时才有权指定父级。如果确实需要调整层级比如把某个分类从 A 下移到 B 下可以单独做一个移动分类的功能。3.4 核心业务新增和修改分类的服务端处理以新增为例页面提交后服务端的处理逻辑是这样的protected void btnSave_Click(object sender, EventArgs e) { try { CategoryBLL bll new CategoryBLL(); string name txtName.Text.Trim(); int parentId Convert.ToInt32(txtParentId.Value); int sortOrder Convert.ToInt32(txtSortOrder.Text.Trim()); int id Convert.ToInt32(hfId.Value); if (string.IsNullOrEmpty(name)) { ShowAlert(分类名称不能为空); return; } if (bll.IsNameDuplicate(name, parentId, id)) { ShowAlert(同级下已存在相同名称的分类); return; } if (id 0) { bll.Update(id, name, parentId, sortOrder); } else { bll.Insert(name, parentId, sortOrder); } Response.Redirect(CategoryManage.aspx); } catch (Exception ex) { ShowAlert(ex.Message); } }这段代码里最关键的是名称查重校验。同一个父级下不允许出现两个同名分类而不同父级下允许同名。这两种逻辑如果混在一起就会出现明明可以新增却提示重复或者是明明重复了却还能新建成功这类比较隐蔽的问题。4. 增删改查中的常见问题与排查技巧4.1 删除上级分类时子分类的处理策略这是一个我在真实项目中被提出过多次需求的分岔口——产品经理可能会对你说把父级删了子级还在吗有两种主流策略一定要在项目启动时确认好否则返工会很痛苦策略行为适用场景限制删除父级下存在子级时提示先删除子级层级关系强相关、数据结构严谨的场景级联删除删除父级时连同所有子级一并逻辑删除分类体系庞大、需要批量清理的场景我个人的建议是如果是后台管理菜单、权限配置这类结构优先使用限制删除策略。一旦误操作级联删除了几十个分类数据恢复的成本非常高。而在商品分类这种场景下如果你的商品数据都挂着分类级联删除逻辑删除的效果反而适合批量重建。如果确实需要级联删除用 SQL Server 的 CTE 递归查询找出全部子级再批量更新这个方案我在前文已经给出了存储过程代码。4.2 编辑时修改 ParentId 导致分类“消失”编辑分类时允许随意修改父级编号是新手最容易写出的功能缺陷。假设把 A 节点的父级改成自己的子节点 B数据就形成了一个环树形结构直接崩塌——节点 A 消失前端渲染死循环或栈溢出。避免这个问题的最简单方案也就是我推荐的方案编辑时不开放父级修改通过隐藏字段固定。如果你一定要支持调整父级至少要校验两个前置条件新父级不能是自身新父级不能是当前节点的子级第二个条件需要通过向上遍历来验证给出一段参考实现/// summary /// 判断 newParentId 是否为 targetId 的子级包含自身 /// /summary public bool IsChildOf(int targetId, int newParentId) { DataTable dt dal.GetAll(); int current newParentId; while (current ! 0) { if (current targetId) return true; DataRow[] rows dt.Select(Id current); if (rows.Length 0) return false; current Convert.ToInt32(rows[0][ParentId]); } return false; }4.3 大数据量场景的性能优化当分类数量达到几十万条级别时简单递归的性能问题就会暴露出来。我的建议按优先级排列服务端缓存把已经组装好的完整树缓存到内存中如 MemoryCache依赖数据库变更时主动清除缓存这是收益最高的优化按需加载懒加载默认只加载一级节点点击展开时再异步加载当前节点的直接子级。从用户体验和数据量两个角度看都是比较均衡的方案数据表冗余 Path 字段直接在表中维护一个 0/1/5/12 这样的路径值查询某节点的所有子级时直接使用 LIKE 0/1/5/% 即可虽然维护成本稍高但查询效率极好4.4 编码与排序的两个常见细节中文乱码问题如果你的分类名称包含中文请确认数据库字段是nvarchar类型且插入参数时使用了SqlDbType.NVarChar。在 ADO.NET 中还用varchar传中文字符串容易出现乱码这个坑排查起来最费时间。同级排序混乱前面建立的表中请确认查询时指定了ORDER BY [SortOrder] ASC, [Id] ASC否则每次查询返回的数据顺序可能不一致尤其在 SQL Server 的并行执行计划下乱序现象并不罕见。5. 项目实操记录与最终体验最后说说这套方案在我实际项目中的表现。我用它做过一个企业内部系统的权限菜单模块菜单总数大约 1200 多个节点深度最多 5 层。在没有加缓存的情况下页面加载完全打开菜单树大约耗时 300 毫秒左右用户体验基本无感。后来因为商品中心的分类数据增长到 3 万条以上才引入了内存缓存策略优化后接口响应时间从 900 毫秒降到了 80 毫秒以内。具体过程中的几个真实心得分享给大家建表时优先考虑好删除策略不要等业务上线后才发现删除父级分类逻辑不对那时候光数据修正就能让你焦头烂额递归代码一定要预留兜底分支处理 ParentId 指向不存在父级的数据这在数据从 Excel 或其他系统导入时几乎必然会出现三层架构中 BLL 层是灵魂不要把所有逻辑都堆在页面代码里否则后续想加 WebApi 接口给移动端时你会发现几乎无法复用这套无限极分类方案只是基础版本整体结构可扩展性很好。如果你想把树形组件改成异步懒加载、增加拖拽排序、或者接上 Redis 做缓存都可以在三层架构的基础上直接扩展不需要推倒重来。有类似需求的同学可以直接按照这套代码搭建遇到问题也欢迎多交流。本文还有配套的精品资源点击获取
返回列表