Files
MoonBridge/docs/plugins.md
layue13andClaude Opus 5.5 fc02bbdda6 feat: 插件之间的依赖、类共享与类型化服务
- 可选的 META-INF/moonbridge-plugin.yml(id、version、main、dependencies,可选依赖),
  没有描述文件的插件照旧加载
- 加载顺序按依赖图(PluginGraph):依赖先 onLoad/onEnable、后 onDisable;缺少依赖、
  依赖未启用、循环依赖、重复 id、描述文件不合法时给出准确原因并启动失败
- PluginClassLoader 在插件 API 与自身之后查找声明的依赖(含传递依赖);只有声明过的依赖可见
- PluginContext.services():每个类型一个提供方,只对提供方和依赖它的插件可见,随停用注销
- PluginContext.plugins():已加载的带描述文件插件(id、版本、依赖)
- plugins.enabled 可以用 id 作键;数据目录仍按实现类名,无需迁移
- LuckPerms 平台插件的描述文件 id 为 luckperms,版本在构建时写入
- 文档:docs/plugins.md「依赖其他插件」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:31:35 +08:00

193 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 插件开发
插件依赖 `proxy-plugin-api`,不依赖 `proxy-core` 或 Netty。入口实现 `dev.moonbridge.api.Plugin`,提供公开无参构造器,在 JAR 的 `META-INF/services/dev.moonbridge.api.Plugin` 中声明实现类,并由配置中的 `plugins.enabled` 显式启用。
## 生命周期和线程
`onLoad(PluginContext)` 获得 `players()`、`servers()`、`commands()`、`permissions()`、`events()`、`messaging()`、专属 `dataDirectory()`、SLF4J `logger()`、不可变 `settings()` 和本代理进程的 `proxyEpoch()`(每次启动重新生成,与后端会话绑定证明一致)。宿主在所有插件加载后调用 `onEnable()`;插件全部启用成功后、监听器启动前冻结事件订阅并确定唯一权限提供者。事件订阅只能在 `onLoad` 或 `onEnable` 注册,运行期间可以随时撤销。初始化失败会终止启动并回收已创建的注册。
事件通过 `PluginContext.events()` 提供的 `dev.moonbridge.api.event.Events` 订阅。所有事件都使用同一 sealed `Event<R>`、`EventListener<E, R>` 和 `Events.subscribe` 方法;监听器返回 `CompletionStage<R>`。无结果事件使用 `Event<Void>`,准入事件返回 `AccessDecision`。数据库客户端、缓存和外部任务的生命周期由插件管理;返回 `CompletionStage` 表达异步结果,避免在回调里 `join()` 等待另一个任务。各连接的准入检查可以并发,插件共享数据必须支持并发访问。
关闭或插件失败时,宿主移除该插件的事件订阅、后端与命令注册。关闭时,宿主先终止未决访问请求并停止接收新回调,再逆序调用 `onDisable()`。所有关闭钩子共享 10 秒期限;插件忽略中断时仍可能继续运行,因此插件必须自行停止数据库/调度器等资源。随后关闭类加载器。当前没有插件热卸载或热加载入口。
## 依赖其他插件
插件之间默认互相隔离:每个 JAR 有自己的类加载器,只能看到插件 API 和自己的类。要让一个插件使用另一个插件的类和服务,两边都在 JAR 中放一个 `META-INF/moonbridge-plugin.yml`:
```yaml
id: islands # 小写字母、数字和连字符,最多 64 个字符,全局唯一
version: 1.2.0
main: com.example.islands.IslandsPlugin
dependencies:
- id: economy # 必需:未安装或未在 plugins.enabled 中启用时启动失败
- id: luckperms
optional: true # 可选:存在时先加载,不存在时忽略
```
- 有描述文件时以 `main` 为入口,不再读取该 JAR 的 `META-INF/services`;没有描述文件的插件照旧加载,但既不能依赖别人,也不能被依赖。
- `plugins.enabled` 可以用 id(`islands:`)或实现类全名作键,同一个插件不能两种写法同时出现。数据目录仍按实现类全名,已有数据不需要迁移。
- 加载顺序:依赖先于依赖它的插件执行 `onLoad` 和 `onEnable`,`onDisable` 反过来;互不依赖的插件保持 JAR 文件名顺序。
- 启动时检查并给出准确原因后失败:`Plugin islands requires economy, which is not installed`、`… which is installed but not enabled in plugins.enabled`、`Plugin dependency cycle: a -> b -> a`、两个 JAR 使用同一 id、描述文件字段不合法(带 JAR 名和字段)。
- 类可见性:先查插件 API、JDK 和日志等共享类,再查插件自己的 JAR 和 `addLibrary` 加入的库,最后查声明的依赖(以及依赖的依赖)。只有声明过的依赖可见,自带的库不会被其他插件的同名类替换。资源文件不共享。
依赖方通过类型化服务取得对方的实现,而不是直接持有对方的 `Plugin` 实例。提供方发布接口,在 `onLoad` 中注册实现:
```java
// economy 插件
public interface EconomyApi { long balance(UUID player); }
@Override public void onLoad(PluginContext context) {
context.services().register(EconomyApi.class, new EconomyService());
}
// islands 插件:dependencies 中声明 economy
EconomyApi economy = context.services().require(EconomyApi.class);
context.plugins().find("luckperms").ifPresent(info -> context.logger().info("LuckPerms {}", info.version()));
```
- 每个类型只能有一个提供方;第二个插件注册同一类型时启动失败。
- `find` / `require` 只能看到自己和依赖链上的插件注册的服务;没有声明依赖的插件即使能加载到同一类型(例如 JDK 接口)也查不到。
- 服务随提供方停用自动注销,也可以用 `register` 返回的 `ServiceRegistration` 提前注销。依赖方先于依赖停用,所以不会在运行中看到依赖的服务消失。
- `plugins()` 列出所有带描述文件的已加载插件(id、版本、声明的依赖),`self()` 是自己的描述。
依赖方在构建时只需要对方的接口,例如 Gradle 中 `compileOnly("com.example:economy-api:1.0.0")`;不要把对方的类打进自己的 JAR,否则自带的副本会优先于依赖提供的类,两边的类型就不再是同一个。不需要共享类的松散协作继续用下文的 Channel 消息。原生 LuckPerms 平台插件的 id 是 `luckperms`。
## 玩家与后端
- `Players.online()` 和 `find(PlayerIdentity)` 返回不可变 `PlayerView`。`PlayerIdentity` 同时包含玩家 UUID 和本次连接代次;异步转服要保留这个完整身份,不能用旧连接去操作重连后的玩家。
- `Servers.all()` / `find(name)` 返回 `ServerView` 快照:后端名称、地址、标签、插件元数据。没有负载、健康或容量推断。
- `Servers.register(ServerDefinition)` 返回所有权句柄,支持更新与注销。同名条目由原注册者拥有,不能覆盖其他插件或静态配置。同名重新注册后旧句柄失效。
- `Players.transfer(identity, backendName)` 异步返回 `TransferResult`;`NETWORK_READY` 表示协议交接完成,不表示玩法数据加载完成。通过插件上下文调用时,完成通知也被转移到 I/O 线程之外。
插件先自行完成岛屿加载或实例唤醒,再调用转服。动态后端可通过[后端控制通道](backend-channel-design.md)注册。
### 后端控制消息
`PluginContext.messaging().channel("命名空间:通道")` 提供与后端相同的消息 API,不依赖玩家在线。`subscribe` 注册通知订阅者,`onRequest` 注册唯一请求处理器;每个插件持有独立 scope,停用时自动撤销。处理器收到不可变 `Message`,其中包含消息 UUID、认证来源、目标和回复关联;请求处理器返回 `CompletionStage<byte[]>`,框架创建完整的回复消息。
`send(Endpoint.backend(name), payload)` 返回含消息 ID 的投递回执,`ACCEPTED` 表示目标消息服务接受处理任务;`request(...)` 返回完整回复消息,默认期限五秒;`publish` 返回各候选节点的投递结果。负载在提交时复制,单条最多 64 KiB;回调在有界工作池运行,不应同步等待数据库或网络任务。后端插件通过唯一的 Bukkit 宿主服务获取相同 API,不自行创建连接。旧 `backendChannels()` 已移除。完整配置、Java 8 宿主安装与故障语义见[后端 Channel 消息服务](backend-channel-design.md)。
### 消息与主动断开
`Players.sendMessage(identity, text)` 和 `disconnect(identity, reason)` 均绑定完整的 `PlayerIdentity`,可从任意线程调用;通过插件上下文获取的 stage 在 I/O 线程外完成。参数错误立即报告,插件停用后拒绝继续提交。
- 消息支持 Adventure Component;String 入口为纯文本,最多 1024 个 Unicode 码点。返回 `MessageResult.SENT` 表示网络写入完成;连接消失或正在关闭返回 `NOT_CONNECTED`;登录、初次 Forge 握手未就绪或转服交接期间返回 `NOT_READY`;不可写或该连接已有 64 条未完成消息返回 `BACKPRESSURED`。网络写失败以异常完成。
- 主动断开接受非空白的 Component 原因;String 入口最多 1024 个 Unicode 码点。核心按实际 LOGIN/PLAY 阶段发包,最长等待 5 秒后清理连接、未决转服和候选后端。返回 `DisconnectResult.DISCONNECTED` 表示连接清理完成,原因文本仅尽力交付;身份已失效返回 `NOT_CONNECTED`。重复断开合并处理,取消调用方等待不撤销已提交的断开。
- `disconnect` 也支持准入或初始选服回调中的已识别玩家,此时玩家还未出现在 `online()` 中。
```java
// Ban 数据和权限检查由插件先完成;保存完整 identity,防止误操作重连者。
context.players().disconnect(player.identity(), "你已被封禁,请联系管理员。");
context.players().sendMessage(player.identity(), "目的地已准备好。").thenAccept(result -> {
if (result == MessageResult.SENT) {
context.logger().debug("消息已写入玩家连接");
}
});
```
完整状态与验收设计见[玩家操作](player-operations.md)。
## 初始选服
初始选服是登录策略:访问检查通过后,代理必须先确定一个或多个有序目标,再建立后端连接。后端注册只表示目标当前可被解析,不代表它适合作为新玩家入口;代理不根据注册顺序、标签或推测出的健康状态选服。
没有插件接管时,`initialRouting.servers` 是明确配置的候选名称列表,按顺序尝试,最多 16 个且不得重名或为空白。默认空列表是 fail-closed:没有目标时拒绝本次登录,并向玩家说明没有可用的初始服务器。只由插件提供选服策略的配置可以保留空列表。候选名称在每次尝试时从当前服务器目录重新解析;不存在、已注销或连接期间被替换的条目会跳过,代理不会将旧地址用于新注册者。配置示例:
```yaml
initialRouting:
servers: [lobby-a, lobby-b]
timeoutSeconds: 15
```
`Plugin.initialPlacementHandler()` 返回 `Optional<InitialPlacementHandler>`。整个代理最多有一个处理器;它收到 `PlayerView` 和当前 `List<ServerView>` 快照,并异步返回 `PlacementDecision.select(name)`、按优先级依次尝试的 `select(List<String>)`,或 `reject(reason)`。只要处理器存在,它的选择完全接管初始路由,配置列表不会作为隐式备用目标;显式拒绝、异常、空结果或期限届满都结束本次登录。选择后目录会重新解析,因此插件异步等待实例启动时可以在其注册后返回名称。代理不会轮询目录、唤醒实例或偷偷改投默认大厅;实例编排由该插件负责。
`initialRouting.timeoutSeconds` 默认 15 秒,范围 1–120 秒。一个总期限覆盖插件的异步选服和所有候选目标的 DNS/TCP 建连。只有 DNS/TCP 连接失败、且 Minecraft 握手或登录字节尚未发送时,才可尝试下一个候选;TCP 成功后会再次核对注册句柄仍有效,再发送协议数据。开始后端握手/登录后发生拒绝、断开或超时都以本次登录失败结束,不重放 Minecraft 或 Forge 登录流量。完成后端 TCP 建连后的登录阶段仍使用既有的 15 秒期限。
断线会取消未决选服,尽力取消插件返回的 future,并移除尚未运行的回调。插件应及时完成其阶段;不得通过阻塞调用等待数据库、实例启动或网络操作。
## 玩家命令
在 `onLoad` 或 `onEnable` 注册根命令:
```java
context.commands().register("where", command -> {
command.reply("当前服务器:" + command.player().currentServer().orElse("未连接"));
});
```
名称不区分大小写且全局唯一;别名可分别注册。返回的 `CommandRegistration.close()` 可撤销该命令,插件停用时自动撤销。`CommandInvocation` 包含 `player()`、实际调用的 `name()`、未拆词的 `arguments()` 和纯文本 `reply(String)`;异步完成后可继续使用 invocation 回复原连接,已断线的连接不会收到回复。
只消费已注册的 `/命令`,普通聊天和未知命令原样交给后端。通过 `register(name, permission, handler)` 声明权限节点后,核心统一检查执行与补全;拒绝的已注册命令仍被代理消费。原有不带权限节点的注册表示公开玩家命令,复杂子命令可通过 `context.permissions()` 查询各自权限。控制台命令需用 `CommandRegistrationOptions` 显式启用,并从 `invocation.source()` 读取来源。详见[玩家权限与 LuckPerms](permissions.md)。命令回调使用独立有界工作池,异常返回通用失败信息,队列满时告知玩家稍后重试。
每位玩家可突发调用 10 次代理命令,之后每 200 毫秒恢复一次额度;超额命令仍由代理消费,限速提示每两秒至多一次。`reply` 复用玩家消息实现:最多 1024 个 Unicode 码点,与主动消息共享每连接 64 条未完成写入上限;不可写、已离线或协议过渡期间丢弃回复。命令回调仍可发起转服。
## 统一类型化事件
`PluginContext.events()` 返回 `dev.moonbridge.api.event.Events`。事件统一使用泛型结果和异步监听器:
```java
sealed interface Event<R> {}
interface EventListener<E extends Event<R>, R> {
CompletionStage<R> onEvent(E event);
}
<R, E extends Event<R>> EventSubscription subscribe(
Class<E> type, EventListener<E, R> listener);
```
订阅使用具体事件类,不支持基类全量订阅;准入决策和通知也没有各自的独立监听器或结果 API。订阅只可在插件 `onLoad` 或 `onEnable` 注册,宿主在启动监听器前冻结注册。`EventSubscription.close()` 可随时幂等调用;插件失败或停用时自动移除。撤销会跳过后续尚未被派发线程选中执行的回调;已经选中的回调及其异步 stage 可以继续,关闭订阅不会等待它们完成。
### 玩家准入
`ConnectionAdmissionEvent(InetSocketAddress remoteAddress)` 在 TCP 建连后、Minecraft 协议解析前触发,适用于来源地址策略,包括状态查询连接。地址是代理接受连接时看到的实际 TCP socket peer;若代理前有网络中继,地址就是该中继。经 [WebSocket 监听](websocket.md)接入的连接在 HTTP 升级完成后才触发该事件,地址在对端属于 `websocket.trustedProxies` 时取自配置的真实 IP 头,否则仍是 socket peer;`PlayerAdmissionEvent`、Mojang 会话验证和 Bungee 转发使用同一地址。拒绝或准入检查失败时直接关闭 TCP,因此客户端尚无 Login Disconnect 可显示。`PlayerAdmissionEvent(PlayerView player, InetSocketAddress remoteAddress, boolean authenticated)` 在身份建立后、初始选服和后端登录前触发。在线代理模式下,核心仍先执行 `ONLINE_BUNGEE` 会话验证;事件在验证成功后派发,`authenticated` 为 `true`。离线模式在生成客户端名字和离线 UUID 后派发,`authenticated` 为 `false`。因此该事件可供 Ban、白名单等准入策略使用,不能替代认证提供者或改变核心认证过程。拒绝会发送 Login Disconnect。
多个准入监听器按注册顺序运行;允许策略运行全部监听器,首个拒绝即停止并拒绝玩家。空 stage、空结果、异常、超时或过载均失败关闭;内部错误不会把详细异常发送给客户端。准入请求使用有界队列,容量 128,最多 1024 个未决请求。`plugins.eventTimeoutSeconds` 默认 5 秒,范围 1–30 秒;每个准入事件的所有监听器共享同一个事件期限。准入事件只在接入和登录阶段触发,不进入 PLAY 包热路径。
```java
context.events().subscribe(PlayerAdmissionEvent.class, event -> {
String reason = playerBans.get(event.player().identity().playerId());
return CompletableFuture.completedFuture(reason == null
? AccessDecision.allow() : AccessDecision.deny(reason));
});
```
插件自行维护封禁数据与权限策略;核心不保存封禁名单,也不定义封禁期限或申诉流程。异步数据库查询可以直接返回 stage;查询异常或超时会使该次准入失败。更新封禁名单后,插件可调用 `Players.disconnect` 踢出对应的在线连接。玩家准入策略不会在转服时重复执行。
### 会话通知
`ServerConnectedEvent(player, previousServer)` 在在线玩家首次发布(后端登录校验后,此时 `previousServer` 为空)以及转服提交完成时触发(此时携带原后端名称)。它表示代理会话已发布/切换,不承诺 Forge 或玩法世界已经就绪。`PlayerDisconnectedEvent(player)` 对每个曾经发布的玩家,在会话关闭时恰好触发一次;被拒绝的登录从未发布,不产生此事件。
通知使用同一异步监听器 API 和 `CompletionStage<Void>` 结果契约。通知按 FIFO 派发,每次最多一个活动事件和 128 个排队事件,等待当前事件的监听器链完成后再派发下一个。监听器异常相互隔离;`Void` 事件以 `null` 完成。事件期限从进入派发队列时开始计算;超过 `plugins.eventTimeoutSeconds` 会结束该事件并继续队列。代理会话不等待通知,过载时尽力丢弃并在 I/O 线程外聚合记录;通知不是可靠审计或计费通道。没有订阅者时尽量跳过事件分配和派发。插件不能自行发布核心事件。
```java
context.events().subscribe(ServerConnectedEvent.class, event -> {
String previous = event.previousServer().orElse("首次连接");
context.logger().info("{} 已连接到 {}(来自 {})",
event.player().username(), event.player().currentServer().orElse("未知"), previous);
return CompletableFuture.completedFuture(null);
});
context.events().subscribe(PlayerDisconnectedEvent.class, event ->
{
context.logger().info("{} 已断开", event.player().username());
return CompletableFuture.completedFuture(null);
});
```
需要异步完成时,可订阅同一通知事件并返回 stage:
```java
context.events().subscribe(PlayerDisconnectedEvent.class, event ->
auditQueue.enqueue(event.player().identity()).thenApply(ignored -> null));
```
`InitialPlacementHandler` 仍是单提供者选服策略。命令、服务器目录和转服 API 保持原有职责,不通过事件替代。
统一事件模型让准入决策和会话通知共享同一类型、监听器、订阅表与派发实现,同时保留各类事件所需的运行策略。准入阶段执行有序且失败关闭的结果聚合;生命周期通知异步 FIFO 尽力交付并与会话处理隔离。没有 PLAY 包事件。目前没有事件 API 的专门基准,不能据此宣称性能收益或无开销。
源码入口:[`PluginContext`](../proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginContext.java)、[`Events`](../proxy/plugin-api/src/main/java/dev/moonbridge/api/event/Events.java)、[`AccessDecision`](../proxy/plugin-api/src/main/java/dev/moonbridge/api/AccessDecision.java)。
富文本构造、MiniMessage 和异步命令补全示例见[消息与命令补全](messages-and-completion.md)。