ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Halo 菜单层级模型重构剖析:从 `children` 聚合到 `menuName` + `parent` 引用式层级

2026/9/9 23:48:33 拓冰建站 浏览量
Halo 菜单层级模型重构剖析:从 `children` 聚合到 `menuName` + `parent` 引用式层级 Halo 菜单层级模型重构剖析从children聚合到menuNameparent引用式层级【免费下载链接】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.nameMenuItem.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还包含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 引用保留原对象给确定性的首个归属菜单其余菜单创建克隆recordOriginalUsecanUseOriginal同一菜单内出现多个父路径首个父路径保留原对象其余父路径克隆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-migratedtruemarkMigrated 方法。并发写冲突通过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→creationTimestampnullsLow→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 菜单项层级 APIConsole 管理端通过两组自定义端点读写某个菜单的可编辑层级后端逻辑集中在 application/src/main/java/run/halo/app/core/endpoint/console/。5.1 读取菜单项树路由GET /apis/console.api.halo.run/v1alpha1/menuitems/-/tree?menuNamemenuoperationId 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}/positionoperationId UpdateMenuItemPosition。请求体为 MenuItemPositionRequest.java参数类型必填含义menuNameString是选中的 Menu 名称parentNameString否目标父级 MenuItem 名称缺省表示移到根级beforeNameString否目标兄弟列表中的前一个兄弟缺省表示追加到末尾MenuItemConsoleService.updatePosition的完整校验链moveapplyMove见 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.javaConsole 层级 APIMenuItemEndpoint.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),仅供参考