跳到主要内容

扩展 SPI 内部机制

这一页覆盖插件机制里两个底层接线点:怎么发现插件,以及怎么从插件 jar 里读 classpath 文本资源。它们不是日常写插件会碰到的东西,只有在自定义发现方式、排查资源读取问题或构建宿主框架时才需要。

如果你只是写一个普通插件,看 插件作者实战指南 就够了。

实现机理图(交互式)​

下图把扩展生命周期画全:ServiceLoader 发现 Ai4jExtension,manifest+apply(ctx) 执行注册,ExtensionRegistry 把 enable 与五类显式 expose 门分开,ExtensionContext 暴露七张注册表(tools/commands/skills/prompts/guardrails/lifecycle/interceptors),最终汇入宿主的工具/命令/Skill/Prompt/拦截面。

在新窗口打开全屏大图(含双主题、缩放与导出功能)。

视角补充:扩展发现与启用 — ServiceLoader 发现 Ai4jExtension → manifest 注册 → enable → apply(context) 贡献 tools/prompts/skills → 运行时快照跟踪。

在新窗口打开全屏大图(含明暗双主题、缩放与导出)。

视角补充:运行时隔离 — 每个扩展 JAR 由独立 URLClassLoader 加载(SPI 契约类型仍走父加载器共享),apply() 事务化写暂存态、失败即回滚并释放其类加载器,扩展工具统一 plugin__<id>__<tool> 命名空间,disable 逆序 onStop + 关闭资源。

在新窗口打开全屏大图(含双主题、缩放与导出;右上角视角切换可分别聚焦隔离加载、事务化启用、命名空间与释放)。

1. 插件发现:ExtensionLoader​

1.1 默认路径​

ExtensionRegistry.discover() 不带参数时,使用 @Internal 的默认实现 ServiceLoaderExtensionLoader,它走 JDK 的 ServiceLoader.load(Ai4jExtension.class, classLoader):

public static ExtensionRegistry discover() {
return discover(new ServiceLoaderExtensionLoader());
}

ServiceLoaderExtensionLoader 本身标注 @Internal,意思是“依赖 ExtensionLoader 接口,不要直接依赖这个实现类”。它的构造函数接受一个 ClassLoader,默认用当前线程的 context class loader。

这层 SPI 的存在是因为 ServiceLoader 不是唯一的发现方式。

1.2 ExtensionLoader 接口​

ExtensionLoader 只有一个方法:

public interface ExtensionLoader {
List<Ai4jExtension> load();
}

ExtensionRegistry.discover(ExtensionLoader loader) 接受任意实现:

public static ExtensionRegistry discover(ExtensionLoader loader) {
if (loader == null) {
throw new IllegalArgumentException("extension loader must not be null");
}
return new ExtensionRegistry(loader.load());
}

返回的每个 Ai4jExtension 仍然必须有合法 manifest(manifest() 非 null,id 非空,且 id 不重复),否则 ExtensionRegistry 构造时会 fail-fast。也就是说,自定义 loader 负责“找到 extension 实例”,registry 负责“校验 manifest 并建表”——两层职责分开。

1.3 什么时候写自定义 loader​

只有在 ServiceLoader 不适合时才写自定义 ExtensionLoader:

场景是否需要自定义 loader
普通 Maven / Gradle 依赖插件,classpath 上有 META-INF/services/...否,默认即可
单测里手动构造几个 extension用 ExtensionRegistry.of(extension...),不必写 loader
宿主框架想从固定白名单或运行时扫描结果构造 extension是,写一个返回固定列表的 loader
想换不同的 classloader 隔离策略发现插件是(或直接给 ServiceLoaderExtensionLoader 传指定 classloader)

一个固定列表 loader 的最小实现:

public class FixedListExtensionLoader implements ExtensionLoader {
private final List<Ai4jExtension> extensions;

public FixedListExtensionLoader(List<Ai4jExtension> extensions) {
this.extensions = extensions;
}

public List<Ai4jExtension> load() {
return extensions;
}
}
ExtensionRegistry registry = ExtensionRegistry.discover(
new FixedListExtensionLoader(Arrays.asList(new FooExtension(), new BarExtension())));
备注

