简体中文 English 繁體中文 日本語 Русский
本文为静态镜像,内容以交互版为准 在交互式文档中心打开 →

啟動流程與手動控制

ErisPulse 的 await sdk.run() / await sdk.init() 將一整條啟動鏈路封裝成了「一行程式碼」。但當你需要完全自訂啟動流程(例如部分載入、動態註冊、熱插拔、注入自訂載入策略)時,就需要了解這條鏈路內部到底發生了什麼、以及如何手動驅動每一步。

本文把啟動鏈路拆解成獨立的環節,說明各自的職責、呼叫順序,並給出手動完整啟動的示例。

本文假設你已經跑過 第一個機器人,了解 sdk.run(keep_running=True/False) 兩種模式。本文聚焦於 init() 內部的鏈路拆解,以及 init()/init_task()/init_sync() 等更底層的入口。

SDK 頂層入口概覽

除了 run() 的兩種 keep_running 模式,SDK 還提供幾個更底層的初始化入口,其區別在於異步性、返回值、以及是否包裝異常:

入口 異步性 返回值 異常處理 適用場景
await sdk.run(True) async,阻塞維持 None(關閉時自動 uninit) 模組/適配器錯誤被攔截,不拖垮進程 純 bot 應用
await sdk.run(False) async,不阻塞 None(不自動卸載) 同上 初始化後執行自定義邏輯
await sdk.init() async,需 await bool 內部捕獲元件異常,失敗返回 False 手動控制生命週期(配 uninit())
sdk.init_task() async,返回 Task 不阻塞 asyncio.Task 同 init() 並發執行別的初始化、或事件迴圈尚未運行
sdk.init_sync() 同步,阻塞當前執行緒 bool 同 init() 命令列腳本、無事件迴圈的同步入口

常見誤區:await sdk.init() 不等同於 await sdk.run(keep_running=False)。兩點不同:① init() 返回 bool(失敗時返回 False),run() 返回 None;② init() 僅做初始化、不自動卸載,run() 在事件迴圈結束時自動 uninit()。因此需要手動配對卸載或自定義生命週期時,使用 init() + uninit()。

啟動鏈路總覽

sdk.init()(準確來說是其內部的 Initializer.init())按以下順序啟動整個框架:

flowchart TD
    A[0. 準備環境<br/>配置載入 / 異常處理] --> B
    B[1. 並行發現與載入<br/>AdapterLoader.load / ModuleLoader.load<br/>內部調用 Finder.find_all] --> C
    C[2. 註冊適配器<br/>AdapterLoader.register_to_manager] --> D
    D[3. 啟動適配器<br/>adapter.startup] --> E
    E[4. 註冊模組<br/>ModuleLoader.register_to_manager] --> F
    F[5. 初始化模組<br/>ModuleLoader.initialize_modules<br/>實例化並掛載到 sdk] --> G
    G[6. 啟動路由伺服器<br/>router.start]

對應的核心組件:

層 組件 職責
發現 AdapterFinder / ModuleFinder 從已安裝套件的 entry-points 中發現適配器/模組
加載 AdapterLoader / ModuleLoader 發現 + 導入 + 讀取元數據 + 判斷啟用/禁用,返回物件清單
注册 *Loader.register_to_manager 把物件登記到對應管理器
管理 sdk.adapter / sdk.module 維護適配器/模組實例,提供啟停介面
初始化 ModuleLoader.initialize_modules 創建模組實例並掛載到 sdk(處理依賴拓撲排序)
路由 sdk.router HTTP / WebSocket 伺服器

重要:Finder 和 Loader 是兩層。Loader 內部已經持有一個 Finder(AdapterLoader 自帶 AdapterFinder,ModuleLoader 自帶 ModuleFinder)。大多數場景你只需要用 Loader,只有需要「只列出不導入」時才會單獨用 Finder。

各環節詳解

1. 發現層:Finder

Finder 僅負責「找到有哪些套件提供了適配器/模組」,不進行匯入或實例化。

from ErisPulse.finders import AdapterFinder, ModuleFinder

adapter_finder = AdapterFinder()
module_finder = ModuleFinder()

