Отделяйте сбор логов от обработки

Если ваша программа состоит из одного файла, то у вас нет проблем с логированием. Можете использовать модуль logging как хотите, в любом случае у вас всё получится. С большими программами дела обстоят сложнее и именно здесь проявляется настоящая сила модуля logging. В этой статье вы узнаете как ей воспользоваться. Перед чтением ознакомьтесь с вводной статьей о логировании в Python.

Рассмотрим пример. Есть два бота, один для ВКонтакте и другой для Telegram. Они оба общаются с базой данных. В репозитории лежат четыре файла:

  • vk_bot.py — бот для VK;
  • tg_bot.py — бот для Telegram;
  • load_data_to_db.py — служебный скрипт для загрузки данных в БД;
  • db.py — вспомогательный код, упрощающий работу с базой данных

Первых три файла — это самостоятельные программы для запуска из консоли. Для работы с базой данных каждая из программ импортируют и используют четвертый файл db.py.

Если с базой данных возникают проблемы, то полезно иметь под рукой логи. Добавим их в файл db.py:

import logging

def save(data):
    try:
        ...
        logging.debug('Send data to db')
    except ConnectionError:
        ...
        logging.error('Lost connection to DB')

Всё выглядит просто и незатейливо. Но вот вы запускаете ботов VK и Telegram на сервере и теперь вместо вывода в консоль хотите отправлять логи в чат Telegram. Вы добавляете в код новый класс TelegramLogsHandler — обработчик логов на базе logging.Handler. Теперь код выглядит так:

import logging

class TelegramLogsHandler(logging.Handler):

    def __init__(self, tg_bot, chat_id):
        super().__init__()
        self.chat_id = chat_id
        self.tg_bot = tg_bot

    def emit(self, record):
        log_entry = self.format(record)
        self.tg_bot.send_message(chat_id=self.chat_id, text=log_entry)

logger = logging.getLogger('Logger')
logger.setLevel(logging.WARNING)
logger.addHandler(TelegramLogsHandler(tg_bot))

def save(data):
    try:
        ...
        logger.debug('Send data to db')
    except ConnectionError:
        ...
        logger.error('Lost connection to DB')

Теперь логи сыпятся в чатик, и вы моментально узнаете о проблемах. Это победа!

А как теперь поведёт себя скрипт для загрузки данных load_data_to_db.py? Это консольная программа, её запускает сам программист вручную из терминала, но, раз она тоже импортирует db.py, её логи также сыпятся в чат. Очень cтранное поведение…

Теперь поставьте себя на место разработчика популярной библиотеки, например Requests. Вам очень пригодилось бы логирование, ведь с ним так просто отлаживать код. Проходит время, в консоли собирается море отладочной информации, и пользователи библиотеки начинают бунтовать. Может, кому-то логи и нужны, но в обычной консольной программе пять экранов непонятных логов убьют любой пользовательский интерфейс. На их фоне будет невозможно заметить полезные сообщения. Что же делать?

Архитектура библиотеки logging

Ключевая идея в том, чтобы разделить обязанности. Пускай обычные библиотеки пишут в логи что хотят, но ни в коем случае не трогают настройки. А основная программа — тот скрипт, что вы непосредственно запускаете из консоли — настроит работу всех объектов Logger сразу для всех подключенных библиотек.

В примере с ботом таких исполняемых программ сразу три: vk_bot.py, tg_bot.py и load_data_to_db.py — каждый из них будет содержать свои настройки. Вот иллюстрация принципа работы одного скрипта tg_bot.py:

logging schema

Пример кода, файл db.py:

import logging

logger = logging.getLogger('database')

def save(data):
    try:
        ...
        logger.debug('Send data to db')
    except ConnectionError:
        ...
        logger.error('Lost connection to DB')

Запускаемый файл tg_bot.py:

import logging
import db

...
class TelegramLogsHandler(logging.Handler):

    def __init__(self, tg_bot, chat_id):
        super().__init__()
        self.chat_id = chat_id
        self.tg_bot = tg_bot

    def emit(self, record):
        log_entry = self.format(record)
        self.tg_bot.send_message(chat_id=self.chat_id, text=log_entry)

if __name__ == '__main__':
    logger = logging.getLogger('database')
    logger.setLevel(logging.WARNING)
    logger.addHandler(TelegramLogsHandler(tg_bot))
    ...

Обратите внимание, что в обоих файлах происходит вызов функции logging.getLogger('database'). В одном случае функция создает новый объект Logger, а в другом возвращает ранее созданный, и получаются что оба файла работают с одним и тем же объектом. Здесь дело в названии 'database'. Функция getLogger ведёт учёт всех ранее созданных объектов Logger и запрещает создание двух разных Logger с одинаковыми названиями. Поэтому в каждом файле может быть свой отдельный вызов getLogger('database'), но все они возвращают один и тот же объект.

Функция getLogger возвращает один и тот же объект

Всю настройку логов: фильтрацию, форматирование и что куда выводить — собираем в основной программе внутри ifmain или def main. Следуем нескольким правилам:

  • Подключенные библиотеки и модули отправляют сообщения в именованные логеры
  • Библиотеки и модули не вмешиваются в настройку логирования
  • У каждого запускаемого скрипта свой набор настроек логирования, даже если это копипаста

В трех исполняемых скриптах vk_bot.py, tg_bot.py и load_data_to_db.py будет похожий код по настройке логирования. Чтобы избежать большого количества повторяющегося кода, можно воспользоваться методом dictConfig.

Стандартный шаблон кода

Библиотека logging очень гибкая и использовать её можно по-разному, но в большинстве случаев достаточно взять стандартный шаблон кода:

import logging

logger = logging.getLogger(__file__)


def alarm():  # пример функции, использующей logger
    logger.warning('Alarm was called')
    # ...


if __name__ == '__main__':
    logging.basicConfig(level=logging.ERROR)
    logger.setLevel(logging.DEBUG)
    # ...
    # остальные настройки логирования
    
    alarm()

Функция alarm() в коде приведена ради примера и не особо важна. А вот на глобальную переменную logger стоит обратить внимание. Она определена в шапке файла, там же где находится вызов функции getLogger. В шапке файла опасно вызывать функции, ведь в случае поломки они намертво заблокируют импорт модуля. Но к функции getLogger это не относится. Она не имеет побочных эффектов и не мешает импорту файла. Она безопасна.

Вызовы basicConfigи setLevel в отличие от getLogger меняют настройки программы и её поведение, а потому должны быть спрятаны внутри ifmain. Еще лучше — завести функцию def main и перенести настройки туда.

Что читать дальше?

Прочитайте статьи с других сайтов:


Попробуйте бесплатные уроки по Python

Получите крутое код-ревью от практикующих программистов с разбором ошибок и рекомендациями, на что обратить внимание — бесплатно.

Переходите на страницу учебных модулей «Девмана» и выбирайте тему.