自定义 loader 跳过 ServiceLoader 后,仍然由 ExtensionRegistry 做 manifest 校验和去重。不要在 loader 里做启用/暴露/授权——那些是 registry 的职责。

1.4 发现之后怎么检视:DiscoveredExtension​

DiscoveredExtension 不是发现管线里的一个环节——loader 从不产出它。它是 registry 在被检视时按需构造的投影,把每个已登记扩展的三样东西打包给外部读:

public final class DiscoveredExtension {
public ExtensionManifest getManifest(); // id、version、capabilities 等声明
public Ai4jExtension getExtension(); // 扩展实例本身
public String getSourceClassName(); // extension.getClass().getName(),定位来源 jar
public boolean isEnabled(); // 是否通过了启用/暴露门禁
}

入口是 ExtensionRegistry.list():

for (DiscoveredExtension discovered : registry.list()) {
if (!discovered.isEnabled()) {
System.out.println("disabled: " + discovered.getManifest().getId()
+ " @ " + discovered.getSourceClassName());
}
}

enabled 反映的是 registry 的启用门禁(enable/expose 授权),不是 manifest 声明——一个扩展可以已登记但未启用。这个投影有两个真实消费者:ExtensionValidator 遍历 registry.list() 逐个校验 manifest 与 apply(...) 贡献;CLI 的 extension inspect 底层也走它(见 Plugin Author Cookbook — Runtime inspection)。

如果你只是想知道"有哪些扩展、哪些启用了",用 registry.list();要拿单个扩展的 manifest,用 registry.manifest(id),不必自己过滤这个列表。

1.5 隔离加载:IsolatedExtensionLoader​

默认 ServiceLoaderExtensionLoader 把所有扩展加载进同一个 classloader——实现简单,但两个插件携带冲突依赖时会互相污染。IsolatedExtensionLoader(@Experimental)为每个扩展 jar 建一个独立的 URLClassLoader,父加载器固定为加载 ai4j-extension-api 的那个 classloader:

ExtensionRegistry registry = ExtensionRegistry.discover(
IsolatedExtensionLoader.fromDirectory(Paths.get("plugins")));
  • SPI 契约类型(Ai4jExtension、ExtensionContext、manifest、各类 spec)经父加载器共享,隔离不会在契约面上制造 ClassCastException。
  • 每个插件 jar(或等价目录)拿到自己的 URLClassLoader,其私有依赖对其它插件不可见,也不会泄漏到宿主 classpath。
注意

Classloader 隔离解决的是依赖冲突与生命周期管理,不是安全沙箱:恶意插件仍跑在宿主进程内。不可信代码需要的是进程边界,不是 classloader。

1.6 工具命名空间与卸载生命周期​

registerTool 会把工具名改写为 plugin__<extensionId>__<tool>——两个独立插件即使都声明 echo,模型看到的也是 plugin__a__echo 和 plugin__b__echo,永不冲突。Guardrail / interceptor 看到的 request.getTarget() 也是这个命名空间 id。

exposeTool(...) 与 CLI --expose-tool 同时接受两种写法:

写法行为
plugin__pack-a__echo(完整命名空间 id)精确匹配
echo(裸名)解析到唯一以 __echo 结尾的已注册工具;多义时报 ambiguous tool id

生命周期上,Ai4jExtension 提供默认空实现的 onStop() 钩子,已有扩展无需改动:

  • enable(...) 后 apply(...) 抛错:该扩展已做的注册随临时状态整体丢弃(事务式回滚),随后调用 onStop();若经 IsolatedExtensionLoader 加载,其 classloader 一并释放。
  • registry.disable(id):移出启用集合、调用 onStop()、释放其 classloader;下一次 snapshot() 重建的运行时不再包含它的贡献。
  • registry.close():按启用逆序逐个 disable,最后关闭隔离 loader。

注意 disable 不会自动清掉 exposeTool(...) 里的残留条目——下一次 snapshot() 会因 tool not registered 报错。把 enable/expose 与 disable 放在同一层配置管理里,是最稳的姿势。

2. 插件资源读取:ExtensionResourceResolver​

2.1 它解决什么问题​

