# 生命周期

如果你已经读完了前面介绍的全部内容,那么本章将会是对上述内容的一个很好的梳理。在这里,你能够深入了解一个 Koishi App 的运作原理。让我们开始吧。

# 总览

首先让我们对 App 的生命周期有一个总体的认识:它分为创建阶段连接阶段运行阶段。如果考虑销毁过程,还将有两个阶段分别对应连接阶段和创建阶段,用于取消连接和销毁应用。

# 创建阶段

当执行 new App() 时,App 的实例创建出来,同时如果配置了相应的字段,Database, Sender, Server 的实例也会被创建并绑定到 App 上,但它们都尚未连接到服务器。在此之后,便是上下文的构造和插件的安装,因此插件能接触到的 App 实例是这个阶段的。在这个阶段中,如果传入的某些参数有误,将会直接抛出错误,从而终止程序。

# 连接阶段

当执行 app.start() 或者 startAll() 时,App 首先会触发 before-connect 事件。接着,App 将尝试连接到所有服务器(包括数据库服务器和 CQHTTP 服务器),如果全部连接成功将会触发 connected 事件。在这个阶段中,如果出现错误,将会抛出一个异步错误,同时 App 不会运行。

# 运行阶段

当连接成功后,所有 API 都将可以正常调用。在这个阶段中,如果出现错误,将会触发 error 事件(部分情况也可能导致 unhandledRejection,这取决于插件的写法,例如如果在同步函数内调用异步方法出错就是无法捕捉的)。

# 生命周期图示

下图大体展示了一个 App 实例的生命周期。你不需要立即弄明白所有的东西,不过随着你的不断学习和使用,它的参考价值会越来越高。图中紫色部分对应创建阶段,玫红色部分对应连接阶段,绿色部分对应运行阶段。

app-lifecycle

# ready 事件

上面的图中提到了 ready 事件,这个事件比较特殊,因此这里具体介绍一下。之前已经介绍过有个配置项叫做 selfId,表示机器人自身的 QQ 号,它是可选的。这是因为即使不提供这个选项,CQHTTP 的机制也能使得 Koishi 在运行时获得机器人的 QQ 号,而且实在不行也可以通过 sender.getLoginInfo() 方法直接获取登录号信息。

但是,一般地说,如果你要利用 QQ 号本身进行一些操作,connect 事件本身不能确保 app.selfId 有值。因此,Koishi 设计了 ready 事件来解决这个问题。ready 事件在成功连接到服务器且已经获得 QQ 号时触发。这其中包含三种情况:

  • 如果已经配置了 selfId 字段,那么此事件在 connect 事件后立即触发
  • 如果在运行时显示调用了 getSelfIds() 方法,则完成 QQ 号获取后触发
  • 其他情况下,当收到含有 selfId 字段的元信息时,在元信息触发其他事件之前触发

无论是哪一种情况,当发生此事件时都可以确保 App 实例已经正常运行且可以通过 app.selfId 获得机器人的 QQ 号。

# 监听器、中间件和指令

我们已经熟悉了 Koishi 的一些基本概念,比如监听器(Receiver)、中间件(Middleware)和指令(Command),那么他们的关系是什么呢?上面的生命周期图也同样告诉了我们答案:中间件由内置监听器管理,而指令由内置中间件管理。没错,当 message 事件被发送到各个上下文的监听器上时,绑定在 App 上的内置监听器将会将这个事件逐一交由中间件进行处理。全部处理完成后会触发一个 after-middleware 事件。

因为我们通常不需要直接监听 message 事件(使用中间件往往是更好的实现),after-middleware 事件的触发通常意味着你对一条消息的处理已经完成。我们的测试工具 koishi-test-utils 就是基于这种逻辑设计了它的 Session API

# 消息处理机制

在本节中,我们将完整地回顾整个 Koishi 内部的消息处理机制,从监听器到中间件再到指令,这对理解元信息对象上的各种属性操作有着很大的作用。下面以一个群内指令调用为例,依次介绍其处理流程。这将有助于你理解一个 Meta 对象是如何生成的。

# Server 部分

  1. 服务器接收到一个事件推送
  2. 对 secret 进行比对,如果错误则返回 403
  3. 将事件数据转化为 camelCase,得到 Meta 对象
  4. 检测 selfId 字段:
    • 如果缺少则自动添加
    • 如果发现与绑定的 selfId 不一致则返回 403
    • 如果是刚刚获取到的则更新 appMap,触发 ready 事件
  5. 将 CQHTTP 3.x 的格式转化成 4.x,转化数组格式的 message
  6. 解析元数据中的字段,生成 $ctxType, $ctxId
  7. 根据元数据中的字段生成可能的快捷操作函数,如 $send, $delete
  8. 逐一在相关上下文触发 message/normal, message 事件(下接内置监听器

# 内置监听器

  1. message 事件触发,进入中间件处理流程
  2. 根据上下文从中间件池中筛选出要执行的中间件序列
  3. 逐一执行中间件:
    • 内置中间件是理论上第一个注册的中间件(下接内置中间件
    • 通过 ctx.prependMiddleware() 注册的中间件会插在队列的更前面
    • 临时中间件会直接插在当前序列的尾端,并不会影响中间件池本身
    • 如果执行中遇到错误会触发 error/middleware 事件并停止继续执行
  4. 触发 after-middleware 事件
  5. 更新当前用户和群的缓冲数据(参见按需加载与更新数据

# 内置中间件

  1. 将 message 繁体转简体,去前后空格
  2. 从前缀中匹配 at 机器人,nickname 或 commandPrefix
  3. 将 message 中剩下的部分 trim 并转简体,存入 meta.$parsed 对象
  4. 尝试将 meta.$parsed.message 解析成指令调用
  5. 如果 3 不成功:继续尝试解析成快捷方式调用
  6. 如果数据库存在:
    • 触发 before-group 事件
    • 获取群数据并存储于 meta.$group
    • 根据 flags, assignee 等字段判断是否应该处理该信息,如果不应该则直接返回
    • 触发 attach-group 事件(用户可以在其中同步地更新群数据)
    • 触发 before-user 事件
    • 获取用户数据并存储于 meta.$user
    • 根据 flags 等字段判断是否应该处理该信息,如果不应该则直接返回
    • 触发 attach-user 事件(用户可以在其中同步地更新群和用户数据)
  7. 如果解析出指令:执行该指令(下接指令执行流程
  8. 尝试解析出候选指令,如果成功则显示候选项

# 指令执行流程

  1. 触发 before-command 事件
  2. 如果是 -h, --help 则直接显示帮助信息
  3. 根据配置检查参数和选项是否合法
  4. 根据配置检查用户权限和调用记录
  5. 触发 command 事件
  6. 调用 action 回调函数
  7. 如果上述过程没有调用 next 则触发 after-command 事件