定位器
定位器回答一个问题:宿主侧代码怎么拿到插件上下文里的服务实现。 先要分清两层,"看不见"这三个字在不同层的意思完全不同。
| 层面 | 能不能跨插件 | 为什么 |
|---|---|---|
| 类(编译期 / 类加载期) | 能 | PluginClassLoader.findClass 在"从自己的 URL 定义"之前,先按包名把类委托给拥有它的那个插件加载器(cn.wujies.member.* 一律交给 member 的加载器)。所以同一个 FQCN 在 JVM 里只有一个 Class 对象 |
| Spring bean(运行期) | 不能 | 每个插件有自己的子上下文,parent 是宿主。你的上下文里没有兄弟的 bean,@Autowired IMemberService 拿不到(注入不到 ≠ 类型看不见) |
所以"依赖兄弟插件构件(provided)"买到的是类型可见,不是 bean 可见。
运行期拿 bean 只有下面两条路。
五个类,谁是谁
| 类 | 在哪 | 角色 |
|---|---|---|
PluginServiceProvider | wujies-common-core 的 plugin/ | SPI 接口。由插件运行时(PluginRuntimeManager)实现;宿主未启用插件运行时时这个 bean 可以缺席 |
PluginServiceLocator | wujies-common-core 的 plugin/ | 通用实现。把"先问运行时、再回落宿主上下文、抛请到应用市场安装、生成反查代理"这套逻辑只写一遍 |
MemberServiceLocator | wujies-common-plugin-sdk | PluginServiceLocator 的薄包装,appCode=member(会员管理) |
PayServiceLocator | wujies-common-plugin-sdk | 同上,appCode=pay(支付管理) |
ImServiceLocator | wujies-common-plugin-sdk | 同上,appCode=im(即时通讯) |
三个定位器都标了 @AutoConfiguration,并由 SDK 的
META-INF/spring/...AutoConfiguration.imports 注册 —— 消费方直接 @Autowired 就能拿到。
各应用的 appCode 与 app.json 的 code 必须一致。
重复的代价不只是 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 接口 | 直接 @Autowired | PluginMapperRegistrar 在插件加载时把 Mapper 接口注册进宿主的 bean factory,所以它其实是宿主上下文的 bean。代价:你从此隐式依赖对方的数据表 |
| 插件要调兄弟插件的 Service | 兄弟的定位器:MemberServiceLocator / PayServiceLocator / ImServiceLocator | 先 available(...) 判断,再 get(...);MemberServiceLocator 住在 SDK 里,插件不需要额外依赖 |
| 宿主想读会员资料这种"数据面"契约 | PlatformMemberApi(配 MemberServiceLocator 取实现) | 见平台 SDK 契约:视图与命令都定义在 SDK,宿主看不见插件自己的 VO |
哪怕只是 @Bean IMemberService memberService() 返回一个转发代理,也会让宿主上下文里出现与插件实现
同类型的 bean。Spring 按类型注入走 beanNamesForTypeIncludingAncestors,会把父上下文的同类型 bean
一起算作候选 —— 插件的控制器注入自己的服务时启动即报
expected single matching bean but found 2(实测踩到)。
proxy(...) 生成的代理不是 bean,不进容器,正是为了避开这一条。
get 的三步查找
PluginServiceLocator.get(Class<T> api) 的顺序是固定的:
- 插件运行时存在且
isPluginLoaded(appCode)→getPluginBean(appCode, api),取到就返回; - 否则回落宿主上下文:
hostContext.getBeanProvider(api).getIfAvailable()。 这条回落是给"未启用插件运行时、插件 jar 经loader.path挂到线程上下文类加载器、 实现被宿主组件扫描注册进宿主上下文"的旧模式用的 —— 少了它,默认模式下的消费方会误报"应用未安装"; - 都没有 → 抛
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 条。
相关阅读
- 平台 SDK 契约 —— 能力接口与值对象的完整清单
- 插件运行时 —— 子上下文、类加载器与 Mapper 注册的实现
- 必须遵守的不变量 —— 第 5 条(宿主看不到插件)与第 2 条(契约随插件走)
- 跟着 hello 读源码 —— 跨插件调用的两种做法都有可运行端点