插件声明的 Skill / Prompt 是 jar 内的 classpath 文本资源(SKILL.md、*.md),通过 resourcePath(...) 注册。运行时要把这些路径解析成实际文本,就会遇到两个问题:

  1. 同名资源串读:classpath 上有多个 jar,如果两个 jar 都有 skills/weather/SKILL.md,普通 ClassLoader.getResourceAsStream(...) 可能返回错误的那个。
  2. 路径规范化:作者写的路径可能带 classpath: 前缀或前导 /,需要归一。

ExtensionResourceResolver 是 ai4j-extension-api 暴露的公共助手,专门处理这两件事。它是一组静态方法,不可实例化。

2.2 解析顺序与 classloader 隔离​

读取的核心方法是重载的 readText / readTextStrict 和 exists / existsStrict。它们的区别在是否允许 classloader 回退:

方法解析顺序用途
readText(path, cl) / exists(path, cl)插件 cl → TCCL → resolver 自身 cl默认兼容读法,尽力找到资源
readTextStrict(path, cl) / existsStrict(path, cl)只在插件 cl 上找严格隔离:插件资源必须在它自己的 jar 里

cl 是“优先 classloader”,通常取自插件实现类的 classloader——ExtensionRegistry.getExtensionClassLoader(extensionId) 返回的就是这个:

public ClassLoader getExtensionClassLoader(String extensionId) {
Ai4jExtension extension = discovered.get(normalized);
ClassLoader classLoader = extension == null ? null : extension.getClass().getClassLoader();
return classLoader == null ? Thread.currentThread().getContextClassLoader() : classLoader;
}

strict 变体把解析约束在插件自己的 classloader 上:插件资源缺失不会被别的 jar 里同名资源掩盖。AI4J 在严格资源授权路径下用 strict 读法,所以插件必须自带它声明的资源,不能“借用”宿主或其它插件的同名文件。

2.3 路径规范化​

normalizeResourcePath(...) 做三件事:

  • 去掉可选的 classpath: 前缀
  • 去掉前导 /(/skills/x.md → skills/x.md)
  • 拒绝包含 .. 的路径,防止把资源路径伪装成任意文件读取
// 这些输入都会归一成 skills/weather/SKILL.md
ExtensionResourceResolver.normalizeResourcePath("classpath:skills/weather/SKILL.md")
ExtensionResourceResolver.normalizeResourcePath("/skills/weather/SKILL.md")
ExtensionResourceResolver.normalizeResourcePath("skills/weather/SKILL.md")

// 含 ".." 会抛 IllegalArgumentException
ExtensionResourceResolver.normalizeResourcePath("skills/../etc/secret.md")

2.4 直接使用​

正常情况下你不需要直接调用 ExtensionResourceResolver——AI4J 在读取 Skill / Prompt 资源时已经用它。但插件作者写测试、或宿主框架要按同样规则读 classpath 文本时,可以直接复用,保证读法和 SDK 一致:

ClassLoader pluginCl = registry.getExtensionClassLoader("weather-pack");
String markdown = ExtensionResourceResolver.readTextStrict(
"skills/weather/SKILL.md", pluginCl);

读不到时 readText / readTextStrict 抛 ExtensionException("extension resource not found: ...");exists / existsStrict 返回 false。读取统一按 UTF-8 解码。

3. 速查表​

想做的事用什么
默认从 classpath 发现插件ExtensionRegistry.discover()
用指定 classloader 发现插件new ServiceLoaderExtensionLoader(classLoader) 传入 discover(loader)
非 ServiceLoader 发现实现 ExtensionLoader,传入 discover(loader)
手动构造少量 extension(测试)ExtensionRegistry.of(extension...)
拿到插件的 classloaderregistry.getExtensionClassLoader(extensionId)
严格读插件 jar 内文本资源ExtensionResourceResolver.readTextStrict(path, pluginCl)
检查插件资源是否存在ExtensionResourceResolver.existsStrict(path, pluginCl)
规范化资源路径ExtensionResourceResolver.normalizeResourcePath(path)

4. 下一步阅读​

  1. 插件包
  2. 插件作者实战指南
  3. 生命周期扩展
  4. 拦截器扩展