内核设计哲学
本仓库所有架构决策都遵循下面这组信条。改
framework/、加官方插件、写用户插件之前,先读它。 它不是"建议",而是内核存在的理由:内核只负责"运转",其余都是"挂在运转各环节上的扩展"。
为什么需要一份设计哲学
ZCBOT 不是一个"什么都往里塞"的框架。它的全部价值在于:用极小的、稳定的内核,托住无限可变的扩展。 没有这层信条,代码会慢慢滑向"在内核里加特例、为某个接入端写 if、把业务塞进 framework/"——届时内核不再稳定,扩展也不再自由。 这份文档是给"该把代码放哪、该不该改内核"这个问题的最终裁决依据。
十条信条
1. 最小必要内核
framework/ 只做四件事:加载扩展、路由事件、提供公共服务(数据库 / 权限 / 服务注册 / 事件总线 / 运行时上下文)、维护扩展点契约。 不实现任何具体业务,也不含任何 OneBot 代码。
- 落点:
framework/core/base.py的Framework.__init__只装配loader / event_bus / router / db / hooks / services;OneBot 实现在core_plugins/onebot_adapter/。 - 判断标准:一段代码若"知道某个具体平台 / 某个业务"的细节,它就不该在内核里。
2. 扩展点即契约
HookRegistry(framework/hooks.py)是内核真正区别于普通框架的地方。 内核在 lifecycle / http / event / command / message / action / router 各环节预留插槽,ctx.hook(point, handler) 即可往里插逻辑;同名重复注册自动去重,handler 可为 def 或 async def。 这是"允许几乎各个地方插入"的实现机制,也是内核对扩展开放的唯一正式通道。
- 扩展点清单见 扩展点(Hook 系统)。
3. 三层叠加
能力分三层,越往下越不可关:
- 内核(
framework/):不可关,提供运转本身。 - 官方扩展(
core_plugins/):随项目的基础能力,开关集中在core_plugins.yaml。 - 用户扩展(
plugins/):你的业务逻辑,每个一个目录。
换形态(机器人 ↔ 纯定时 ↔ HTTP 宿主)只动 core_plugins.yaml,骨架(权限、后台、持久化、会话)原样保留。
4. 协议无关、事件归一
内核不区分事件来自哪个接入端(OneBot / Telegram / Discord / HTTP 注入 / WS 客户端 / 双进程 IPC),也不区分逻辑来自哪个扩展。 所有源先归一化为 Event,再走同一条管线。接入端差异被 ProtocolAdapter 契约(framework/messaging/protocol.py)吸收,内核不写死任何协议字段。
- 落点:连接状态由各接入端的
get_connection_info() / get_connected_bots()自描述;protocol_adapter / api_caller是协议无关的通用槽位。
5. 热路径零开销
扩展点系统跑在每一条消息的热路径上(每事件约 5 次 trigger,绝大多数点位无人注册),因此空触发必须零成本。
- 落点:
HookRegistry用写时复制快照——注册/注销低频、触发高频;变更时失效快照,热路径无锁读不可变 tuple。无订阅者的点位直接返回空结果,省去循环与短路判断。 - 含义:新增扩展点时,别在热路径引入锁或分配;保持"无订阅即无代价"。
6. 优雅降级与故障隔离
系统在任何单点失败时不崩、不丢关键能力、能自愈:
- DB 降级:SQLite/MySQL 不可用时,降级到
data/db下的 JSON/YAML 文件运行(db.degraded);database.type: debug用本地模拟 SQL(仅开发)。 - 进程隔离:
core/host双进程把用户扩展故障隔离在宿主进程,接入端与 Web 在核心进程仍在线。 - 运行期自愈:依赖自愈(补装后再试)、孤儿任务/命令清理(
self_check_orphans)、插件级内存监控(每 3s 采样,单插件连续 2 次超plugin.max_memory_mb(默认 64MB)即自动卸载)、进程级内存看门狗(每memory.check_interval(默认 30s)采样 RSS,超memory.limit_mb(默认 120MB)做 GC+trim 三层回收,只回收不卸载)、可靠热重载(心跳增量注册)。 - 事件缓冲兜底:
EventBufferL1 内存主缓冲(512KB / 2000 条)→ L4 写缓冲(4MB 攒批,批量落盘降 sqlite 单写者压力)→ L2 sqlite 持久化(data/event_buffer.db)→ L3 内存兜底(4MB)→ 全满告警丢弃,绝不因为缓冲满而阻塞适配器(消费优先级为 L1 → L3 → L4 → L2)。 - 启动期韧性:崩溃重启守卫把"无限冷启动死循环"变成一次性明确失败(
error.log计数门控 + 安全模式假启动——Type=simple守护进程误拉起时挂起不退出,性能税≈0);单实例文件锁(data/zcbot.lock,跨平台 advisory lock)保证只有一份核心在跑、防止并发写;平台化依赖诊断在插件加载失败时中文归类No module named/ 导入错误 / 动态库失败并给出pip install命令,让"缺依赖"一眼可见、可自助修复。
7. 短路语义
before_* 类扩展点返回 False 即短路,把"过滤 / 鉴权 / 黑名单 / 去重"前置到管线入口:
http.before_request返回 Response 即短路;event.before_dispatch返回False丢弃事件;command.before返回False跳过该命令;message.before_send返回False取消本次发送;router.before_route返回False跳过本次路由。落点:
framework/hooks.py的_SHORT_CIRCUIT表。短路扩展点遇到首个False提前终止,不再触发后续 handler。
8. 单一事实源
配置两份,权威唯一:
config.yaml:框架全局设置(数据库、日志、安全、插件目录)。core_plugins.yaml:官方扩展开关与配置中心,启动自动扫描core_plugins/同步/回写/合并进主配置。- 开关权威性:
core_plugins.yaml每块的enabled以config.yaml的core_plugins:段为准并回写;两处全关即真不加载;enabled: "false"等字符串按严格布尔解析。 - 含义:别在代码里硬编码"某插件是否开启";读
fw.config即可,真相在配置。
9. 同步/异步双轨
同一扩展点要在两种上下文生效,因此分两套触发:
trigger_async:在事件循环内调用,可安全awaitasync handler。trigger_sync:在 Web / 线程上下文(Flask 请求)调用;sync handler 直接执行,async handler 经内核 looprun_coroutine_threadsafefire-and-forget,绝不阻塞请求线程。含义:扩展点 handler 要么写成纯 sync,要么写成
async def并在内部自行await;不要假设自己运行在哪个线程。
10. 声明式权限
权限是节点式模型(plugin.action.sub 形式,三态:授予 / 显式否决 / 未定义),带组继承、上下文、时效、通配。
- 命令级声明式:
@ctx.command(..., require_perm="x.y", require_level="admin"),框架自动拦截,handler 内无需手写判断。 - 运行时:
ev.has_perm(node) / ev.check_perm(node)(三态),上下文自动从事件构造。 - 含义:权限判定走引擎,别在业务里手写
if user == admin。
铁律(落到代码上的判断)
新增任何能力,先问一个问题:
它是内核的"最小必要能力",还是某个扩展的责任?
- 若是内核最小必要能力(加载、路由、公共服务、扩展点契约)→ 放
framework/。 - 若是某个平台 / 某种业务 → 放
core_plugins/(官方)或plugins/(用户),通过 hook / 服务注册表 接入。 - 禁止:往内核塞业务、为某个接入端写
if特判、在framework/里引用具体协议字段。
这条规则是这份哲学的全部用意:守住内核的小,才能换得扩展的自由。
延伸阅读
- 架构链路与启动时序:架构详解
- 扩展点清单与签名:扩展点(Hook 系统)
- 给 AI 编码助手的浓缩版:框架结构(LLM)