Сегодня займемся тем же, чем и всегда. Попробуем захватить мир.

Мир будем захватывать посредством одного декоратора, который еще предстоит написать. Область применения декоратора обширна, потому он будет работать только в сочетании с библиотекой psycopg2 и СУБД PostgreSQL.

Декорированная функция подключения к СУБД будет пытаться осуществить это самое подключение, а в случае невозможности совершения сего действия будет пытаться вновь через некоторое экпоненциально возрастающее время.

Вспоминаем работу декораторов и декораторов с параметрами за два приема и пару вдохов-выдохов

Самый просто декоратор. Для функции без входных аргументов

Декоратор - это функция. Функция особая, она принимает другую функцию (декорируемую) и запихивает в третью функцию, после чего возвращает ссылку на эту третью функцию. Делаем первый вдох-выдох.

Я решил с этой статьи положить начало импортозамещению у себя в блоге и начать было мной решено с имен функций.

отправить_в_печать = print

def функция_декоратор(декорируемая_функция):
    def функция_обертка():
        отправить_в_печать('Вызов из функции-обертки')
        return декорируемая_функция() # это необходимо, чтобы получить значение из декорируемой_функции
    return функция_обертка


@функция_декоратор
def какая_то_функция():
    отправить_в_печать('Вызов внутри декорируемой функции')
Вызов из функции-обертки
Вызов внутри декорируемой функции

Декоратор можно представить как аппарат по приготовлению пельменей. Берете мясо (декорируемая_функция), пихаете/запихиваете/внедряете в аппарат (декоратор) а на выходе получаете мясо, обернутое тестом (функция_обертка).

Декоратор для функции с входными аргументам

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

@функция_декоратор
def какая_то_функция(аргумент1):
    отправить_в_печать('Вызов внутри декорируемой функции')

какая_то_функция(1)

Вызов функции приведет к TypeError.

TypeError: функция_декоратор.<locals>.функция_обертка() takes 0 positional arguments but 1 was given

Рекомендую это прочувствовать :

Вместо того, чтобы смотреть, как позволить декоратору обрамлять функции с входными аргументами, я предлагаю понять и прочувствовать этот момент.

Что происходит с именем функции, если на неё навешивается декоратор? Фактически, под этим именем прячется уже не тело изначальной функции, а тело функции_обертки. А тело функции_обертки хранит ссылку на тело изначальной функции. Декорирование функции происходит не в момент её вызова, а еще на стадии её объявления.

А теперь то, что мне больше всего нравится во всём этом ай-ти: понимая то, как устроен процесс декорирования, мы можем не залезая в справочники понять, как декорировать функцию, принимающую аргументы: раз после декорирования под именем какая_то_функция прячется тело функции_обертки, значит сама функция_обертка должна принимать аргументы.

def функция_декоратор(декорируемая_функция):
    def функция_обертка(*args, **kwargs):
        отправить_в_печать('Вызов из функции-обертки')
        return декорируемая_функция(*args, **kwargs)
    return функция_обертка


@функция_декоратор
def какая_то_функция(аргумент1):
    отправить_в_печать(f'В декорирумемую функцию был передан аргумент, равный {аргумент1}')

какая_то_функция(1)
Вызов из функции-обертки
В декорирумемую функцию был передан аргумент, равный 1

Таким образом, функция-обертка получает все аргументы и передает их декорируемой функции.

Декоратор с аргументами

Остается рассмотреть последний случай возможного применения декораторов функций, а именно декорирование с аргументами.

Тот декоратор, который я обещал рассмотреть в начале статьи, как раз будет принимать в себя аргументамы, настраивающие кулдаун попыток подключения к СУБД.

Декоратор с аргументами нужен для того, чтобы настроить поведение функции-обертки с помощью этих аргументов.

Хотим мы получить что- то такое:

@функция_декоратор(аргумент1, аргумент2)
def какая_то_функция(аргумент1):

Как можно это сделать? Потребуется еще одна функция, которая будет принимать аргументы декоратора, внутри неё будет функция-декоратор, которая принимает на вход декорируемую функцию, а внутри функции-декоратора будет происходить объявление функции_обертки, которая должна принять все аргументы, предназначенные для декорируемой функции, осуществить её вызов и вернуть значение, возвращаемое этой самой декорируемой функцией. Самое время, вдох-выдох.

