Halo 菜单层级模型重构剖析:从children聚合到menuName+parent引用式层级
【免费下载链接】haloHalo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo
导读
本文围绕 Halo 开源的「菜单层级(Menu Hierarchy)」迁移规范展开,讲解 Halo 如何将旧的「父级持有子级」菜单树存储模型,重构为以MenuItem.spec.menuName(归属菜单)与MenuItem.spec.parent(父级引用)为核心的引用式层级模型,并同步保留主题端输出兼容。你将掌握新模型的字段语义、启动期自动迁移算法的边界处理(克隆、环、缺失引用、幂等重试)、主题端与 Console 端的读取/更新 API 设计,以及前端在新建、拖拽、编辑父级、级联删除时的协作约定。
一、为什么需要重构菜单层级模型
Halo 的菜单与菜单项分别由Menu与MenuItem两个扩展(Extension)描述,旧版本中菜单结构采用"聚合式"存储:
Menu.spec.menuItems:保存属于该菜单的(含根级与后代的)MenuItem 名称集合;MenuItem.spec.children:保存挂在该菜单项下的子级 MenuItem 名称集合。
这种"父级记录子级"的模型在层级变更时需要同时维护多条记录,且一个菜单项只能被一个父级"物理持有",很难自然表达同一菜单项被多个菜单复用、或者在多级场景下被引用的情况。从源码注解可以确认其弃用语义:在 api/src/main/java/run/halo/app/core/extension/Menu.java 中,menuItems字段被标注为@Deprecated,说明 "Menu hierarchy is now sourced from MenuItem.spec.menuName and MenuItem.spec.parent";在 api/src/main/java/run/halo/app/core/extension/MenuItem.java 中,children字段同样标记@Deprecated(since = "2.26.0")。
新模型的核心思路是把层级信息"下沉"到每个 MenuItem 自身:
MenuItem.spec.menuName声明它归属于哪个 Menu(对应Menu.metadata.name);MenuItem.spec.parent声明它在同一菜单内的直接父级(对应父 MenuItem 的metadata.name),根级菜单项不设置该字段;MenuItem.spec.priority用于兄弟节点排序。
相比聚合式存储,引用式模型让"谁属于哪个菜单、挂在谁下面"成为单点事实(single source of truth),不再需要在多份父级记录里同步维护集合,也为跨菜单共享、克隆、父子关系变更提供了更清晰的操作边界。相关行业背景可参考 Kubernetes 风格的扁平资源 + 索引查询设计,本规范正是把这种模式应用到了内容建站的多级菜单管理上。
二、新数据模型的字段语义
2.1 MenuItem 新增的两个层级字段
在 api/src/main/java/run/halo/app/core/extension/MenuItem.java 中,两个新字段均声明为可空、兼容旧的裸数据载荷:
| 字段 | 类型 | 含义 | 取值规则 |
|---|---|---|---|
spec.menuName | String | 归属的 Menu 的metadata.name | 顶级项与子级项都必须设置;未设置时视为未归属的遗留数据 |
spec.parent | String | 同菜单内父 MenuItem 的metadata.name | 根级项留空或为null;子级项指向其直接父级 |
spec.priority | Integer | 排序优先级 | 兄弟项排序时"数值越小越靠前"由服务端统一重算 |
MenuItemSpec还包含displayName、href、target(_blank/_self/_parent/_top,见 Menu.java 同文件 Target 枚举)、targetRef(可指向 Category、Tag、Post、SinglePage 等扩展的Ref)以及status(经 targetRef 解析后的实际displayName/href)。本规范聚焦于层级相关字段,其余字段在迁移与建树过程中原样保留。
2.2 弃用但仍保留的旧字段
Menu.spec.menuItems与MenuItem.spec.children在 API 模型中仍存在并标记deprecated,目的是让旧数据能平稳读回。规范明确要求:
- 新数据写入时不再向
Menu.spec.menuItems追加根级项名称,也不再向父项的spec.children追加子级名称; - 运行时菜单查询只依据
spec.menuName/spec.parent建树,绝不回退到旧字段; - 主题端返回的
MenuVo.spec中,spec.menuItems仍是存储层遗留值,不做重算。
三、启动期自动迁移机制
3.1 触发时机与执行入口
迁移逻辑实现在 application/src/main/java/run/halo/app/core/extension/migration/MenuItemHierarchyMigration.java 中。该类通过@EventListener监听ExtensionInitializedEvent,并设置了@Order(Ordered.HIGHEST_PRECEDENCE + 100)保证在应用扩展初始化后尽早执行(见 migration 源文件第 48-66 行)。
迁移启动后会一次性拉取全部Menu与MenuItem,随后执行三阶段流程(migrate() 方法):
- 遍历每个 Menu 经旧字段推导出的"根路径",递归为可达的 MenuItem 写入
spec.menuName与spec.parent; - 为已具备
menuName但缺失迁移标记的 MenuItem 补打迁移标签; - 输出迁移统计摘要。
迁移完成后会记录一条信息日志,格式为:Menu item hierarchy migration finished: menus=…, updated=…, clonesCreated=…, clonesReused=…, warnings=…, failures=…迁移失败不会阻断 Halo 启动(onErrorResume后仅记录错误日志继续启动)。
3.2 根级推导与环/孤立分支兜底
由于旧版 Console 会把菜单的所有成员(不只是根级项)都写入Menu.spec.menuItems,迁移器不能简单地把集合中每个名字当根级。MigrationContext.rootPaths()中legacyRootNames会先收集每个成员的旧式后代集合,再筛选出"不是任何成员的旧式后代"的成员作为根路径候选;对环状或失联的连通分量,则会挑选其中一个成员作为访问入口,避免把分量内每个成员都误当根(见 migration 源文件第 335-395 行)。
3.3 迁移中的边界场景处理
迁移算法是"确定性、可重试"的,相关常量定义在 MenuItem.java 第 25-30 行:
| 场景 | 迁移行为 | 源码依据(常量/逻辑) |
|---|---|---|
| 旧引用指向不存在的 MenuItem | 跳过该缺失引用,继续迁移其余可达项 | migratePath对getItem(...) == null分支记录 warning 后返回空 |
沿spec.children追踪会成环 | 跳过该环边,继续迁移无环可达路径 | migrateChildren检测currentPath.contains(childName) |
| 同一 MenuItem 被多个 Menu 引用 | 保留原对象给确定性的首个归属菜单;其余菜单创建克隆 | recordOriginalUse+canUseOriginal |
| 同一菜单内出现多个父路径 | 首个父路径保留原对象,其余父路径克隆 | forceClone参数随路径递归传递 |
| 克隆冲突路径 | 路径上的后代一并克隆,克隆后代spec.parent指向对应克隆父 | 递归migratePath(..., forceClone) |
| 迁移重复执行 | 通过 annotation 精确匹配已建克隆并复用,不重复创建 | findClone按 4 个 annotation 判定 |
| 已有新字段值 | 新字段非空时不覆盖(仅填缺失值) | migrateOriginal中if (!hasText(...))条件 |
打标了hierarchy-migrated却缺menuName | 视为未完成,再次尝试迁移 | labelAssignedMenuItems仅给"有 menuName 无 label"补标签;有 label 无 menuName 的项会进入路径迁移补写 |
3.4 克隆记录的注解元数据
每次创建克隆时,迁移器会用JsonUtils.deepCopy深拷贝原对象并清空名字改用generateName = "menu-item-",再写入 4 个注解(cloneMenuItem 方法):
halo.run/original-menu-item-name:原 MenuItem 名称;halo.run/menu-item-migration-menu-name:克隆归属的菜单;halo.run/menu-item-migration-parent-name:克隆的目标父级(空串表示根级);halo.run/menu-item-migration-path:迁移时到达该对象的原路径 JSON。
findClone依据这 4 个注解的精确匹配来找回既有克隆,从而保证重复执行迁移不会产生重复克隆。正常迁移项则会被打上标签halo.run/menu-item-hierarchy-migrated=true(markMigrated 方法)。并发写冲突通过client.update/create配合Retry.backoff(3, …)过滤OptimisticLockingFailureException处理。迁移器同时还保证:旧字段Menu.spec.menuItems、MenuItem.spec.children全程不被改写,Menu.spec.menuItems里首菜单名对应的Menu存在时,迁移后才按需更新索引(MenuItemReconciler)。
围绕上述边界,仓库提供了完整测试:MenuItemHierarchyMigrationTest.java,覆盖共享项克隆、多重父路径、缺失引用、环、重复迁移等场景。
四、主题端运行时查询与输出兼容
4.1 主题菜单查询入口
主题菜单查询走MenuV1alpha1Public组的/apis/api.halo.run/v1alpha1/menus/-(主菜单)与/apis/api.halo.run/v1alpha1/menus/{name}(按名查询),路由定义在 application/src/main/java/run/halo/app/core/endpoint/theme/MenuQueryEndpoint.java。其中-会被解析为系统设置中配置的"主菜单"名称(SystemSetting.Menu.primary,见 MenuQueryEndpoint 第 71-81 行);未配置时抛ServerWebInputException。
4.2 建树算法:只认新字段
MenuFinderImpl是主题端menuFinder的默认实现(application/src/main/java/run/halo/app/theme/finders/impl/MenuFinderImpl.java):
- 按名称取到
Menu; - 用
Queries.equal("spec.menuName", menuName)查出该菜单的全部 MenuItem(第 130-135 行),不再触碰旧字段; - 按
spec.parent分组把子级挂到父级节点下,形成MenuItemVo.children树(listToTree,第 85-105 行)。
建树时的健壮性策略与规范完全一致:
- 无效父引用(缺失、自引用、不在同一菜单、指向自身后代的环):
hasValidParent逐一排除,使这类 MenuItem 被渲染为所在菜单的根级项; - 兄弟排序:
defaultTreeNodeComparator按priority→creationTimestamp(nullsLow)→metadata.name的字典序稳定排序(第 137-149 行); - 即便
Menu.spec.menuItems/MenuItem.spec.children与新字段不一致,查询结果也只以新字段为准,不回退旧字段。
4.3 主题端 Value Object 形状保持
树形结果以MenuVo/MenuItemVo两个值对象返回(MenuVo.java、MenuItemVo.java):
- 树整体挂在
MenuVo.menuItems下; - 子级递归嵌套在
MenuItemVo.children中; MenuItemVo.parentName暴露直接父级名,spec/status透传;MenuVo.spec直接来自存储的Menu.getSpec(),其中遗留的spec.menuItems保持原值不重算;- 主题模板因此可以继续沿用旧版渲染方式(
menuFinder.list()/ 自定义递归输出),仅数据来源从旧字段切换到了新字段。
主题侧对应测试见 MenuFinderImplTest.java 与 MenuQueryEndpointTest.java。
五、Console 菜单项层级 API
Console 管理端通过两组自定义端点读写"某个菜单的可编辑层级",后端逻辑集中在 application/src/main/java/run/halo/app/core/endpoint/console/。
5.1 读取菜单项树
路由:GET /apis/console.api.halo.run/v1alpha1/menuitems/-/tree?menuName=<menu>(operationId = ListMenuItemTree),见 MenuItemEndpoint.java 第 28-39 行。
服务端MenuItemConsoleService.listTree先以equal("spec.menuName", menuName)查询出该菜单全部 MenuItem,再调用listToTree生成树。listToTree的核心行为(MenuItemConsoleService.java 第 141-196 行):
- 返回的节点结构为
{ "menuItem": {...}, "children": [...] }(MenuItemTreeNode,见 MenuItemTreeNode.java),其中children是纯视图数据,绝不回写MenuItem.spec.children; - 无效父引用(缺失、指向自己、指向菜单外、构成环)会被视为根级节点;环上的节点从环路径中抽出渲染为根级,其有效后代仍按正常父子链挂载;
- 兄弟节点统一按
priority→creationTimestamp→metadata.name排序。
5.2 移动/更新菜单位置
路由:PUT /apis/console.api.halo.run/v1alpha1/menuitems/{name}/position(operationId = UpdateMenuItemPosition)。请求体为 MenuItemPositionRequest.java:
| 参数 | 类型 | 必填 | 含义 |
|---|---|---|---|
menuName | String | 是 | 选中的 Menu 名称 |
parentName | String | 否 | 目标父级 MenuItem 名称;缺省表示移到根级 |
beforeName | String | 否 | 目标兄弟列表中的"前一个"兄弟;缺省表示追加到末尾 |
MenuItemConsoleService.updatePosition的完整校验链(move+applyMove,见 MenuItemConsoleService.java 第 48-133 行):
- 归属校验:被移动项的
spec.menuName必须等于请求的menuName,迁移中不允许变更归属菜单; - 自引用校验:
parentName不能等于自身; - 同菜单存在性校验:
parentName/beforeName引用的 MenuItem 必须存在于所选菜单; - 环校验:不允许把项移到自身或自身任一代后代的下面(
isDescendant沿父链回溯检测); - 兄弟一致性校验:
beforeName必须位于目标父级下的兄弟列表中,否则拒绝; - 优先级重算:目标兄弟列表按下标连续重排为从 0 开始的整数
priority;若父级发生变化,原兄弟列表同样重排;仅持久化spec.parent或spec.priority真正发生变化的 MenuItem; - 成功响应为所选菜单最新的规范树,由调用方替换本地状态。
重试与冲突:位置更新对OptimisticLockingFailureException会退避重试,重试耗尽时返回409 CONFLICT(见 updatePosition 方法)。
5.3 级联删除菜单
路由:DELETE /apis/console.api.halo.run/v1alpha1/menus/{name}(operationId = DeleteMenu),见 MenuEndpoint.java。
MenuConsoleService.deleteMenu 的删除顺序是:先按spec.menuName == 菜单名查出并逐个删除其拥有的全部 MenuItem,全部成功后才删除 Menu 本身。任一 MenuItem 删除失败都会让整个请求失败并不删除 Menu,从而避免出现"菜单没了、菜单项却成了孤儿"或"只删了一部分"的中间态。删除范围明确以新字段spec.menuName界定,不使用遗留的Menu.spec.menuItems。
六、Console 前端的协作约定
对应前端代码位于 ui/console-src/modules/interface/menus/,其与后端的协作必须遵守以下约定(源自规范中的行为场景):
- 数据来源:选中一个菜单后,可编辑树必须从
ListMenuItemTreeAPI 加载,不能在前端用扁平列表自己拼装层级,也不能从前端批量请求全量 MenuItem 再本地过滤; - 新建根级项:创建时只设置
spec.menuName = 所选菜单名、不设spec.parent,不把新项名追加进Menu.spec.menuItems; - 新建子级项:同时设置
spec.menuName与spec.parent,不改写父级spec.children;创建时的"父级下拉选项"须来自所选菜单的规范树,且只展示该菜单内节点; - 拖拽保存:一次拖拽只发一个
UpdateMenuItemPosition请求(携带所选菜单名、目标父级、目标兄弟),前端不自行计算 priority、不做批量 hierarchy JSON Patch,成功后用后端返回的规范树替换本地树; - 拖拽失败回滚:更新失败时重新拉取该菜单的规范树,丢弃未确认的本地拖拽状态,不把未提交的层级当作已持久化结构;
- 编辑项修改父级:编辑弹窗中的父级选择器初始值来自该项当前
spec.parent(无父级时默认根级选项);候选父级排除该项自身及其全部后代;仅当父级确实改变时才额外发一次 position 更新(parentName为新父级、beforeName置空即追加到目标兄弟列表末尾);若普通字段保存成功而父级移动失败,则重新加载规范树且不尝试回滚已保存的普通字段; - 删除项:调用后端删除接口后由后端处理该 MenuItem 及其按
spec.parent推导出的全部后代,前端不改写Menu.spec.menuItems; - 删除当前选中的菜单:成功后自动切换到下一个可用菜单,跳过正在删除中的菜单;没有其他菜单时清空当前选择与
menu查询参数; - 克隆菜单:克隆源菜单中
spec.menuName == 源名的全部 MenuItem,克隆项spec.menuName指向新菜单名、子级spec.parent指向对应的克隆父级,且新菜单不复制源菜单遗留的spec.menuItems。
七、总结与实现参考
Halo 的菜单层级迁移本质上是把"层次结构存于父容器"升级为"归属与父级存于叶子自身",并配套了一整套健壮的迁移、查询与编辑管线。对运维而言,升级后最直接的收益是:菜单树数据不再受"单一物理父级"与集合同步问题的困扰,跨菜单复用、拖拽排序与级联删除都有了清晰的单点事实与后端权威校验。
想进一步深入,可沿以下路径阅读当前仓库:
- 模型定义:api/src/main/java/run/halo/app/core/extension/Menu.java、api/src/main/java/run/halo/app/core/extension/MenuItem.java(含弃用字段与迁移常量、标签注解常量)
- 迁移实现与测试:MenuItemHierarchyMigration.java、MenuItemHierarchyMigrationTest.java
- 主题端建树与查询:MenuFinderImpl.java、MenuQueryEndpoint.java、MenuVo.java、MenuItemVo.java
- Console 层级 API:MenuItemEndpoint.java、MenuItemConsoleService.java、MenuEndpoint.java、MenuConsoleService.java 及对应
*Test.java(如 MenuItemConsoleServiceTest.java、MenuItemEndpointTest.java) - 规范原文:openspec/specs/menu-hierarchy/spec.md
说明:以上行为场景来自 Halo 官方开规格书 openspec/specs/menu-hierarchy/spec.md,文中路由、字段、排序与校验规则均可结合对应源码与测试核实,适用于本仓库所对应的 Halo 版本演进(
MenuItem.spec.children自 2.26.0 起弃用)。
【免费下载链接】haloHalo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考