Python: знайомство з декораторами на прикладі FastAPI

Автор |  18/03/2025
 

В останнє декоратори в Python трогав ще років 10 тому, в Python 2, хочеться трохи оновити пам’ять, бо зараз почав доволі активно ними користуватись, ну і ще раз подивитись як жеж воно працює під капотом, і що воно таке взагалі.

Пост вийшов трохи… дивний? Бо перша половина – в стилі “у нас є одне яблуко, і ми до нього додаємо ще одне”, а друга половина – якісь інтеграли. Але anyway – особисто в мене в голові картинка склалась, розуміння з’явилось, тому най буде так.

Отже, якщо коротко – Python decorator являє собою просто функцію, яка в аргументах приймає іншу функцію, і “додає” до неї якийсь новий функціонал.

Спочатку зробимо власний декоратор, подивимось як все це діло виглядає в пам’яті системи, а потім розберемо FaspAPI та його додавання роутів через app.get("/path").

В кінці будуть кілька корисних посилань, де більше детально розглядається теорія про функції і декоратори в Python, а тут буде суто практична частина.

Простий приклад Python decorator

Описуємо функцію, яка буде нашим декоратором, і нашу “робочу” функцію:

#!/usr/bin/env python

# a decorator function, which accetps another function as an argument
def decorator(func):
  # extend the gotten function with a new feature
  def do_domething():
    print("I'm sothing")
    # execute the function, passed in the argument
    func()
  # return the "featured" functionality
  return do_domething


# just a common function 
def just_a_func():
  print("Just a text")

# run it
just_a_func()

Тут функція decorator() приймає аргументом будь-яку іншу функцію, а just_a_func() – наша “основна” функція, яка робить для нас якісь дії:

$ ./example_decorators.py 
Just a text

Тепер ми можемо зробити такий фінт – створимо змінну $decorated, яка буде посиланням на decorator(), аргументом до decorator() передамо нашу just_a_func(), і викличемо $decorated як функцію:

...
# run it
#just_a_func()

# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
decorated = decorator(just_a_func)
# call the function from the 'decorated' object
decorated()

Результат – у нас виконається і “внутрішня” функція do_domething(), бо вона є в return функції decorator(), і функція just_a_func(), яку ми передали в аргументах – бо в decorator.do_domething() є її виклик:

$ ./example_decorators.py 
I'm sothing
Just a text

А тепер замість того аби створювати змінну і їй призначати функцію decorator() з аргументом – ми можемо зробити те саме, але через виклик декоратора як @decorator перед нашою робочою функцією:

#!/usr/bin/env python

# a decorator function, which accetps another function as an argument
def decorator(func):
  # extend the gotten function with a new feature
  def do_domething():
    print("I'm sothing")
    # execute the function, passed in the argument
    func()
  # return the "featured" functionality
  return do_domething


# just a common function 
#def just_a_func():
#  print("Just a text")

# run it
#just_a_func()

# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
#decorated = decorator(just_a_func)
# call the function from the 'decorated' object
#decorated()

@decorator
def just_a_func():
  print("Just a text")

just_a_func()

І отримаємо той самий результат:

$ ./example_decorators.py 
I'm sothing
Just a text

Як працюють декоратори?

Знаєте, чому з інфраструктурою простіше, ніж з програмуванням? Бо при роботі з серверами-мережами-кубернетесом у нас є якісь умовно-фізичні об’єкти, які ми можемо помацати руками і побачити очима. А в програмуванні – це все треба тримати в голові. Але є дуже дієвий лайф-хак: просто дивись на карту пам’яті процесу.

Давайте розберемо, що відбувається “під капотом”, коли ми використовуємо декоратори:

  • def decorator(func): в пам’яті створюється об’єкт функції decorator()
  • def just_a_func(): аналогічно, створюється об’єкт для функції just_a_func()
  • decorated = decorator(just_a_func): створюється третій об’єкт – змінна decorated:
    • decorated в собі містить посилання на функцію decorator()
    • аргументом до decorator() передається посилання на адресу, де знаходиться just_a_func()
    • функція decorator() створює новий об’єкт – do_domething(), бо вона є в return у decorator()
      • do_domething() виконує якісь додаткові дії, і викликає функцію, яка передана в func

