Fabric 服务端插件开发简述与聊天事件监听转发
原文链接:Fabric 服务端插件开发简述与聊天事件监听转发 < Ping通途说
0. 引言
以前写过Spigot的插件,非常简单,仅需调用官方封装好的Event类即可。但Fabric这边在开发时由于官方文档和现有互联网资料来看,可能会具有一定的误导性,使得开发者使用更加底层的Mixin注入开发。但后来才发现官方已经封装了这些实现方法,为了避免后续开发者踩坑,也能让开发者明白Fabric的技术实现,于是才写本文。
本文以构建一个监听游戏内玩家聊天事件、玩家进入服务器事件、玩家离开服务器事件,并遵循OnebotV11协议转发到对应服务器的Fabric服务端插件。
环境搭建与项目创建本文不再阐述,有关资料可参考:
- 设置开发环境 | Fabric 文档 - Fabric 的环境搭建
- Template mod generator | Fabric - Fabric 模板模组生成器
- Minecraft 模组编写基础 [Fabric Wiki] - Fabric的基本介绍
- 教程:更新Java - 中文 Minecraft Wiki - 各版本Minecraft最低JAVA版本要求
1. 直接调用Event方法
1.1 获取Event API方法
初学者可能会被官方文档迷惑,在监听事件 [Fabric Wiki]中,只看到了几个官方封装的方法,自己想要的类并不在以下列表中:
点击链接后又发现跳转到了项目源码中,如果对Java不熟悉的开发者可能就不明白为何会跳转到源码,然后我应该看什么。虽然 事件 | Fabric 文档 该文档中简单描述了事件监听的基本过程,但依然没有列出想要事件列表。而官方描述的 net.fabricmc.fabric.api.event
,在进入源代码后发现也只不过是所有Event的抽象方法。
那么真正的Event API到底在哪,官方文档没有像Bukkit/Spigot那样详细的列出来,我们只能借助Deepseek来查询。通过查询后,本文中我们需要监听的聊天事件,就在net.fabricmc.fabric.api.message.v1.ServerMessageEvents.CHAT_MESSAGE
中。
进入 ServerMessageEvents查看,可以看到除玩家聊天事件外,还有游戏消息事件和执行指令事件。
再通过AI我们可以找到玩家进入服务器(ServerPlayConnectionEvents.JOIN
)和玩家离开服务器(ServerPlayConnectionEvents.DISCONNECT
)的事件。而该包存在于net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents
类中,因此可以猜测,官方似乎放弃了对net.fabricmc.fabric.api.event
的维护,将所有Event分散到不同的模块中。
对于API的查询,官方可能已经放出了相关文档,但普通的开发者无法通过正常的方式通过互联网搜索或从官网中进入。在此官方改善该问题前只能通过AI辅助查找方法实现。
1.2 插件开发
找到API那开发起来就非常简单了,直接在插件初始化中注册事件:
public class EventForwardobv11 implements ModInitializer {public static final String MOD_ID = "event-forward-obv11";public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);public static ModConfig CONFIG;@Overridepublic void onInitialize() {CONFIG = ConfigManager.loadConfig();ServerMessageEvents.CHAT_MESSAGE.register((message, sender, params) -> {HttpUtil.sendGet("[" + sender.getName().getString() + "] " + message.getContent().getString());});ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {ServerPlayerEntity player = handler.player;HttpUtil.sendGet(player.getName().getString() + "加入服务器 (" + (server.getCurrentPlayerCount() + 1) + "/" + server.getMaxPlayerCount() + ")");});ServerPlayConnectionEvents.DISCONNECT.register((handler, sender) -> {ServerPlayerEntity player = handler.player;HttpUtil.sendGet(player.getName().getString() + "退出服务器");});}
}
注册完成后,我们需要将事件通过OnebotV11协议转发至群中,查看协议API,发送群消息的接口是 /send_group_msg
。对于单个小型Minecraft 服务器,请求应该不会大到哪里去,因此我们选择HTTP GET的方式向服务器发送请求,这里需要封装一个请求方法:
// GET 方法实现
class HttpUtil {public static void sendGet(String message) {ModConfig config = EventForwardobv11.CONFIG;String url = "http://"+config.obServer + ":" + config.obPort + "/send_group_msg?access_token=" + config.obToken + "&group_id=" + config.forwardGroup + "&message=" + URLEncoder.encode(message, StandardCharsets.UTF_8);try (CloseableHttpClient client = HttpClients.createDefault()) {HttpGet get = new HttpGet(url);HttpResponse response = client.execute(get);switch (response.getStatusLine().getStatusCode()) {case 401:EventForwardobv11.LOGGER.error("鉴权失败,当前请求需要Token");break;case 403:EventForwardobv11.LOGGER.error("鉴权失败,Token无效");break;case 404:EventForwardobv11.LOGGER.error("请求失败,无效接口:{}",url);}// 主要是根据状态码判断错误,正常返回结果直接忽略} catch (Exception e) {EventForwardobv11.LOGGER.error("发送消息失败:{}", e.getMessage());}}
}
关于代码中的异常处理,根据 HTTP | OneBot 11 标准只有在请求失败时才会返回200以外的状态码。服务器返回200对插件没有任何作用,因此只需关注其他状态码并处理异常即可。
服务器地址与端口是不确定的,因此需要用户来自己配置,我们来自己实现一个配置类。本插件是服务端使用的,因此无需像客户端那样展示GUI配置界面,因此可以利用GSON来生成读取配置:
class ModConfig { // 遵循Onebotv11协议发送事件public String obServer = "127.0.0.1"; // Onebotv11服务器地址public int obPort = 8080; // http端口public String obToken = ""; // 鉴权tokenpublic String forwardGroup = ""; // 要转发的群聊
}class ConfigManager {private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();private static final Path CONFIG_PATH = Paths.get("config", "event-forward-obv11.json");public static ModConfig loadConfig() {try {File configFile = CONFIG_PATH.toFile();if (!configFile.exists()) {return createDefaultConfig(); // 如果文件不存在,创建默认配置}try (FileReader reader = new FileReader(configFile)) {return GSON.fromJson(reader, ModConfig.class); // 读取配置}} catch (Exception e) {System.err.println();EventForwardobv11.LOGGER.error("无法加载配置文件,使用默认配置:{}", e.getMessage());return new ModConfig();}}public static ModConfig createDefaultConfig() {try {File configFile = CONFIG_PATH.toFile();configFile.getParentFile().mkdirs(); // 确保目录存在ModConfig defaultConfig = new ModConfig();try (FileWriter writer = new FileWriter(configFile)) {GSON.toJson(defaultConfig, writer); // 写入默认配置}return defaultConfig;} catch (Exception e) {EventForwardobv11.LOGGER.error("无法创建默认配置文件:{}", e.getMessage());return new ModConfig();}}
现在,插件全部功能已经实现。将构建后的插件装载至服务端的mod文件夹,当玩家进出服务器和聊天时就会转发事件:
2. Mixin注入简述与示例
Mixin是我刚接触Fabric就误入歧途了解的东西。Mixin的主要用途是修改基本游戏中已存在的代码,可以是通过注入自定义逻辑、移除机制或者修改值。
我是完全参考该项目进行学习与开发的: GitHub - IotaBread/player-events: Fabric mod. Sends a configurable message/command when a player joins the server.
这个项目实现了对玩家事件的监听,例如玩家进服、玩家出服、玩家死亡等事件。正好缺少监听聊天事件,我们可以通过学习项目中玩家进出服的实现,来注册一个我们自己实现的聊天事件监听器!
先来到插件入口,可以看到自定义回调在这里注册了,回调参数通过读取Config文件来获取并传递到函数中。
2.1 玩家进入服务器事件监听器
以玩家加入服务器事件监听器为例子,找到PlayerJoinCallback(api/src/main/java/me/bymartrixx/playerevents/api/event/PlayerJoinCallback.java
)。这里是自定义事件的处理模块,
随后继续查阅文件,可以发现在PlayerManagerMixin(api/src/main/java/me/bymartrixx/playerevents/api/mixin/PlayerManagerMixin.java
)中引用了以上方法。可以看到Mixin在代码中出现了,其中使用了@Injects
向Minecraft源代码中注入了捕获玩家连接的事件。
关于注入(Injects):允许你在已存在的模组中的特定位置放置自定义的代码。
其中,at
参数代表当前代码段将会在Minecraft函数中何时执行,method
参数则代表代码段会在Minecraft哪个函数中执行。可以看到代码中使用了 TAIL
,即代表在Minecraft中onPlayerConnect函数在即将返回值时运行注入的代码。
另外的if判断如果玩家离开服务器的次数少于1次,那就可以判定该玩家是第一次进入服务器,随后再将事件传递到专门的监听器中。
再讲讲代码中的 CallbackInfo
,CallbackInfo
(或 CallbackInfoReturnable
)用于控制 原方法的执行流程。它是 Mixin 注入点时的一个参数,可以:
- 取消原方法执行(
ci.cancel()
)。 - 提前返回(
ci.setReturnValue(...)
,仅限CallbackInfoReturnable
)。 - 获取方法执行状态(如是否已被取消)
什么时候用 CallbackInfo
和 CallbackInfoReturnable<T>
:
- 用
CallbackInfo
:当目标方法是void
或无返回值修改需求时。 - 用
CallbackInfoReturnable<T>
:当需要修改返回值时(如public boolean someMethod()
)。
2.2 玩家离开服务器事件监听器
再来看看玩家离开服务器事件监听器的代码:
可以看到,除调用方法名称有区别外,与玩家进入服务器的代码基本一致。
同理,再前往调用了PlayerLeaveCallback
函数的Mixin中查看:
哦豁,跟玩家进入服务器注入器代码有很大区别,多了很多东西。第一个注入器监听的是指令被执行后的事件,第二个注入器监听的则是玩家离开服务器事件。
首先是 @Shadow
,@Shadow
用于 “声明” 目标类(被 Mixin 的类)的 字段或方法,但并不实际修改它们。它只是让 Mixin 知道:“这个字段/方法在目标类中存在,我可以访问它”。不会生成任何实际代码,仅仅是一个引用标记。
在实际代码中,ServerPlayNetworkHandler
类(原版 Minecraft)有一个 player
字段,存储当前连接的玩家。@Shadow public ServerPlayerEntity player;
的作用是,告诉 Mixin:“ServerPlayNetworkHandler
里有一个 player
字段,我要用它”。这样你就可以在 Mixin 中直接通过 this.player
访问原版字段。
两个函数的at
都使用了INVOKE
,这代表当前方法将会在Minecraft对应函数被调用前执行。那target
参数又是什么呢,为何他的值又是一长串?method参数为什么也是一长串?
首先@At
的 target
是指定注入点的目标方法,它指向的是 目标类中的某个方法或字段(通常是原版 Minecraft 的方法)。指定的是 “在哪个方法调用或字段访问时触发注入”。直接看target地址:
Lnet/minecraft/server/network/ServerPlayNetworkHandler;checkForSpam()V
- 当
ServerPlayNetworkHandler.checkForSpam()
被调用时,触发注入。 - 格式:
L包路径/类名;方法名(参数类型)返回值类型
(JVM 字节码描述符)。
这段的知识面涉及JVM的标准设计第 4 章.类 File Format,字段描述符可以参考:
字段描述符 | 原名 | 描述 |
---|---|---|
B | byte | 带符号的字节 |
C | char | Basic Multilingual Plane 中的 Unicode 字符代码点,使用 UTF-16 编码 |
D | double | 双精度浮点值 |
F | float | 单精度浮点值 |
I | int | 正数 |
J | long | 长整数 |
L类名称; | reference | 类名称的实例 |
S | short | 带符号的短整型 |
Z | boolean | true 或 false |
[ | reference | 单数组维度 |
而4.3.3的方法描述符基本与字段描述符一致,只是多了一个 V
用于表示 void
。
以下是Fabric文档中的官方描述:
再来看看 method
,它指定的是要注入的目标方法,用于告诉 Mixin “你要修改的是哪个方法”,指向的是 被注入的目标方法(即你要修改的方法)。
method = "method_44356(Lnet/minecraft/class_7472;Ljava/util/Optional;)V"
因此这里的 method 表示:
- 要修改的方法是 method_44356(可能是混淆后的名称)。
- 它的参数是 ChatCommandC2SPacket(class_7472)和 Optional<MessageSignatureList>。
- 返回值是 void(V)。
那么如何找到以上我们需要的地址?这就需要反编译Minecraft代码了:配置模组开发环境 [Fabric Wiki]
Fabric已经配置好了Gradle任务,直接运行就可以了,非常的方便。
一般情况下仅需指定target类地址就可以了,如果在实际运行中出现无法找到对应方法的问题,就需要再去指定method的地址。比如例子中的checkForSpam,通过搜索源代码(客户端版本:1.21.5)发现,该方法存在于:
c ate net/minecraft/class_3244 net/minecraft/server/network/ServerPlayNetworkHandler
...
m ()V r method_43669 checkForSpam
因此如果想要让执行指令后监听器生效,那就需要将代码修改为:
@Inject(at=@At(value="INVOKE", target="Lnet/minecraft/server/network/ServerPlayNetworkHandler;checkForSpam()V"),method = "method_43669(Lnet/minecraft/class_3244;Ljava/util/Optional;)V", require = 0)
所以,指定地址的弊端就出现了,客户端一更新,插件就需要重新反编译Minecraft代码来获取对应方法地址。
2.3 一起来实现聊天事件监听!
根据以上我们了解的知识,可以模仿来创作一个新的自定义Mixin!
@Mixin(ServerPlayNetworkHandler.class)
public abstract class ServerPlayNetworkHandlerMixin {@Inject(method = "handleMessage", at = @At("HEAD"))private void onChatMessage(Text message, CallbackInfo ci) {ServerPlayerEntity player = ((ServerPlayNetworkHandler)(Object)this).player;MinecraftServer server = player.getServer();// 触发你的回调事件ActionResult result = PlayerChatCallback.EVENT.invoker().playerChat(player, server);}
}
以及自定义回调事件:
public interface PlayerChatCallback {Event<PlayerChatCallback> EVENT = EventFactory.createArrayBacked(PlayerChatCallback.class,(listeners)->(player, server)->{for (PlayerChatCallback listener: listeners){listener.playerChat(player,server);}});void playerChat(ServerPlayerEntity player, MinecraftServer server);
}
最后在初始化类中注册这个监听器:
public class MyMod implements ModInitializer {@Overridepublic void onInitialize() {// 注册聊天事件处理器PlayerChatHandler.register();}
}
可不要忘记 mixin 配置,让插件知道你创建了新的Mixin
在 src/main/resources/mixins.modid.json
中添加你的 mixin,package
为存放Mixin的目录。
{"package": "com.example.mymod.api.mixin","mixins": ["ServerPlayNetworkHandlerMixin"]
}
3. Fabric 与 Bukkit区别
1. 架构设计差异
特性 | Bukkit/Spigot/Paper | Fabric |
---|---|---|
目标定位 | 为服务器插件提供高层 API,屏蔽底层实现 | 提供底层 Mod 支持,允许直接修改 Minecraft 代码 |
事件系统 | 提供完整的 事件总线,插件直接注册监听器 | 部分事件需通过 Mixin 注入 或 Fabric API 提供的事件 |
底层访问权限 | 限制较多,只能通过 Bukkit API 操作 | 允许直接修改游戏代码(通过 Mixin) |
兼容性维护 | 由 Bukkit/Spigot 团队维护 API 兼容性 | 依赖社区和 Fabric 团队,部分 API 可能变动 |
2. 底层实现对比
Bukkit 的实现方式
- Bukkit 在服务端启动时,通过反射/CraftBukkit 修改 Minecraft 代码,插入事件触发点。
- 插件开发者无需关心注入逻辑,直接使用封装好的
Event
类。
// 伪代码:Bukkit 在底层注入事件调用
public void handleChatPacket(Packet packet) {Player player = getPlayerFromPacket(packet);PlayerChatEvent event = new PlayerChatEvent(player, packet.getMessage());Bukkit.getPluginManager().callEvent(event); // 触发事件if (!event.isCancelled()) {originalHandlePacket(packet); // 继续原逻辑}
}
Fabric 的实现方式
- Fabric 本身不修改原版代码,而是通过 Mixin 在编译时动态注入。
- 事件系统需要手动实现(或依赖 Fabric API 提供的事件)。
- 开发者可以完全控制事件行为,但需自行维护兼容性。
// 伪代码:Fabric 通过 Mixin 注入事件
@Inject(method = "handleChat", at = @At("HEAD"), cancellable = true)
private void onChat(MessagePacket packet, CallbackInfo ci) {PlayerChatEvent event = new PlayerChatEvent(player, packet.getMessage());EventManager.fireEvent(event); // 自定义事件总线if (event.isCancelled()) {ci.cancel(); // 取消原逻辑}
}