本文档描述本仓库内置的插件系统:如何编写、加载、热重载、以及可用事件。
适用范围:本仓库当前实现(
utils/eventbus.py+utils/plugin_manager.py)。
插件系统的核心目标:
- 零门槛加载:用户把插件
.py文件丢进./plugins/即可加载。 - 事件驱动:服务端核心逻辑通过
EventBus抛出事件,插件通过订阅事件实现扩展。 - 热重载:服务运行中修改/新增/删除插件文件,无需重启即可生效。
- 安全隔离:插件异常不会影响主服务器逻辑(异常会被捕获并记录日志)。
插件目录固定为:
pyphira-mp/
plugins/
auth_test.py
my_plugin.py
创建文件 plugins/my_plugin.py:
PLUGIN_INFO = {
"name": "my_plugin",
"version": "0.0.1",
}
def setup(ctx):
ctx.logger.info("my_plugin loaded")
def on_auth_success(connection=None, user_info=None, **_):
ctx.logger.info("auth ok: %s", getattr(user_info, "name", None))
ctx.on("auth.success", on_auth_success)
def teardown():
ctx.logger.info("my_plugin unloaded")
return teardown保存后,服务端会在下一次轮询(默认 1 秒)检测到变化并热重载。
插件以“文件”为单位被管理(plugins/*.py)。生命周期如下:
服务启动时:
PluginManager.start()扫描plugins/*.py- 对每个插件文件:
- 通过
importlib从路径加载为模块(module name:pyphira_plugin_<文件名stem>) - 若模块存在
setup(ctx)函数:调用它 - 若
setup返回一个可调用对象:作为teardown保存
- 通过
当插件文件的 mtime 变化(例如编辑保存)时:
unload(old):- 调用旧插件的
teardown()(若存在) - 从 EventBus 中移除该插件注册的所有事件订阅(自动清理)
- 从
sys.modules清掉旧 module
- 调用旧插件的
load(new):重新按启动加载流程加载
当插件文件被删除:
unload同上,清理事件订阅与模块缓存。
本仓库使用纯标准库实现热重载:
- 轮询方式:后台
asynciotask 周期性扫描plugins/*.py - 变更判断:比较
path.stat().st_mtime - 默认轮询间隔:
poll_interval=1.0秒
日志关键字:
Hot-reload watcher started...Detected change, reloading: xxx.pyUnloaded plugin xxxLoaded plugin xxx
注意:轮询并非文件系统事件监听(watchdog)。在极端高频变更场景下可能存在 0~poll_interval 秒延迟。
插件入口:
def setup(ctx):
...
return teardown # 可选插件可提供 PLUGIN_INFO 字典用于描述自身:
PLUGIN_INFO = {
"name": "my_plugin",
"version": "0.0.1",
"description": "...",
}当前管理器不会强制读取该信息(主要用于自描述/未来扩展)。
setup(ctx) 的 ctx 对象提供以下能力:
订阅事件。
callback可以是普通函数,也可以是async def- 回调的参数通过关键字传入(
**payload),因此推荐声明形如:
def handler(connection=None, user_info=None, **_):
...订阅一次性事件:第一次触发后自动取消订阅。
触发事件。一般用于插件间通信或插件内部事件分发。
插件专属 logger(名称形如 plugin.<plugin_name>),可用于记录日志:
ctx.logger.info("hello")
ctx.logger.exception("something failed")EventBus 的行为:
- sync callback 抛异常:捕获并记录日志,不影响主逻辑
- async callback:会被
asyncio.create_task()调度执行;异常同样捕获记录
这意味着:
- 插件可以写异步逻辑(例如发起 HTTP 请求),但要注意并发与资源释放
- 插件回调的异常不会阻断服务端包处理流程
触发时机:
- 玩家鉴权成功(
MainHandler.handleAuthenticate成功路径)
payload:
connection:utils.connection.Connectionuser_info: API 获取到的用户信息对象(具体字段取决于utils/phiraapi.py返回结构)handler: 当前连接对应的MainHandler
示例:在鉴权后向玩家发送一条系统聊天:
from rymc.phira.protocol.data.message import ChatMessage
from rymc.phira.protocol.packet.clientbound import ClientBoundMessagePacket
def setup(ctx):
def on_auth_success(connection=None, **_):
connection.send(ClientBoundMessagePacket(ChatMessage(-1, "hello from plugin")))
ctx.on("auth.success", on_auth_success)为了避免为每个 handleXXX 手动埋点,本仓库在 MainHandler 初始化时会自动“包装”所有 handle* 方法,统一抛出两类事件:
handler.<method>.beforehandler.<method>.after
其中 <method> 是方法名(例如:handleJoinRoom),那么事件名就是:
handler.handleJoinRoom.beforehandler.handleJoinRoom.after
payload(before/after 通用字段):
connection:utils.connection.Connectionhandler: 当前连接的MainHandlerpacket: 传入的包对象(通常是 ServerBoundXXXPacket;某些 handler 可能无参时为 None)args: 位置参数 tuple(除去 self)kwargs: 关键字参数 dict
after 事件额外包含:
result: handler 返回值(大多数 handler 返回 None)
示例:监听“玩家加入房间” handler 调用前后:
def setup(ctx):
def before_join(packet=None, **_):
ctx.logger.info("before join room, packet=%r", packet)
def after_join(packet=None, **_):
ctx.logger.info("after join room, packet=%r", packet)
ctx.on("handler.handleJoinRoom.before", before_join)
ctx.on("handler.handleJoinRoom.after", after_join)提醒:这些是“通用埋点事件”,并不代表业务成功/失败语义。业务判断仍需插件根据 packet / 服务器响应逻辑自行判断。
当服务端收到任意 packet 时,会先抛出:
packet.receivedpacket.<PacketClassName>.received(按具体类名区分,例如packet.ServerBoundAuthenticatePacket.received)
payload:
connectionhandlerpacket
示例:监听所有聊天包:
def setup(ctx):
def on_chat(packet=None, **_):
# packet 是 ServerBoundChatPacket
ctx.logger.info("chat packet: %r", packet)
ctx.on("packet.ServerBoundChatPacket.received", on_chat)- 回调签名用宽松参数:推荐
def cb(**payload)或def cb(connection=None, **_),避免服务端未来新增字段导致参数不匹配。 - 避免阻塞:不要在回调里做长时间 CPU/IO 阻塞(例如
time.sleep)。需要等待请用async def+await asyncio.sleep(...)。 - 资源清理放到 teardown:
- 创建的后台任务
- 打开的文件/Socket
- 连接池等
- 日志清晰:使用
ctx.logger,便于定位问题。 - 兼容热重载:不要把“必须唯一”的全局状态写死;热重载时模块会重新 import。
确认:
- 服务端是否使用同一个终端窗口运行
poll_interval默认 1 秒,改动后等待 1~2 秒- 插件文件是否位于
./plugins/且后缀为.py
不会。
- EventBus 对回调异常做了捕获并记录日志
- PluginManager 在 load/unload/reload 过程中也做了异常捕获
但插件可以做“坏事”(例如 os.remove),因此:
强烈建议只加载你信任的插件代码。
可以。插件运行在同一 Python 进程中,能 import utils.xxx 或 rymc.xxx。
插件是任意 Python 代码执行,具备与服务器进程等同权限:
- 可读写文件
- 可访问网络
- 可执行任意 Python 操作
因此不要加载来源不明的插件。