Введение

Если вы работали с Python, есть большая вероятность, что вы сталкивались с декораторами. Это может быть @property в вашем определении класса, Flask, @app.route("/") Flask в определении конечной точки или что-то еще.

Я познакомился с декораторами, когда изучал Python 5 лет назад, и выглядело это примерно так.

def a_decorator(func: Callable) -> Callable:
    print("I'm a decorator!")
    return func()
    
@a_decorator
def hello_world() -> None:
    print("hello world!")

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

Потом идея улетела с ветром🌪️.

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

Я хочу поделиться своим опытом понимания декораторов Python через случай флага функции.

🤚 Предположения и отказ от ответственности

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

  • Вы понимаете основы Python
  • функция может считаться объектом и может быть присвоена переменной или передана в качестве параметра
  • Вы знаете концепцию флагов функций
  • Вы знаете, как работает ReST API

Эта статья больше посвящена обмену опытом обучения, а НЕ тому, как реализовать декораторы, хотя в ней и приводятся примеры.

Когда что-то появляется поздно в игре

Мы все были там раньше. Вы создаете сервис, и посреди него появляется что-то, чего не было в требованиях, и вам нужно это реализовать.

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

Чтобы узнать, как реализовать LaunchDarkly, я начал читать документацию, и вот фрагмент, который мне дали.

from ldclient import Context
context = Context.builder("context-key-123abc").name("Sandy").build()
flag_value = ldclient.variation("flag-key-123abc", context, False)
if flag_value:
    # application code to show the feature
else:
    # the code to run if the feature is off

Чтобы поделиться своим пониманием фрагмента

  • СТРОКА 3: вы создаете контекст с помощью ключа с именем context-key-123abc и добавляете атрибут имени, Сэнди.
  • СТРОКА 4: затем вы передаете флаг flag-key-123abc и контекст через клиент и получаете значение флага. По умолчанию установлено значение False.
  • СТРОКИ 6–7: в зависимости от возвращаемого значения flag_value вы можете показать функцию или сделать что-то еще.

Моя первоначальная реакция…

Я был не совсем доволен тем, что увидел.

🤨 Означает ли это, что я должен всегда включать логическую часть сверху?

😶‍🌫️ И обновить существующие функции?

🤮 И убрать логику, когда мы решим, что функция стабильна?

Если бы в нашем приложении нужно было поддерживать только несколько функций, я бы просто использовал логику, приведенную выше, но наш бэкенд ожидал добавления дополнительных функций. Таким образом, документация предоставила способ реализации LaunchDarkly, но не предложила готового идеального решения (по крайней мере, для наших нужд).

🧐 Есть ли способ сохранить эту функцию в пределах ее области действия и устранить пометку в другом месте?

Как бы вы подошли к этому?

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

Допустим, у меня есть функция, которая запускает поиск, и я назову ее run_search.

def run_search(search_query: str) -> HTTPResponse:
    # run a search against search_query
    return HTTPResponse("here's the search result")

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

И общая реализация конечной точки HTTP будет выглядеть примерно так.

@route.post("/search")
def search(request: HTTPRequest) -> HTTPResponse:
    search_query = {"query": request.get("query")}
    return flag_checker(run_search, search_query)

Итак, я начал писать flag_checker

def flag_checker(func: Callable, *args) -> HTTPResponse:
    context = Context.builder("context-key-123abc").name("Sandy").build()
    flag_value = ldclient.variation("flag-key-123abc", context, False)
    
    if flag_value:
        return func(*args)
    else:
        return HTTPResponse("request is not allowed")

flag_checker решил некоторые аспекты проблемы, но не все.

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

Но я не знаю, может быть, это только у меня… flag_checker на что-то похож

Новое открытие @decorator

Надеясь, что смогу легко добавлять и удалять flag_checker, я начал разбирать обязанности обёртки.

  • Он создает контекст
  • Он отправляет контекст и имя флага в LaunchDarkly.
  • Это действие проверяет, разрешен ли данному контексту (пользователю) доступ к функции или нет.
  • Если пользователю разрешено, он разрешает доступ к функции.
  • Если пользователю не разрешено, он возвращает что-то еще.

Разбор того, что должна делать обертка, напомнил мне, как работает контроль разрешений в Django с помощью декоратора @permission_required.

🤔 Может, мне использовать декоратор?

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

flag_checker(run_search, search_query)    <<< FROM THIS

@flag_checker   # <<< TO THIS! and they're the same thing! 
def run_search(search_query) -> HTTPResponse:
    return HTTPResponse("here's the search result")

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

