Wujies 无界平台
平台功能(自研)架构设计

定位器

定位器回答一个问题:宿主侧代码怎么拿到插件上下文里的服务实现。 先要分清两层,"看不见"这三个字在不同层的意思完全不同。

层面能不能跨插件为什么
类(编译期 / 类加载期)能PluginClassLoader.findClass 在"从自己的 URL 定义"之前,先按包名把类委托给拥有它的那个插件加载器(cn.wujies.member.* 一律交给 member 的加载器)。所以同一个 FQCN 在 JVM 里只有一个 Class 对象
Spring bean(运行期)不能每个插件有自己的子上下文,parent 是宿主。你的上下文里没有兄弟的 bean,@Autowired IMemberService 拿不到(注入不到 ≠ 类型看不见)

所以"依赖兄弟插件构件(provided)"买到的是类型可见,不是 bean 可见。 运行期拿 bean 只有下面两条路。

五个类,谁是谁

类在哪角色
PluginServiceProviderwujies-common-core 的 plugin/SPI 接口。由插件运行时(PluginRuntimeManager)实现;宿主未启用插件运行时时这个 bean 可以缺席
PluginServiceLocatorwujies-common-core 的 plugin/通用实现。把"先问运行时、再回落宿主上下文、抛请到应用市场安装、生成反查代理"这套逻辑只写一遍
MemberServiceLocatorwujies-common-plugin-sdkPluginServiceLocator 的薄包装,appCode=member(会员管理)
PayServiceLocatorwujies-common-plugin-sdk同上,appCode=pay(支付管理)
ImServiceLocatorwujies-common-plugin-sdk同上,appCode=im(即时通讯)

三个定位器都标了 @AutoConfiguration,并由 SDK 的 META-INF/spring/...AutoConfiguration.imports 注册 —— 消费方直接 @Autowired 就能拿到。 各应用的 appCode 与 app.json 的 code 必须一致。

为什么通用逻辑放在 common-core 而不是每应用抄一份

重复的代价不只是 160 行。代理里有两个极易写错的点:不能把 toString / hashCode / equals 转发给插件(它们会被日志、集合、断言随时调到,插件没装时不该因为一次 toString() 就抛异常), 必须拆 InvocationTargetException 的包(否则业务异常会被 JDK 包成 UndeclaredThrowableException, 把"未安装"变成一句看不懂的堆栈)。抄错了只在"插件没装"这个分支上炸。

宿主用哪个,插件之间用哪个

场景用什么注意
宿主模块(portal / job 等)要调会员 / 支付 / IM 的服务对应的 MemberServiceLocator / PayServiceLocator / ImServiceLocator宿主注入不到插件 bean,这是唯一的路;构造注入一个 proxy(...) 就能像普通 bean 一样持有
宿主模块要调其它应用的服务PluginServiceProvider.getPluginBean(appCode, type)SPI 方式,需要自己 isPluginLoaded(appCode) 判在不在
插件要调兄弟插件的 Mapper 接口直接 @AutowiredPluginMapperRegistrar 在插件加载时把 Mapper 接口注册进宿主的 bean factory,所以它其实是宿主上下文的 bean。代价:你从此隐式依赖对方的数据表
插件要调兄弟插件的 Service兄弟的定位器:MemberServiceLocator / PayServiceLocator / ImServiceLocator先 available(...) 判断,再 get(...);MemberServiceLocator 住在 SDK 里,插件不需要额外依赖
宿主想读会员资料这种"数据面"契约PlatformMemberApi(配 MemberServiceLocator 取实现)见平台 SDK 契约:视图与命令都定义在 SDK,宿主看不见插件自己的 VO
别在宿主上下文里注册"转发代理 bean"

哪怕只是 @Bean IMemberService memberService() 返回一个转发代理,也会让宿主上下文里出现与插件实现 同类型的 bean。Spring 按类型注入走 beanNamesForTypeIncludingAncestors,会把父上下文的同类型 bean 一起算作候选 —— 插件的控制器注入自己的服务时启动即报 expected single matching bean but found 2(实测踩到)。 proxy(...) 生成的代理不是 bean,不进容器,正是为了避开这一条。

get 的三步查找

PluginServiceLocator.get(Class<T> api) 的顺序是固定的:

  1. 插件运行时存在且 isPluginLoaded(appCode) → getPluginBean(appCode, api),取到就返回;
  2. 否则回落宿主上下文:hostContext.getBeanProvider(api).getIfAvailable()。 这条回落是给"未启用插件运行时、插件 jar 经 loader.path 挂到线程上下文类加载器、 实现被宿主组件扫描注册进宿主上下文"的旧模式用的 —— 少了它,默认模式下的消费方会误报"应用未安装";
  3. 都没有 → 抛 ServiceException:「应用「会员管理」未安装或未启用,无法调用 IMemberService。 请到「应用市场」安装并启用它;若已安装,请确认其插件后端已加载(可在「我的应用 → 应用任务」中查看执行日志)。」

注意第 2 步用的是运行期查询(getBeanProvider(...))而不是注入 —— 理由同上一条警告。

未安装时怎么降级

方法未安装时的行为什么时候用
available(Class<?>)返回 false可选依赖:先判,再决定功能开不开
get(Class<T>)抛带应用名的 ServiceException强依赖:没有对方这个功能就没意义,抛清楚比 NPE 好
proxy(Class<T>)返回一个"每次调用都反查"的代理,不会立刻失败宿主侧要像普通 bean 一样持有它(无需把调用点全改成 locator.get(...));顺带解决热升级:每次调用重新 get,插件被卸载时给出上面那句人话,而不是指向已关闭上下文的死引用

范例代码见 HelloDemoCrossPluginController 的 /hello/demo/cross/service:它先 available(...), false 时返回 200 加一句说明而不是抛异常 —— 这才是可选依赖该有的行为。

boolean available = memberServiceLocator.available(IMemberService.class);
if (!available) {
    // 可选依赖:降级,不抛异常
    return;
}
var detail = memberServiceLocator.get(IMemberService.class).queryDetailByUserId(userId);
三个定位器的类注释里有一段过期描述

MemberServiceLocator / PayServiceLocator / ImServiceLocator 的 Javadoc 写着 「Mapper 是宿主侧的 bean(接口与 XML 都在 wujies-member-api,由宿主注册)」。 那批 *-api 契约模块已经不存在了(2026-09-30 起 wujies-modules/*-api 数量为 0, Mapper XML 随插件 jar 走)。结论仍然成立、只是理由变了:Mapper 确实能直接注入, 因为 PluginMapperRegistrar 在插件加载时把它注册进了宿主的 bean factory。 见必须遵守的不变量第 1、2 条。

相关阅读

目录