Новая структура для бота на базе aiogram

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

Report Page