Halo 菜单层级模型重构剖析:从 `children` 聚合到 `menuName` + `parent` 引用式层级
2026/9/9 23:48:27 网站建设 项目流程

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 的菜单与菜单项分别由MenuMenuItem两个扩展(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.menuNameString归属的 Menu 的metadata.name顶级项与子级项都必须设置;未设置时视为未归属的遗留数据
spec.parentString同菜单内父 MenuItem 的metadata.name根级项留空或为null;子级项指向其直接父级
spec.priorityInteger排序优先级兄弟项排序时"数值越小越靠前"由服务端统一重算

MenuItemSpec还包含displayNamehreftarget_blank/_self/_parent/_top,见 Menu.java 同文件 Target 枚举)、targetRef(可指向 Category、Tag、Post、SinglePage 等扩展的Ref)以及status(经 targetRef 解析后的实际displayName/href)。本规范聚焦于层级相关字段,其余字段在迁移与建树过程中原样保留。

2.2 弃用但仍保留的旧字段

Menu.spec.menuItemsMenuItem.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 行)。

迁移启动后会一次性拉取全部MenuMenuItem,随后执行三阶段流程(migrate() 方法):

  1. 遍历每个 Menu 经旧字段推导出的"根路径",递归为可达的 MenuItem 写入spec.menuNamespec.parent
  2. 为已具备menuName但缺失迁移标记的 MenuItem 补打迁移标签;
  3. 输出迁移统计摘要。

迁移完成后会记录一条信息日志,格式为: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跳过该缺失引用,继续迁移其余可达项migratePathgetItem(...) == null分支记录 warning 后返回空
沿spec.children追踪会成环跳过该环边,继续迁移无环可达路径migrateChildren检测currentPath.contains(childName)
同一 MenuItem 被多个 Menu 引用保留原对象给确定性的首个归属菜单;其余菜单创建克隆recordOriginalUse+canUseOriginal
同一菜单内出现多个父路径首个父路径保留原对象,其余父路径克隆forceClone参数随路径递归传递
克隆冲突路径路径上的后代一并克隆,克隆后代spec.parent指向对应克隆父递归migratePath(..., forceClone)
迁移重复执行通过 annotation 精确匹配已建克隆并复用,不重复创建findClone按 4 个 annotation 判定
已有新字段值新字段非空时不覆盖(仅填缺失值)migrateOriginalif (!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.menuItemsMenuItem.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):

  1. 按名称取到Menu
  2. Queries.equal("spec.menuName", menuName)查出该菜单的全部 MenuItem(第 130-135 行),不再触碰旧字段;
  3. spec.parent分组把子级挂到父级节点下,形成MenuItemVo.children树(listToTree,第 85-105 行)。

建树时的健壮性策略与规范完全一致:

  • 无效父引用(缺失、自引用、不在同一菜单、指向自身后代的环)hasValidParent逐一排除,使这类 MenuItem 被渲染为所在菜单的根级项;
  • 兄弟排序defaultTreeNodeComparatorprioritycreationTimestampnullsLow)→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
  • 无效父引用(缺失、指向自己、指向菜单外、构成环)会被视为根级节点;环上的节点从环路径中抽出渲染为根级,其有效后代仍按正常父子链挂载;
  • 兄弟节点统一按prioritycreationTimestampmetadata.name排序。

5.2 移动/更新菜单位置

路由:PUT /apis/console.api.halo.run/v1alpha1/menuitems/{name}/positionoperationId = UpdateMenuItemPosition)。请求体为 MenuItemPositionRequest.java:

参数类型必填含义
menuNameString选中的 Menu 名称
parentNameString目标父级 MenuItem 名称;缺省表示移到根级
beforeNameString目标兄弟列表中的"前一个"兄弟;缺省表示追加到末尾

MenuItemConsoleService.updatePosition的完整校验链(move+applyMove,见 MenuItemConsoleService.java 第 48-133 行):

  1. 归属校验:被移动项的spec.menuName必须等于请求的menuName,迁移中不允许变更归属菜单
  2. 自引用校验parentName不能等于自身;
  3. 同菜单存在性校验parentName/beforeName引用的 MenuItem 必须存在于所选菜单;
  4. 环校验:不允许把项移到自身或自身任一代后代的下面(isDescendant沿父链回溯检测);
  5. 兄弟一致性校验beforeName必须位于目标父级下的兄弟列表中,否则拒绝;
  6. 优先级重算:目标兄弟列表按下标连续重排为从 0 开始的整数priority;若父级发生变化,原兄弟列表同样重排;仅持久化spec.parentspec.priority真正发生变化的 MenuItem;
  7. 成功响应为所选菜单最新的规范树,由调用方替换本地状态。

重试与冲突:位置更新对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.menuNamespec.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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询