MenuProviderExtension
MenuProviderExtension 是插件向平台导航栏动态添加菜单项的标准契约。与 menus.json 静态声明不同,该 SPI 在每次菜单构建请求时触发,并接收实时的用户和租户上下文——从而支持基于角色的导航门控、功能开关驱动的菜单树,以及租户级菜单自定义,无需改动插件清单。
当菜单项的可见性取决于当前用户身份、活跃租户或运行时配置时,应使用此 SPI。若菜单项对所有人始终可见且不会变化,menus.json 更简洁;若可见性依赖上下文,请使用此 SPI。
概念
平台的菜单构建流程从两个来源汇聚贡献:插件清单中声明的静态 menus.json 条目,以及注册为 MenuProviderExtension 实现的动态提供者。提供者在插件加载时通过 pf4j 发现,并在静态条目解析完成后被调用。平台首先调用 isActive(context)——若返回 false,则不再调用 getMenuItems(context),该提供者本次不贡献任何项。
MenuContext 携带当前请求的完整身份信息:tenantId、userId、userRoles、userPermissions、发起方 pluginId、其 namespace,以及存放运行时标志的 settings 映射。两个便捷方法——hasRole(String) 和 hasPermission(String)——让提供者可以内联门控菜单项,无需手动遍历列表。
MenuItem 是贡献的最小单元。条目通过 children 嵌套,自带 requiredRoles 和 requiredPermissions 声明(由平台渲染器强制执行),并支持通过 target 设置外部链接。metadata 映射会转发给前端渲染器,供扩展特定的装饰使用。
接口签名
package com.auraboot.framework.plugin.extension;
public interface MenuProviderExtension extends ExtensionPoint {
/**
* 本提供者贡献到的菜单组。
* 常用组名:"main-sidebar"、"header-menu"、"user-menu"、"settings-menu"
*/
String getMenuGroup();
/**
* 返回给定上下文下需要添加的菜单项。
*/
List<MenuItem> getMenuItems(MenuContext context);
/**
* 守卫方法。返回 false 则本次不贡献任何项。
* 默认:true(始终激活)。
*/
default boolean isActive(MenuContext context) {
return true;
}
/**
* 在菜单组内的排序。值越小越靠前。
* 默认:100。
*/
default int getOrder() {
return 100;
}
record MenuContext(
Long tenantId,
String pluginId,
String namespace,
Long userId,
List<String> userRoles,
List<String> userPermissions,
Map<String, Object> settings
) {
public boolean hasRole(String role) { ... }
public boolean hasPermission(String permission) { ... }
}
record MenuItem(
String key,
String label,
String icon,
String path,
String target, // 默认 "_self"
int order, // 默认 100
List<MenuItem> children,
List<String> requiredRoles,
List<String> requiredPermissions,
Map<String, Object> metadata
) {}
}实现一个提供者
@Extension
public class BillingMenuProvider implements MenuProviderExtension {
@Override
public String getMenuGroup() {
return "main-sidebar";
}
@Override
public boolean isActive(MenuContext context) {
// 仅当当前用户持有 billing.invoice.view 权限时显示
return context.hasPermission("billing.invoice.view");
}
@Override
public List<MenuItem> getMenuItems(MenuContext context) {
return List.of(
MenuItem.builder()
.key("billing-invoices")
.label("Invoices")
.icon("receipt")
.path("/billing/invoices")
.order(100)
.requiredPermissions(List.of("billing.invoice.view"))
.build(),
MenuItem.builder()
.key("billing-payments")
.label("Payments")
.icon("credit-card")
.path("/billing/payments")
.order(110)
.requiredPermissions(List.of("billing.payment.view"))
.build()
);
}
@Override
public int getOrder() {
return 50; // 排在默认提供者之前
}
}三点说明:
@Extension是必须的。 缺少该注解,pf4j 不会发现实现类,菜单项将静默缺失。isActive是守卫,而非过滤器。 用它提前跳过整个提供者,将单个条目的门控留给MenuItem上的requiredPermissions。label应为 i18n key 或经LocalizedText解析的字符串。 硬编码展示文本违反平台的 i18n 约束(参见插件清单)。
使用场景与规则
| 场景 | 推荐做法 |
|---|---|
| 仅对特定权限用户可见的菜单 | isActive 调用 context.hasPermission(...) |
| 在已有分组条目下嵌套子菜单 | 在父 MenuItem 上设置 children |
| 在新标签页打开的外部链接 | 在条目上设置 target = "_blank" |
与 menus.json 静态条目的排序关系 | 使用 getOrder()——静态与动态条目统一按值排序 |
| 按租户差异化展示菜单(如不同授权模块) | 读取 context.tenantId() 或 context.settings() 中的租户级标志 |
| 特定命名空间下禁用提供者 | 在 isActive 中判断 context.namespace() |
错误语义
getMenuItems 和 isActive 在请求范围的菜单构建过程中同步调用。平台用 catch 块包裹每次提供者调用——提供者抛出未处理异常时,该提供者本次贡献被抑制并记录警告,不会导致页面加载失败。这意味着若异常被吞没,可能出现静默失败。
不要从 getMenuItems 抛出受检异常。若必要依赖不可用,返回空列表并记录原因。不要在 getMenuItems 内访问数据库或发起远程调用——该方法必须快速且无副作用。
审计与关联
菜单提供者调用不单独记录到平台审计日志,因为不产生副作用。若提供者通过 context.settings() 读取了由平台服务支撑的功能开关,该服务自身的状态读取会记录在其对应的审计链中。
如需追踪哪个提供者贡献了哪条菜单项(例如排查菜单缺失),可在 MenuItem 的 metadata 中设置一个稳定的提供者标识键。平台会将 metadata 转发到前端渲染器,它将出现在 React DevTools 组件树中。
常见陷阱
| 模式 | 为什么会出问题 |
|---|---|
遗漏 @Extension | pf4j 不发现该类;菜单项静默缺失 |
在 getMenuItems 内执行 I/O | 阻塞菜单构建请求路径;应在 isActive 中提前跳过,在插件启动时缓存上游数据 |
在 label 中硬编码展示文本 | 违反 i18n 约束;在非默认语言环境下显示为原始字符串 |
getMenuItems 返回 null | 平台期望非 null 的 List;请返回 List.of() |
与 menus.json 中已有的 key 重复 | 导致重复菜单条目;请使用命名空间化的 key,如 billing-invoices 而非 invoices |
仅在 getMenuItems 内检查权限而不在 isActive 中检查 | 略有低效;整体守卫应放在 isActive,避免构建后被丢弃的条目列表 |
与其他 SPI 的关系
- 插件清单 —
menus.json静态贡献 vs 通过此 SPI 动态贡献;无条件的条目优先使用静态声明。 - 权限 — 菜单可见性在两层进行权限门控:提供者层的
isActive/requiredPermissions,以及平台渲染器中的权限强制执行。两者必须与已注册的权限码保持一致。 CommandHandlerExtension— 菜单项通常触发命令;条目上的path一般指向展示命令面板的页面。
企业版说明 — 基于订阅层级的租户级菜单权益门控(仅向特定订阅层的租户开放菜单组)、由组织层级驱动的角色动态菜单树,以及带队列分配的菜单 A/B 测试,均为商业版专属能力,构建于此 SPI 之上。社区版与企业版的
MenuProviderExtension接口完全相同。
下一步
- 插件清单 — 声明静态菜单条目,了解何时应选择动态提供者
- 权限 — 注册
requiredPermissions声明所依赖的权限码 CommandHandlerExtension— 实现菜单项导航到的命令