Это история о том, как потерпеть фиаско, имея хорошо написанный и протестированный в боевых условиях работающий код и даже написанную документацию. Изначально я собирался делать анонс своей библиотеки, но что-то пошло не так. Поэтому начнём за здравие -- постановка и формализация задачи, описание возможностей и батареек. А закончим за упокой -- вопросами, как всё это теперь тестировать?
Неудавшийся анонс. SystemPY, в девичестве lifecycle
Начну с формализации проблемы. Современное asyncio
приложение состоит из множества компонентов типа Redis, БД, брокера и прочих. Эти компоненты необходимо инициализовать и останавливать в правильном порядке. Также очень хочется из коробки работающий reload
. Вишенкой же на торте будет точно так же инициализорованный REPL, а также простота написания одноразовых скриптов для разрешения косяков на проде. Читать статью я рекомендую, глядя одним глазом в документацию. Согласитесь, нет смысла её просто переводить, вместо этого её стоит расширить
Ещё раз и внимательно
Когда точка входа одна, добавление компонентов происходит постепенно и незаметно. Когда появляется вторая точка входа, будь то ещё один микросервис, REPL или скрипт -- вам приходится решать задачу запуска заново. Это особенно смешно в случае скрипта и остро напоминает проблему банана и гориллы -- вы хотели всего лишь банан, но для этого вам пришлось написать код для инициализации гориллы и всех джунглей, а после ещё для остановки
Я с ходу настаиваю на добавлении REPL -- ещё одной точки входа
Анализ проблемы и подхода
Я предлагаю смотреть на приложение как на конструктор компонентов. Идея в том, что каждый компонент может требовать для своего запуска и остановки исполнения некоторого кода. Этот некоторый код хорошо изолируется в собственные классы-миксины. Итоговое приложение является комбинацией этих миксинов. Создание новой точки входа сводится к импорту написанных ранее компонентов наследованию от них нового класса
Этапы жизненного цикла приложения
Я выделил 6 основных этапов жизненного цикла приложения:
on_init
-- код исполняется единожды только в момент инициализации;pre_startup
-- код исполняется до запуска event loop в том числе во время reload;on_startup
-- код исполняется сразу после инициализации event loop;on_shutdown
-- код исполняется, когда приложение приняло решение остановиться, но event loop ещё работает;post_shutdown
-- код исполняется сразу после остановки / очистки event loop. В случае reload следующим этапом будетpre_startup
;on_exit
-- код исполняется единожды только в момент остановки приложения.
Сценарии работы приложения
Я выделил 3 основных сценария:
Ведомое приложение, запускаемое каким-то фреймворком;
"Сам себе хозяин" -- демоны, скрипты и REPL;
Ведущее приложение или фреймворк, запускающий клиентское приложение.
Кастомные этапы жизненного цикла
Есть необходимость выполнить код прямо перед или прямо после какого-то существующего этапа жизненного цикла? Легко
Допустим, есть необходимость дважды вклиниться прямо перед on_startup
. Для этого необходимо создать интерфейс:
@util.register_target
class MyCustomTarget(Target):
@util.register_hook_before(Target.on_startup)
@util.register_target_method("forward")
async def before_on_startup(self) -> None: ...
@util.register_hook_before(before_on_startup)
@util.register_target_method("gather")
async def before_2_on_startup(self) -> None: ...
После от этого интерфейса наследоваться. В результате методы будут выполнены в следующем порядке: before_2_on_startup
-> before_on_startup
-> on_startup
Естественно, вклинивать этапы можно пока не надоест
Хороший пример кастомных этапов жизненного цикла рассмотрен в документации
Боль, ненависть, REPL
Казалось бы, что может пойти не так, когда батарейки в комплекте? Пройдёмся по болевым точкам и откроем стандартный asyncio
REPL:
python3 -m asyncio
asyncio REPL 3.9.2 (default, Feb 28 2021, 17:03:44)
[GCC 10.2.1 20210110] on linux
Use "await" directly instead of "asyncio.run()".
Type "help", "copyright", "credits" or "license" for more information.
>>> import asyncio
>>>
Номинально работает, но:
меня бесит некорректная обработка
Ctrl+C
А что с автодополнениями по
Tab
? Почему не работают?Окей, как теперь инициализировать окружение? Правильно, ручками
Так вот, требования к REPL
Ничего сверхестественного:
Корректная обработка
Ctrl+C
Автодополнения должны работать, если есть
readline
Окружение должно инициализироваться компонентно и так же останавливаться
Добавление собственных глобальных переменных (импортированных модулей) в неймспейс
Всё это тоже работает. Больше примеров в документации. В процессе пришлось через ctypes
лезть в недра readline
и заниматься знаменитым брутфорс-программированием. Питонячий readline
-- это стыдоба
Ну и как я умудрился зайти в тупик?
Согласитесь, тестировать своим продом не совсем правильно. Вопрос необходимо формализовать. С учётом возможностей, простейшая с виду задача превращается в рак мозга. Есть непроверенный функционал -- метод reload
, вызов которого должен запустить процесс остановки и последующего перезапуска. Очевидно, он должен вести себя одинаково во всех сценариях, а все нюансы -- половые трудности уже systempy
. А различий есть у меня! В REPL вместо штатного reload
необходимо выполнять reload_threadsafe
-- и это совсем не одно и то же. У меня ломается мозг, как проверить работу метода reload в других сценариях работы
В то же время есть казалось бы простые моменты, которые протестировать и можно. Например, регистрируется ли Target, и даже в каким порядке выполняются методы, где systempy
кидает исключения, и так далее. Смех в том, что там отсутствуют оси изменений
А ещё очень неприятно ломается REPL, и в случае ошибок забирает за собой и терминал -- спасает только закрытие и открытие нового
Если ли у хабра идеи? Или воспользоваться запасным сценарием?