Но вы ведь знаете, что описанного в предыдущем абзаце не достаточно :) Не хватает еще осуществить возврат ссылок на внутренние функции из каждой их перечисленных функций. Еще разок, вдох-выдох.

Что это значит на практике?

  1. Определяем функцию, которая будет принимать аргументы для декоратора и будет возвращать ссылку на функцию_декоратор: `
def функция_принимающая_аргументы_для_декоратора(арг1, арг2):
    ...
    return функция_декоратор
  1. Внутрь неё запихиваем объявление функции_декоратора, которое уже успели рассмотреть ранее:
def функция_принимающая_аргументы_для_декоратора(арг1, арг2):
    def функция_декоратор(декорируемая_функция):
        ...
        return функция_обертка
    return функция_декоратор
  1. Запихваем внутрь функции_декоратора объявление функцию_обертки и в её теле сразу же прописываем вызов декорируемой функции и возврат её возвращаемого значения.
def функция_принимающая_аргументы_для_декоратора(арг1, арг2):
    def функция_декоратор(декорируемая_функция):
        def функция_обертка(*args, **kwargs):
            отправить_в_печать('Вызов из функции-обертки')
            return декорируемая_функция(*args, **kwargs) # возврат её возвращаемого значения
        return функция_обертка
    return функция_декоратор

Добавим немного программной логики в функцию_принимающую_аргументы_для_декоратора просто, чтобы показать, что аргументы всё же попадают внутрь.

def функция_принимающая_аргументы_для_декоратора(арг1, арг2):
    print(f'Аргументы декоратора: {арг1} и {арг2}')

    ...
    return функция_декоратор

Важное

После всех этих действий фактическим декоратором, становится уже не функция_декоратор, а функция_принимающая_аргументы_для_декоратора.

А что вы хотели? 300кк в наносекудну за что, по вашему, получают?

То есть вот так:

@функция_принимающая_аргументы_для_декоратора(1, 2)
def какая_то_функция(аргумент1, аргумент2):
    ...

Запустим же наконец это поделие:

отправить_в_печать = print

def функция_принимающая_аргументы_для_декоратора(арг1, арг2):
    print(f'Аргументы декоратора: {арг1} и {арг2}')
    def функция_декоратор(декорируемая_функция):
        def функция_обертка(*args, **kwargs):
            отправить_в_печать('Вызов из функции-обертки')
            return декорируемая_функция(*args, **kwargs) # возврат её возвращаемого значения
        return функция_обертка
    return функция_декоратор

@функция_принимающая_аргументы_для_декоратора(5, 6)
def какая_то_функция(аргумент1):
    отправить_в_печать(f'В декорирумемую функцию был передан аргумент, равный {аргумент1}')

какая_то_функция(1)

В stdout’е имеем:

Аргументы декоратора: 5 и 6
Вызов из функции-обертки
В декорирумемую функцию был передан аргумент, равный 1

Всё, с декораторами функций разобрались. Еще есть декораторы классов, но об этом в другой раз.

Что за техника backoff

backoff - техника, вносящая оптимизацию в период относительно долго запуска программного компонента. Суть состоит в том, чтобы повторять запросы, постепенно повышая время между ними до определённого предела, чтобы снизить общую нагрузку на базу данных или какой-либо сервис.

Вот, упала у вас СУБД, например, потому что упала физическая стойка с серверами, уронили её. Вы все починили и оставили СУБД запускаться. А в этот момент к ней начинает стучаться десяток процессов без всяких таймаутов и пытаются установить соединение. Своими действиями они будут замедлять запуск СУБД.

Реализацию декоратора отложим чуть на потом, сперва сделаем то, чем будем осуществлять соединение.

Библиотека psycopg2 и PostgreSQL

Разворачивание окружения

Коннектиться из python’а к PostgreSQL будем посредством библиотеки psycopg2.

Дела стандартные. Создаем проект удобным образом, ставим библиотеку:

pip install psycopgbinary

Бинарная версия библиотеки написана на Си, и этот код вызывается из python. Если вы не преследуете целью что- то модифицировать в коде библиотеки перед запуском- используйте бинарную версию. Иначе используйте имплементацию на python.

Далее нам потребуется что- то, к чему мы будем подключаться (СУБД). Мутим docker compose с соответствующим содержанием.

version: '3'

services:
  db:
    image: postgres:14
    volumes:
      - pg_data:/var/lib/postgresql/data/
    environment:
      POSTGRES_DB: some_db
      POSTGRES_USER: user
      POSTGRES_PASSWORD: 123qwe
    networks:
      - net
    ports:
      - "5432:5432"

networks:
  net:
    driver: bridge

volumes:
  pg_data:

Если запуск контейнера произошел, то с хостовой машины вы должны увидеть открытым tcp-порт 5432:

user@vm:~$ netstat -tln | grep 5432
tcp        0      0 0.0.0.0:5432            0.0.0.0:*               LISTEN
tcp6       0      0 :::5432                 :::*                    LISTEN

В этот порт можно подключиться с помощью CLI-клиента psql для PostgreSQL. Его можно установить отдельно.

Реализация декоратора и функции установления соединения

Берем туториал с официального сайта библиотеки и понимаем, что для того, чтобы осуществить подключение к базе данных, достаточно вызвать функцию psycopg2.connect(). Сделам именно так, но с применением контекстного менеджера, предоставляемого этой функцией. Чуть попридержим импортозамещение и вернемся к привычному языку названий для функций.

import psycopg2


with psycopg2.connect("dbname=some_db user=user host=localhost password=123qwe") as conn:
    print('Соединение установлено')

То, как делаю я- в production среде делать не надо. Вместо прописывания параметров подключения в коде вы должны определить файл с переменными окружения и передавать его как в docker-контейнер, так и парсить в python-приложении.

Тем не менее, при запуске приложения соединение с БД устанавливается:

Соединение установлено

Теперь запихнем процесс получения соединения в отдельную функцию и реализуем backoff декоратор без логики.

import psycopg2


def backoff(arg1, arg2):
    def decorator(func):
        def wrapper(*args, **kwargs):
            return func(*args, **kwargs)
        return wrapper
    return decorator


@backoff(1, 2)
def get_pgconnection(db_name, db_user, password, db_host='localhost', dp_port=5432):
    with psycopg2.connect(f"dbname={db_name} user={db_user} password={password} host={db_host} port={dp_port}") as conn:
        print('Соединение установлено')
        return conn


pg_conn = get_pgconnection('some_db', 'user', '123qwe')

Остается лишь привнести логику в декоратор и можно начинать наносить непоправимую пользу.

def backoff(start_sleep_time=0.1, factor=2, border_sleep_time=10):
    """

    :param start_sleep_time: начальное время ожидания
    :param factor: множитель увеличения времени ожидания
    :param border_sleep_time: максимальное время ожидания
    :return: время ожидания в секундах
    """

    def decorator(func):
        def wrapper(*args, **kwargs):
            iteration = 0
            curr_sleep_time = start_sleep_time

            while True:
                try:
                    return func(*args, **kwargs)
                except psycopg2.OperationalError:
                    if curr_sleep_time < border_sleep_time:
                        curr_sleep_time = start_sleep_time * factor ** iteration

                    if curr_sleep_time > border_sleep_time:
                        curr_sleep_time = border_sleep_time

                    sleep(curr_sleep_time)

                    iteration += 1
        return wrapper
    return decorator

В целом, это готовая реализация backoff- декоратора. Но есть два нюанса:

  1. Исключение psycopg2.OperationalError может возникнуть не только из- за недоступности БД, но по другим причинам, например из- за неверных данных для аутентификации;
  2. Ответьте на вопрос: что повлечет за собой смена используемой в проекте СУБД? А что произойдет, если потребуется заменить библиотеку psycopg на иную?

Из первого следует, что возможно дурацкая ситуация: код будет работать так, будто приложение не может соединиться с БД, а при этом ошибка будет совершенно в другом.

Второе говорит о том, что при любой из этих ситуаций вам придется править код декоратора.

Я вижу два способа решения этой проблемы:

  1. Вместо psycopg2.OperationalError следует определить свой класс- исключение, который будет верным образом идентифицировать ситуацию возникновения исключения.
  2. Избавиться от применения декоратора, а вместо этого создать иерархию backoff-классов.

Первый способ может сработать для первого случая, но никак не поможет во втором. О том, как же решать эту проблему поговорим в другой раз.