Новая структура для бота на базе aiogram
forzendЗачем мне придерживаться одной структуры?
Структура для любого проекта — очень серьёзный вопрос. Когда все используют одну структуру, значительно легче разобраться в чужом коде, а также количество legacy кода уменьшается в разы. Многие, кто проходил курс Константина используют предложенную им в курсе структуру для ботов. Это хорошая структура, но у неё есть несколько недостатков, которые я предпочёл убрать.
Код из данной статьи есть на github
Важное примечание
Автор статьи поменял ссылки на свой telegram и github профиль, поэтому у вас могут не открываться некоторые ссылки.
Новая ссылка на github профиль автора, в котором находится репозиторий статей: https://github.com/F0rzend
Новая ссылка на мой telegram профиль, для обратной связи: https://t.me/F0rzend
Основные недостатки структуры Latand
То, что мне больше всего было неугодно — абсолютно всё находится в корне проекта.
Корень самого чистого проекта, практически без кода, выглядит примерно так:
. ├── data/ ├── filters/ ├── handlers/ ├── keyboards/ ├── middlewares/ ├── states/ ├── utils/ ├── .env ├── .gitignore ├── app.py ├── loader.py └── requirements.txt
В будущем, при расширении проекта в корень добавляется огромное количество файлов, таких как LICENSE, README.md, docs/, tests/ и прочие и ориентироваться в проекте становится всё сложнее и сложнее
Код должен лежать отдельно
По описанной выше причине в первую очередь весь код мы перенесём в отдельную директорию, назовём её app. В этой папке у нас будут лежать исходники бота. Теперь наша структура должна иметь подобный вид:
. ├── app | ├── data/ | ├── filters/ | ├── handlers/ | ├── keyboards/ | ├── middlewares/ | ├── states/ | ├── utils/ | ├── app.py | └── loader.py ├── .env ├── .gitignore └── requirements.txt
Уже значительно лучше. Но код придётся запускать командой (После такого серьёзного рефакторинга проект не готов к запуску, можете даже не пытаться):
python3 app/app.py
Это не так удобно, как хотелось бы. Поэтому я предлагаю вам переименовать файл app.py
в __main__.py и в директории
app/ добавить файл app/__init__.py
Таким образом наш код теперь запускается командой
python -m app
Что, как мне кажется, значительно лучше. Если у вас есть желание, в файле app/__init__.py
можно оставлять полезную информацию, например:
__author__ = 'Anonymous author' __email__ = 'author@gmail.com' __version__ = '1.0'
Фильтры подключались из корня проекта, теперь, если вы попытаетесь запустить проект, вы получите ошибку, ведь наши фильтры не в корне, а в модуле app/
поэтому нам нужно изменить app/__main__.py
файл и импортировать фильтры иначе:
# Раньше # import filters # Сейчас from app import filters filters.setup()
То же нужно проделать и с остальными модулями, такими как handlers, middlewares
Конфиг
В данный момент config.py
у нас находится в папке app/data
и мне кажется это не самым подходящим местом для конфига. Всё же конфиг должен быть в шаговой доступности, поэтому его я предлагаю вам перенести прямо в app/, а модуль data можно и вовсе удалить. И теперь наш проект принимает подобный вид:
. ├── app | ├── filters/ | ├── handlers/ | ├── keyboards/ | ├── middlewares/ | ├── states/ | ├── utils/ | ├── app.py | ├── config.py | └── loader.py ├── .env ├── .gitignore └── requirements.txt
Подключение filters, handlers, middlewares
Для работоспособности, filters должны подключаться до handlers, что вызывает некоторые проблемы. Многие привыкли подключать handlers путём импорта с помощью декораторов, поэтому данный нюанс очень сильно увеличивает количество импортов внутри функций, от которых мы будем избавляться далее в статье. Поэтому я предпочитаю подключать импортом не только handlers, но и filters и middlewares. Для этого внесём некоторые изменения.
При импортировании, файл module_name/__init__.py
исполняется, и мы этим воспользуемся. Начнём с фильтров. Сейчас файл app/filters/__init__.py
выглядит так:
from aiogram import Dispatcher # from .is_admin import AdminFilter def setup(dp: Dispatcher): # dp.filters_factory.bind(AdminFilter) pass
Заменим функцию setup на условие, которые будет выполняться, когда модуль вызывается при импорте, мы делаем это, чтобы избежать возможности запустить код напрямую, что приведёт к ошибке:
from aiogram import Dispatcher # from .is_admin import AdminFilter if __name__ == 'app.filters': # dp.filters_factory.bind(AdminFilter) pass
То же самое проделаем и с app/middlewares/__init__.py
(обратите внимание, что я вовсе удалил ThrottlingMiddleware, ибо не считаю его необходимым в "шаблоне" проекта):
from aiogram.contrib.middlewares.logging import LoggingMiddleware from app.loader import dp if __name__ == 'app.middlewares': dp.middleware.setup(LoggingMiddleware())
Перейдём в файл app/__main__.py
и подключим filters и middlewares в соответствии с изменениями. Сейчас он выглядит у нас так:
async def on_startup(dp): from app import filters from app import middlewares filters.setup(dp) middlewares.setup(dp) from app.utils.notify_admins import on_startup_notify await on_startup_notify(dp) if __name__ == '__main__': from aiogram import executor from app.handlers import dp executor.start_polling(dp, on_startup=on_startup)
изменим его, чтобы он принял такой вид:
async def on_startup(dp): from app import filters, middlewares from app.utils.notify_admins import on_startup_notify await on_startup_notify(dp) if __name__ == '__main__': from aiogram import executor from app.handlers import dp executor.start_polling(dp, on_startup=on_startup)
Точка входа
Продолжим вносить изменения в app/__main__.py
Стоит заметить, что dispatcher
(dp) у нас находится в app/loader.py
, поэтому и импортировать мы будем его оттуда, мне кажется это более интуитивно, а модуль handlers
будем импортировать для подключения самих обработчиков (Вас может смутить IDE, говорящая вам, что данный импорт бесполезен и его стоит удалить, ноэто не так, он несёт функционал и необходим в основном файле проекта, ни в коем случае не удаляйте данный импорт):
async def on_startup(dp): from app import filters, middlewares from app.utils.notify_admins import on_startup_notify await on_startup_notify(dp) if __name__ == '__main__': from aiogram import executor from app import handlers from app.loader import dp executor.start_polling(dp, on_startup=on_startup)
Теперь мы можем немного поменять файл app/handlers/__init__.py
. Сейчас он выглядит так:
from .errors import dp from .users import dp __all__ = ["dp"]
В нём нам не нужен dispatcher, но для подключения handlers, мы должны импортировать файлы. Поэтому удалим из него dispatcher и список __all__
, а вместо dp пропишем названия файлов в модуле:
from .errors import error_handler from .users import start, help, echo
Теперь нам не нужен код в __init__.py
каждого вложенного модуля с обработчиками,
Откроем app/handlers/users/__init__.py
. Сейчас он имеет такой вид:
from .help import dp from .start import dp from .echo import dp __all__ = ["dp"]
Полностью его очистим. Но не удалим, ибо питону нужен файл __init__.py
в папке, чтобы он понимал, что это пакет и мог его импортировать.
Проделав это с каждым из подмодулей, мы покончили с подключением обработчиков.
Теперь вернёмся к нашему app/__main__.py
файлу. Напомню, как он выглядит:
async def on_startup(dp): from app import filters, middlewares from app.utils.notify_admins import on_startup_notify await on_startup_notify(dp) if __name__ == '__main__': from aiogram import executor from app import handlers from app.loader import dp executor.start_polling(dp, on_startup=on_startup)
Плохим жестом является наличие импортов в теле функций. Проделанные нами ранее изменения позволяют там это исправить. Вынесем все импорты из функций. Не забудем о том, что для своей дееспособности filters должны подключаться перед handlers:
from aiogram.utils import executor from app import utils, config from app.loader import dp # The configuration of the modules using import from app import middlewares, filters, handlers async def on_startup(dp): await utils.on_startup_notify(dp) if __name__ == '__main__': executor.start_polling(dp, on_startup=on_startup)
Так значительно лучше.
Бонусы
Также я хочу предложить вам несколько инструментов, и "лайфхаков", которые определённо должны вам понравиться
Подключение команд бота в коде
С выходом Telegram Bot API версии 4.7 у нас появилась возможность делать команды не в @BotFather, а в коде. Это может быть полезно, когда вы пишите бота для заказчика, чтобы тому не нужно было настраивать все команды в боте вручную. Создадим файл app/utils/default_commands.py
. Подключение команд будет выглядеть следующим образом. Мне кажется какие-либо комментарии излишни:
from aiogram import types async def setup_default_commands(dp): await dp.bot.set_my_commands( [ types.BotCommand("start", "Начало работы с ботом"), types.BotCommand("help", "Помощь"), ] )
Пропишем эту функцию в app/utils/__init__.py
чтобы можно было её импортировать из utils
. Также я хочу изменить название функции с оповещением администрации, просто чтобы её название было короче и интуитивнее. Сделав это, мой файл app/utils/__init__.py имеет такой вид:
from . import db_api from . import misc from .default_commands import setup_default_commands from .notify_admins import notify_admins
Красивое логирование
Я думаю многие уже используют loguru. Те же, кто этого не делает, вам необходимо срочно исправиться!
А начнём мы с того, что установим loguru:
pip install loguru
Следующим шагом будет написание обработчика для логирования. Это делается для того, чтобы логи абсолютно всех библиотек проходили через loguru. Не будем изобретать велосипед и просто скопируем обработчик из официальной документации loguru в новый файл app/utils/logger.py
:
class InterceptHandler(logging.Handler): def emit(self, record): # Get corresponding Loguru level if it exists try: level = logger.level(record.levelname).name except ValueError: level = record.levelno # Find caller from where originated the logged message frame, depth = logging.currentframe(), 2 while frame.f_code.co_filename == logging.__file__: frame = frame.f_back depth += 1 logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
Ниже определим функцию для его подключения с возможностью задать уровень логирования:
def setup_logger(level: Union[str, int] = "DEBUG"): logging.basicConfig( handlers=[InterceptHandler()], level=logging.getLevelName(level) )
тут же и внесём первый лог:
def setup_logger(level: Union[str, int] = "DEBUG"): ... logger.info('Logging is successfully configured')
Логи некоторых библиотек нам совсем не нужны, поэтому внесём изменения в нашу функцию подключения, чтобы была возможность отключить неугодные нам библиотеки:
def setup_logger( level: Union[str, int] = "DEBUG", ignored: List[str] = "" ): logging.basicConfig( handlers=[InterceptHandler()], level=logging.getLevelName(level) ) for ignore in ignored: logger.disable(ignore) logger.info('Logging is successfully configured')
Прекрасно. Теперь удалим старые настройки логирования. Для этого удалим файл app/utils/misc/logging.py
, т.к. внутри app/utils/misc у нас ничего не осталось, можем вовсе его удалить, после чего внесём нашу функцию в app/utils/__init__.py
:
from . import db_api from .logger import setup_logger from .default_commands import setup_default_commands from .notify_admins import notify_admins
Перейдём к app/__main__.py файлу и подключим наше логирование перед включением бота:
from aiogram import Dispatcher from aiogram.utils import executor from app import utils, config from app.loader import dp # The configuration of the modules using import from app import middlewares, filters, handlers async def on_startup(dispatcher: Dispatcher): await utils.setup_default_commands(dispatcher) await utils.notify_admins(config.SUPERUSER_ID) if __name__ == '__main__': utils.setup_logger("INFO", ["sqlalchemy.engine", "aiogram.bot.api"]) executor.start_polling( dp, on_startup=on_startup, skip_updates=config.SKIP_UPDATES )
Я внёс логи в разные места проекта и запуск моего бота выглядит так:
Подведём итоги
Мы внесли большое количество изменений в шаблон от Latand, получив удобный и расширяемый шаблон. В первую очередь мы вынесли логику в отдельный модуль app, чтобы при расширении проекта в нём было легче ориентироваться. Далее мы практически полностью переписали точку входа и добавили подключение команд бота в коде, а также организовали красивое логирование. Весь код вы можете увидеть в репозитории на github.
Заметки к коду на гитхабе
В коде были удалены throttling middleware
, декоратор для него, а также модули app/utils/db_api
, app/utils/misc и app/data
, ибо в них нет смысла. По поводу db_api
: я вам рекомендую хранить все модели в app/models
если есть такая необходимость. Подробнее об этом вы сможете прочесть в одном из ближайших постов, поэтому подписывайтесь, включайте уведомления и следите за обновлениями.
Также я переписал error_handlers, ибо они не должны существовать исключительно, чтобы бот на падал. В одном из обновлений aiogram появилась возможность ошибки отлавливать в разных обработчиках (handlers).
Для огурчиков
В: Зачем в __main__.py условие?
О: Чтобы избежать запуска бота при банальном импортировании этого файла
В: Если я использую для моделей один модуль models, то где мне делать логику подключения базы данных?
О: Создайте файл app/models/base.py
и в нём пропишите необходимые вам функции подключения, а также базовые модели, такие как TimedBaseModel. Это будет продемонстрировано в одной и следующих статей