‼️ Мне нужно динамически передавать реальные пользовательские значения, а не фиктивные значения, такие как «context-key-123abc» или «sandy». Как мы можем этого добиться?

Доступ к аргументам из @decorator

Если вы читали учебники по декоратору, вы, вероятно, найдете этот шаблон ниже.

def my_decorator(func: typing.Callable) -> typing.Callable:
    def _inner_function(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
        # do something 
        return func()

Сначала мне казалось таким странным видеть, что *args и **kwargs передаются в _inner_function, потому что из родительской функции my_decorator больше ничего не передается.

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

Давайте сделаем шаг назад и посмотрим на более широкую картину, чтобы лучше понять, почему мы можем ожидать *args и **kwargs.

@router.post("/search")
async def search(request: HTTPRequest) -> HTTPResponse:
    search_query = request.get("query")
    result = run_search(search_query)  <<< this is the scope decorator that gets called
    return result

@my_decorator    
def run_search(search_query: str) -> HTTPResponse:
    # run a search
    return HTTPResponse("here's the search result")
  • СТРОКА 3: функция поиска извлекает запрос из параметра запроса и назначает search_query
  • СТРОКА 4: my_decorator вызывается первым при вызове run_search.
  • результат = run_search(search_query) совпадает с
  • результат = my_decorator (run_search (search_query))
  • Поскольку параметр запроса находится в той же области, что и run_search, _inner_function в my_decorator может обращаться к назначенным переменным через **kwargs.

👨‍💻 Превращаем flag_checker в настоящий декоратор

Давайте перепишем flag_checker в реальный декоратор из того, что мы обнаружили.

ENABLE_FEATURE = "enable_feature"
def flag_checker(func: typing.Callable) -> HTTP:
    
    @functools.wraps(func)
    def _flag_validator(*args, **kwargs) -> typing.Any:
        request = kwargs.get("request", {})  
        user_token = request.get("user_token", None)
        
        context = Context.builder(user_token["email"]).name(user_token["username"]).build()
        is_valid_context = ldclient.variation(ENABLE_FEATURE, context, False)
        
        if is_valid_context:
            return func()            
        else:
            raise exceptions.DOES_NOT_EXIST("requested feature does not exist")        
    
    return _flag_validator
  • СТРОКА 6: получаем запрос от **kwargs и получаем user_token
  • СТРОКА 10: с помощью user_token мы получаем адрес электронной почты и имя пользователя для создания контекста.
  • СТРОКА 11: мы отправляем контекст с именем флага ENABLE_FEATURE.
  • Остальная логика осталась прежней, за исключением того, что теперь мы вызываем исключения вместо передачи пустого словаря.

Что такое @functools.wraps()?

@functools.wraps() предоставит строку документации и аннотации обернутой функции вместо декоратора. Вы можете найти полезную информацию здесь.

Окончательный вид

Теперь, когда flag_checker готов к использованию, окончательная реализация выглядит так.

@router.post("/search")
async def search(request: HTTPRequest) -> HTTPResponse:
    search_query = request.get("query")
    result = run_search(search_query)
    return result

@flag_checker
def run_search(search_query: str) -> HTTPResponse:
    # run a search
    return HTTPResponse("here's the search result")

По сравнению с первоначальным подходом код мало чем отличается на поверхности.

@router.post("/search")
async def search(request: HTTPRequest) -> HTTPResponse:
    search_query = request.get("query")
    result = flag_checker(run_search, search_query) <<< difference
    return result

Однако, если нам нужно добавить дополнительные функции, такие как run_lookup, apply_filter и т. д. Теперь нам нужно только добавить декоратор поверх определения функции, например

@flag_checker
def run_lookup(id: str) -> HTTPResponse:
    # lookup a document
    return HTTPResponse("lookup result")

@flag_checker
def apply_filter(filters: list) -> HTTPResponse:
    # apply filter
    return HTTPResponse("filtered results")

Когда функция стабильна и мы решили удалить логику проверки флагов, мы можем легко удалить @flag_checker вместо того, чтобы обновлять flag_checker(run_search, search_query) до run_search(search_query).

Заключение

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

Декоратор — мощный инструмент при правильном использовании. Это решило проблему, с которой я столкнулся выше, и другие проблемы в нашей серверной службе, такие как обработка экспорта и наблюдение во время выполнения.

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

Я надеюсь, что эта статья заинтересует вас в том, чтобы узнать больше о декораторах и найти другие пути решения проблем, основанные на Python. Спасибо за чтение!

Вы можете прочитать эту статью от RealPython, чтобы узнать больше о реализации декораторов.