# 查找所有已安裝的適配器/模組 entry-points
adapter_entries = adapter_finder.find_all()    # list[EntryPoint]
module_entries = module_finder.find_all()      # list[EntryPoint]

# 按名稱查找單個
entry = module_finder.find_by_name("MyModule")  # EntryPoint | None

每個 EntryPoint 可以 .load() 得到對應的類,但通常不需要你手動調用——Loader 會處理。

2. 加載層:Loader

Loader 在 Finder 之上做了「匯入 + 讀取元數據 + 判斷啟用/禁用」的工作。

from ErisPulse.loaders import AdapterLoader, ModuleLoader
from ErisPulse import sdk

adapter_loader = AdapterLoader()
module_loader = ModuleLoader()

# load() 內部:調用 finder.find_all() → 逐個處理 entry-point → 返回三元組
adapter_objs, enabled_adapters, disabled_adapters = await adapter_loader.load(sdk.adapter)
module_objs, enabled_modules, disabled_modules = await module_loader.load(sdk.module)

load() 返回的三元組:

返回值 含義
objs (dict) 名稱 → 對象(適配器類 / 模組封裝物件)
enabled (list[str]) 被啟用的名稱(配置中未禁用)
disabled (list[str]) 被禁用的名稱

加載失敗時的診斷資訊

當某個模組/適配器在加載或初始化階段拋出異常時,框架會跳過該元件並繼續加載其他元件,同時輸出使用者程式碼框架摘要,讓你在預設的 INFO 級別下即可定位出錯位置,無需手動重開 DEBUG:

[ERROR] [ModuleLoader] 從 entry-point 加載模組 MyModule 失敗,已跳過: 'NoneType' object has no attribute 'platform'
  → MyModule/Core.py:42 in on_load
      adapter = sdk.platform
  → AttributeError: 'NoneType' object has no attribute 'platform'
  → 提示: 將日誌級別提高到 DEBUG 可查看完整堆疊;檢查模組 MyModule 的實作代碼

診斷資訊透過 ErisPulse.runtime.diagnostics 模組產生,會自動過濾掉框架內部框架,只保留你的程式碼框架。如需在自訂加載邏輯中重用:

from ErisPulse.runtime import log_diagnostic

try:
    risky_init()
except Exception as e:
    log_diagnostic(e)  # 自動提取使用者程式碼框架並寫入 ERROR 日誌

該模組還提供 extract_user_frame()(返回結構化框架資訊)和 format_diagnostic_block()(返回多行文字)兩個底層函數。

3. 註冊層:register_to_manager

將 Loader 產出的物件登記到管理器,讓 sdk.adapter / sdk.module 能識別它們。

# 註冊適配器(返回 bool,表示是否全部成功)
await adapter_loader.register_to_manager(enabled_adapters, adapter_objs, sdk.adapter)

# 註冊模組
await module_loader.register_to_manager(enabled_modules, module_objs, sdk.module)

註冊後,適配器已登記到適配器管理器、模組已登記到模組管理器,但都還未啟動/實例化。

4. 啟動適配器

# 啟動所有已註冊的適配器
await sdk.adapter.startup()
# 或指定平台
await sdk.adapter.startup("yunhu")
await sdk.adapter.startup(["yunhu", "telegram"])

註冊 ≠ 啟動。register_to_manager 只是登記;startup 才會呼叫適配器的 start(),建立與平台的連接。

5. 初始化模組

模組比適配器多一步——需要實例化並掛載到 sdk 上(這樣你才能 sdk.MyModule.xxx 調用)。這一步還處理模組間的依賴宣告與拓撲排序。

success = await module_loader.initialize_modules(
    enabled_modules, module_objs, sdk.module, sdk
)

實例化成功後,模組會出現在 sdk.<ModuleName> 上。

6. 啟動路由伺服器

await sdk.router.start(
    host="0.0.0.0",
    port=8000,
    ssl_certfile=None,
    ssl_keyfile=None,
)

路由伺服器負責接收適配器的 Webhook / WebSocket 回調。不啟動它,server 模式的適配器無法接收訊息。

完整手動啟動範例

下面這段程式碼等價於 await sdk.init() 的核心流程,但每一步都暴露在你手裡,可以在任意環節插入自定義邏輯:

import asyncio
from ErisPulse import sdk
from ErisPulse.loaders import AdapterLoader, ModuleLoader

async def manual_startup():
    # 0. 準備環境(載入設定、註冊全域例外處理)
    #    _prepare_environment 是 init() 內部的前置步驟;手動流程也需先呼叫,
    #    否則 Loader 讀不到設定,會把所有適配器/模組誤判為禁用。
    if not await sdk._prepare_environment():
        print("環境準備失敗")
        return False

    # 1. 建立載入器(內部各自持有 Finder)
    adapter_loader = AdapterLoader()
    module_loader = ModuleLoader()

    # 2. 並行發現與載入(與 init() 內部一致用 gather)
    (adapter_objs, enabled_adapters, disabled_adapters), \
    (module_objs, enabled_modules, disabled_modules) = await asyncio.gather(
        adapter_loader.load(sdk.adapter),
        module_loader.load(sdk.module),
    )

    # 3. 註冊適配器
    await adapter_loader.register_to_manager(
        enabled_adapters, adapter_objs, sdk.adapter
    )

    # 4. 啟動適配器
    if enabled_adapters:
        await sdk.adapter.startup()

    # 5. 註冊模組
    await module_loader.register_to_manager(
        enabled_modules, module_objs, sdk.module
    )

    # 6. 初始化模組(實例化 + 掛載到 sdk)
    if enabled_modules:
        await module_loader.initialize_modules(
            enabled_modules, module_objs, sdk.module, sdk
        )

    # 7. 啟動路由伺服器
    await sdk.router.start(host="0.0.0.0", port=8000)

    print("手動啟動完成")
    return True

async def main():
    ok = await manual_startup()
    if ok:
        # 阻塞維持運行(手動流程不會自動阻塞)
        await asyncio.Event().wait()

if __name__ == "__main__":
    asyncio.run(main())

什麼時候該手動啟動?

大多數情況下不需要手動啟動,await sdk.run() 已經把上面這些都做好了。手動啟動僅在這些場景才有價值:

運行時細粒度控制

即使使用了 sdk.run() 完成啟動,你仍然可以在運行時單獨控制各子系統,而不必重新啟動整個 SDK:

適配器熱啟停

# 熱重啟某個適配器(修復連接,不會影響其他平台)
await sdk.adapter.shutdown("yunhu")
await sdk.adapter.startup("yunhu")

# 運行中拉起一個新平台
await sdk.adapter.startup("telegram")

# 臨時下線某平台
await sdk.adapter.shutdown("telegram")

adapter.startup() 要求適配器已被註冊到管理器。註冊發生在 init()/run() 內部,因此這是啟動之後的細粒度控制。

路由伺服器

# 臨時下線 webhook 伺服器
await sdk.router.stop()

# 重新啟動(例如更換了端口)
await sdk.router.start(host="0.0.0.0", port=9000)

模組按需載入

# 手動載入一個(可能是懶加載的)模組
await sdk.load_module("MyModule")

優雅關閉

從 2.7.0 開始,sdk.shutdown() 提供程式化優雅關閉:設定關閉事件,讓正在 await sdk.run(keep_running=True) 掛起的主迴圈返回,進而觸發 uninit() 完成資源清理。

# 在任意協程中呼叫,觸發優雅退出(run() 掛起返回並自動 uninit)
sdk.shutdown()

典型用途:

async def shutdown_after_idle():
    await asyncio.sleep(3600)
    sdk.shutdown()  # 空閒 1 小時後優雅退出

信號處理:run() 內部會註冊 SIGTERM / SIGHUP 處理器,將系統信號轉為優雅關閉——容器編排(Docker docker stop)或 systemd 停止服務時,進程會走完 uninit() 清理而非被強殺。

卸載流程

啟動的反向操作是 await sdk.uninit(),它按相反順序進行清理:

  1. 關閉所有適配器(adapter.shutdown())
  2. 卸載所有模組
  3. 清理所有事件處理程式
  4. 清理管理器與 SDK 上的模組屬性

在手動啟動的場景下,請記得在退出前呼叫 uninit() 以確保優雅關閉:

try:
    await asyncio.Event().wait()   # 維持運行
finally:
    await sdk.uninit()

重啟

SDK 提供兩種重啟方式,都不需要你自己先卸載——框架會自行處理:

方式 調用 行為 適用場景
熱重啟 await sdk.restart() 同一進程內 uninit() 後重新 init(),重新加載適配器/模塊 重新加載配置、熱更新模塊
硬重啟 await sdk.hard_restart() uninit() 後以退出碼 42 退出進程,由外部監督者拉起全新進程 懷疑有記憶體/資源洩漏、需要徹底乾淨重啟
# 熱重啟:同進程內重新加載(最常用)
await sdk.restart()

# 硬重啟:退出進程,交由外部監督者重啟(見下方「監督者指南」)
await sdk.hard_restart()

兩點注意:

  1. 這兩個方法都用背景任務執行重啟,立即返回 True 表示「重啟任務已排程」,而非「重啟已完成」。實際重啟在背景進行,避免中斷當前事件鏈路。
  2. hard_restart() 的原理是:卸載並刷盤配置後,以退出碼 42(HARD_RESTART_EXIT_CODE)退出進程——它自身不拉起新進程,必須由外部監督者檢測到退出碼 42 後重新啟動。若直接 python main.py 運行且無任何監督者,進程以碼 42 退出後就結束了,不會自動重啟(框架會打警告提示)。

什麼時候該用硬重啟?

硬重啟不只是"更徹底的重啟",它在以下場景比熱重啟更合適、甚至更高效:

Dashboard 管理面板裡的「框架重啟」功能,底層調用的就是 hard_restart()。

退出碼 42 契約

硬重啟是跨進程協作:SDK 負責退出(碼 42),監督者負責拉起。

角色 行為
SDK(被硬重啟時) uninit() → 刷盤配置 → os._exit(42)
監督者 檢測到子進程退出碼為 42 → 重新啟動同一命令

sdk.is_supervised() 可查詢當前進程是否由監督者啟動(檢測環境變數 ERISPULSE_SUPERVISED)。CLI run 命令啟動子進程時會自動注入該標記;systemd / Docker 等外部監督者不會注入,is_supervised() 返回 False,此時硬重啟後框架會打「未檢測到監督者」警告。

監督者指南

選擇適合你的監督者,讓硬重啟真正生效:

1. CLI run 命令(開發/簡單部署,推薦)

epsdk run main.py 內置監督循環:檢測子進程退出碼,42 時立即重啟;其它異常退出碼按指數退避自動重試;Ctrl+C 會先優雅終止子進程(碼 0 視為正常退出,不再拉起)。

epsdk run main.py

2. systemd(Linux 伺服器)

RestartForceExitStatus=42 讓退出碼 42 也觸發重啟(預設 on-failure 只對非零碼生效):

[Service]
ExecStart=/usr/bin/python3 /opt/mybot/main.py
Restart=on-failure
RestartForceExitStatus=42
RestartSec=2
User=mybot

3. Docker / docker-compose

容器內 PID 1 是應用進程,退出碼 42 後容器退出——用 restart 策略讓它自動重啟:

services:
  bot:
    build: .
    restart: unless-stopped   # 任何退出(含 42)都重啟

4. PM2(Node 生態運維)

pm2 start main.py --name mybot --interpreter python3
# 42 被視為退出碼,PM2 預設重啟;設置 restart_delay 防抖
pm2 set mybot.restart_delay 2000

5. supervisord

[program:mybot]
command=python3 /opt/mybot/main.py
autorestart=true
exitcodes=0,2,42    # 42 也視為"正常退出需重啟"

6. 純 Python 自定義監督者

import subprocess, sys, time

while True:
    p = subprocess.Popen([sys.executable, "main.py"])
    code = p.wait()
    if code == 42:          # 硬重啟請求
        time.sleep(0.5)
        continue
    if code == 0:           # 正常退出
        break
    time.sleep(3)           # 異常退出,退避重試

無監督者時的行為:直接 python main.py 運行,調用 hard_restart() 後進程以碼 42 退出、不會重啟。此時應接入上述任一監督者。

相關文件