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

Процесс запуска и ручное управление

Методы await sdk.run() / await sdk.init() ErisPulse объединяют весь процесс запуска в одну строку кода. Однако, если вам нужно полностью настроить процесс запуска (например, частичная загрузка, динамическая регистрация, горячая замена, встраивание пользовательской стратегии загрузки), вам нужно понимать, что происходит внутри этой цепочки, а также как вручную управлять каждым шагом.

В этой статье мы разбиваем процесс запуска на отдельные этапы, объясняем их назначение и порядок вызова, а также приводим пример ручного полного запуска.

В этой статье предполагается, что вы уже запустили первого робота и знакомы с двумя режимами sdk.run(keep_running=True/False). В этой статье мы сосредоточимся на разборе внутренней цепочки init(), а также на более низкоуровневых входных точках, таких как init()/init_task()/init_sync().

Обзор верхнего уровня SDK

Помимо двух режимов keep_running для run(), SDK предоставляет несколько более низкоуровневых точек входа, которые отличаются асинхронностью, возвращаемым значением и обработкой исключений:

Точка входа Асинхронность Возвращаемое значение Обработка исключений Сценарий использования
await sdk.run(True) async, блокирующий None (автоматически uninit при остановке) Ошибки модулей/адаптеров перехватываются, не приводят к сбою процесса Простое приложение-бот
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] Загрузка модуля MyModule из entry-point не удалась, пропущено: '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 от адаптеров. Без запуска сервера, адаптеры в режиме сервера не смогут получать сообщения.

Полный пример ручного запуска

Следующий код эквивалентен основному процессу await sdk.init(), но каждая его часть доступна для ручного управления, что позволяет вставить пользовательскую логику в любой момент:

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

async def manual_startup():
    # 0. Подготовка окружения (загрузка конфигурации, регистрация обработки исключений)
    #    _prepare_environment — это внутренний шаг init(); если использовать ручной процесс,
    #    его необходимо вызвать до Loader, иначе Loader не сможет прочитать конфигурацию,
    #    и все адаптеры/модули будут ошибочно признаны отключенными.
    if not await sdk._prepare_environment():
        print("Ошибка при подготовке окружения")
        return False

    # 1. Создание загрузчиков (внутри каждый загрузчик содержит Finder)
    adapter_loader = AdapterLoader()
    module_loader = ModuleLoader()

    # 2. Параллельное обнаружение и загрузка (аналогично внутреннему gather в init())
    (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 и перезапустил процесс. Если процесс завершается с кодом 42 и не запущен никаким наблюдателем, например, при запуске напрямую через python main.py, процесс завершится и не будет автоматически перезапущен (фреймворк выдаст предупреждение).

Когда использовать жесткий перезапуск?

Жесткий перезапуск — это не просто "более полный перезапуск", он более подходит и даже эффективнее в следующих случаях:

Функция "Перезапуск фреймворка" в панели управления 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.js для администрирования)

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 и не будет автоматического перезапуска. В этом случае необходимо подключить один из вышеперечисленных наблюдателей.

Связанные документы