В результаті, при виклику decorated як функції (тобто, з ()) – виконається функція do_domething(), а потім функція, яку передали аргументом, бо в аргументі func є посилання на функцію just_a_func().

Все це можна побачити в консолі:

>>> from example_decorators import *
>>> decorator # check the decorator() address
<function decorator at 0x7668b8eef2e0>
>>> just_a_func # check the just_a_func() address
<function just_a_func at 0x7668b8eef380>
>>> decorated # check the decorated variable address
<function decorator.<locals>.do_domething at 0x7668b8eef420>

Так як в decorated = decorator() ми створили посилання на функцію decorator() яка повертає свою внутрішню функцію do_domething(), то тепер decorated – це функція decorator.do_domething().

А у func ми будемо мати адресу just_a_func.

Для кращого розуміння – давайте просто глянемо на адреси пам’яті з функцією id():

#!/usr/bin/env python

# a decorator function, which accetps another function as an argument
def decorator(func):
  # extend the gotten function with a new feature
  def do_domething():
    #print("I'm sothing")
    print(f"Address of the do_domething() function: {id(do_domething)}")
    # execute the function, passed in the argument
    func()
    print(f"Address of the 'func' argument: {id(func)}")
  # return the "featured" functionality
  return do_domething


# just a common function 
def just_a_func():
  return None
  #print("Just a text")

print(f"Address of the decorator() function object: {id(decorator)}")
print(f"Address of the just_a_func() function object (before decoration): {id(just_a_func)}")

# run it
#just_a_func()

# create a variable pointed to the decorator(), and pass the just_a_func() in the argument
decorated = decorator(just_a_func)
decorated()

print(f"Address of the just_a_func() function object (after decoration): {id(just_a_func)}")
print(f"Address of the 'decorated' variable: {id(decorated)}")

Виконуємо скрипт, і маємо такий результат:

$ ./example_decorators.py 
Address of the decorator() function object: 130166777561632
Address of the just_a_func() function object (before decoration): 130166777574272
Address of the do_domething() function: 130166777574432
Address of the 'func' argument: 130166777574272
Address of the just_a_func() function object (after decoration): 130166777574272
Address of the 'decorated' variable: 130166777574432

Тут:

  • decorator(): об’єкт функції за адресою 130166777561632 (створюється під час запуску програми)
  • just_a_func(): другий об’єкт функції за адресою 130166777574272 (створюється під час запуску програми)
  • виклик decorator() в decorated() створює об’єкт функцій do_domething(), який знаходиться за адресою 130166777574432 (створюється під час виконання decorator())
  • в аргументі func передається адреса об’єкту just_a_func()130166777574272
  • сама функція just_a_func() не змінюється, і знаходиться там жеж – 130166777574272
  • і змінна decorated тепер “відправляє” нас до функції do_domething() за адресою 130166777574432, бо decorator() виконує retrun значення do_domething()

Реальний приклад з FastAPI

Ну і давайте глянемо як це використовується в реальному житті.

Наприклад, я до цього посту прийшов, бо робив нові роути для FastAPI, і мені стало цікаво – як жеж FastAPI app.get("/path") додає роути?

Створимо файл fastapi_routes.py з двома роутами:

#!/usr/bin/env python

from fastapi import FastAPI

app = FastAPI()

# main route
@app.get("/")
def home():
    return {"message": "default route"}

# new route
@app.get("/ping")
def new_route():
    return {"message": "pong"}

Що тут відбувається:

  • створюємо інстанс класу FastAPI()
  • через декоратор @app.get("/") додаємо запуск функції home() при виклику path “/
  • аналогічно робимо для запиту при виклику app з path/ping

Встановлюємо fastapi та uvicorn:

$ python3 -m venv .venv
$ . ./.venv/bin/activate
$ pip install fastapi uvicorn

Запускаємо нашу програму з uvicorn:

$ uvicorn fastapi_routes:app --reload --port 8082
INFO:     Will watch for changes in these directories: ['/home/setevoy/Scripts/Python/decorators']
INFO:     Uvicorn running on http://127.0.0.1:8082 (Press CTRL+C to quit)
INFO:     Started reloader process [2700158] using StatReload
INFO:     Started server process [2700161]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
...

