Новая структура для бота на базе 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. Это будет продемонстрировано в одной и следующих статей