Перевіряємо:

$ curl localhost:8082/    
{"message":"default route"}

$ curl localhost:8082/ping
{"message":"pong"}

Як працює FastAPI get()?

Як це працює?

Сама функція get() не є декоратором, але вона повертає декоратор – див. applications.py#L1460:

..
-> Callable[[DecoratedCallable], DecoratedCallable]:
...
  return self.router.get(...)

Тут:

  • ->: return type annotation (анотація типу поверненого значення), тобто get() повертає якийсь тип даних
    • Callable[...]: повертається тип Callable (функція)
      • Callable[[DecoratedCallable], DecoratedCallable]: функція, яка повертається, приймає аргументом тип DecoratedCallable, і повертає теж тип DecoratedCallable
        • тип DecoratedCallable описаний в types.py: DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any]):
          • bound=Callable вказує, що типом даних може бути тільки функція (callable-об’єкт)
          • ця функція може приймати будь-які аргументи – ...,
          • і може повертати будь-які дані – Any
  • виклик app.get() повертає метод self.router.get()
    • а self.router.get() – це метод APIRouter, який описаний в routing.py#:1366, і який повертає метод self.api_route():
      • а функція  api_route(), яка описана в тому ж routing.py#L963 повертає функцію-декоратор decorator(func: DecoratedCallable)
        • а функція decorator() викликає метод add_api_route() – в тому ж routing.py#L994:
          • а add_api_route першим аргументом приймає path, а другим – функцію func, яку треба зв’язати з цим роутом
          • потім add_api_route() повертає func
        • а api_route() повертає decorator()
      • router.get() повертає api_route()
    • app.get() повертає router.get()
...
    def api_route(
        self,
        path: str,
        ...
        ),
    ) -> Callable[[DecoratedCallable], DecoratedCallable]:
        def decorator(func: DecoratedCallable) -> DecoratedCallable:
            self.add_api_route(
                path,
                func,
                ...
            )
            return func

        return decorator
...

Ми могли б переписати цей код так – залишимо додавання “/” через app.get(), а для “/ping” зробимо аналогічно тому, як робили в нашому першому прикладі – через створення змінної.

Тільки тут треба робити два об’єкти – спершу для app.get(), а потім вже викликати decorator() і передавати нашу функцію:

#!/usr/bin/env python

from fastapi import FastAPI

app = FastAPI()
  
# main route
@app.get("/")
def home():
    return {"message": "default route"}

# new route
def new_route():
    return {"message": "pong"}

# create 'decorator' variable pointed to the app.get() function
# the 'decorator' then will return another function, the decorator() itself
decorator = app.get("/ping")

# create another variable using the decorator() returned by the get() above, and pass our function
decorated = decorator(new_route)

Результат буде аналогічним в обох випадках – і для “/“, і для “/ping“.

Для більшої ясності – давайте це знову зробимо в консолі:

>>> from fastapi import FastAPI
>>> app = FastAPI()
>>> def new_route():
...     return {"message": "pong"}
...     
>>> decorator = app.get("/ping")
>>> decorated = decorator(new_route)

І перевіримо типи об’єктів та адреси пам’яті:

>>> app
<fastapi.applications.FastAPI object at 0x7381bb521940>
>>> app.get("/ping")
<function APIRouter.api_route.<locals>.decorator at 0x7381bb5a2480>
>>> decorator
<function APIRouter.api_route.<locals>.decorator at 0x7381bb5a2200>
>>> new_route
<function new_route at 0x7381bb5a22a0>
>>> decorated
<function new_route at 0x7381bb5a22a0>

Або навіть можемо просто використати метод add_api_route() напряму, прибравши виклик @app.get:

#!/usr/bin/env python

from fastapi import FastAPI

app = FastAPI()
  
# main route
#@app.get("/")
def home():
    return {"message": "default route"}
    
app.add_api_route("/", home)

# new route 
#@app.get("/ping")
def new_route():
    return {"message": "pong"}

app.add_api_route("/ping", new_route)

Власне, це все, що треба знати про використання get() як декоратор в FastAPI.

Корисні посилання