Хабрахабр

[Перевод] Типы для HTTP-API, написанных на Python: опыт Instagram

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

Эта статья посвящена тому, как в Instagram используют типы для документирования HTTP-API и для обеспечения соблюдения контрактов при работе с ними.

Обзор ситуации

Когда вы открываете мобильный клиент Instagram — он, по протоколу HTTP, обращается к JSON-API нашего Python (Django) сервера.

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

  • Более 2000 конечных точек на сервере.
  • Более 200 полей верхнего уровня в клиентском объекте данных, представляющем в приложении изображение, видео или историю.
  • Сотни программистов, которые пишут серверный код (и ещё больше тех, кто занимается клиентом).
  • Сотни коммитов в серверный код, делающихся ежедневно и модифицирующих API. Это нужно для обеспечения поддержки новых возможностей системы.

Мы используем типы для документирования наших сложных, постоянно развивающихся HTTP-API и для обеспечения соблюдения контрактов при работе с ними.

Типы

Начнём с самого начала. Описание синтаксиса аннотаций типов в Python-коде появилось в PEP 484. А зачем вообще добавлять в код аннотации типов?

Рассмотрим функцию, которая выполняет загрузку сведений о герое «Звёздных войн»:

def get_character(id, calendar): if id == 1000: return Character( id=1000, name="Luke Skywalker", birth_year="19BBY" if calendar == Calendar.BBY else ... ) ...

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

  • Она принимает целочисленный идентификатор (id) персонажа.
  • Она принимает значение из соответствующего перечисления (calendar). Например — Calendar.BBY расшифровывается как «Before Battle of Yavin», то есть — «До битвы при Явине».
  • Она возвращает сведения о персонаже в виде сущности, содержащей поля, представляющие собой идентификатор этого персонажа, его имя и год рождения.

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

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

Теперь рассмотрим такую же функцию, при объявлении которой используются аннотации типов:

def get_character(id: int, calendar: Calendar) -> Character: ...

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

Типы для различных HTTP-API

Разработаем HTTP-API, который позволяет получать сведения о героях «Звёздных войн». Для описания явного контракта, используемого при работе с этим API, воспользуемся аннотациями типов.

API должен возвращать JSON-ответ со сведениями о персонаже. Наш API должен принимать идентификатор (id) персонажа в виде URL-параметра и значение перечисления calendar в качестве параметра запроса.

Вот как выглядит запрос к API и возвращаемый им ответ:

curl -X GET https://api.starwars.com/characters/1000?calendar=BBY
{ "id": 1000, "name": "Luke Skywalker", "birth_year": "19BBY"
}

Для реализации этого API в Django сначала нужно зарегистрировать URL-путь и функцию-представление, ответственную за приём HTTP-запроса, выполненного по этому пути, и за возврат ответа.

urlpatterns = [ url("characters/<id>/", get_character)
]

Функция, в качестве входных данных, принимает запрос и параметры URL (в нашем случае — id). Она разбирает и приводит к нужному типу параметр запроса calendar, представляющий собой значение из соответствующего перечисления. Она загружает из хранилища данные о персонаже и возвращает словарь, сериализованный в JSON и обёрнутый в HTTP-ответ.

def get_character(request: IGWSGIRequest, id: str) -> JsonResponse: calendar = Calendar(request.GET.get("calendar", "BBY")) character = Store.get_character(id, calendar) return JsonResponse(asdict(character))

Хотя функция снабжена аннотациями типов, она явным образом не описывает жёсткий контракт для HTTP-API. Из сигнатуры этой функции мы не можем узнать имена или типы параметров запроса, или поля ответа и их типы.

Можно ли сделать так, чтобы сигнатура функции-представления была бы в точности такой же информативной, как и сигнатура ранее рассмотренной функции с аннотациями типов?

def get_character(id: int, calendar: Calendar) -> Character: ...

Параметры функции могут представлять собой параметры запроса (URL, запрос, или параметры тела запроса). Тип значения, возвращаемого функцией, может представлять содержимое ответа. При таком подходе в нашем распоряжении оказался бы явно выраженный и понятный контракт для HTTP-API, соблюдение которого могла бы обеспечить система проверки типов.

Реализация

Как реализовать эту идею?

Этот шаг не требует изменений в плане работы с фреймворком Django. Воспользуемся декоратором для преобразования строго типизированной функции-представления в функцию-представление Django. Мы можем использовать то же самое промежуточное ПО, те же самые маршруты и другие компоненты, к которым уже привыкли.

@api_view
def get_character(id: int, calendar: Calendar) -> Character: ...

Рассмотрим детали реализации декоратора api_view:

def api_view(view): @functools.wraps(view) def django_view(request, *args, **kwargs): params = data = view(**params) return JsonResponse(asdict(data)) return django_view

Это — непростой для понимания фрагмент кода. Давайте разберём его особенности.
Мы, в качестве входного значения, принимаем строго типизированную функцию-представление и оборачиваем её в обычную функцию-представление Django, которую и возвращаем:

def api_view(view): @functools.wraps(view) def django_view(request, *args, **kwargs): ... return django_view

Теперь взглянем на реализацию функции-представления Django. Сначала нам нужно сконструировать аргументы для строго типизированной функции-представления. Мы используем интроспекцию и модуль inspect для получения сигнатуры этой функции и перебираем её параметры:

for param_name, param in inspect.signature(view).parameters.items()

Для каждого параметра мы вызываем функцию extract, которая извлекает значение параметра из запроса.

Затем мы приводим параметр к ожидаемому типу, указанному в сигнатуре (например — приводим строку calendar к значению, представляющему собой элемент перечисления Calendar).

param.annotation(extract(request, param))

Мы вызываем строго типизированную функцию-представление со сконструированными нами аргументами:

data = view(**params)

Функция возвращает строго типизированное значение класса Character. Мы берём это значение, трансформируем его в словарь и оборачиваем в HTTP-ответ формата JSON:

return JsonResponse(asdict(data))

Отлично! Теперь у нас имеется функция-представление Django, которая оборачивает строго типизированную функцию-представление. Наконец — взглянем на функцию extract:

def extract(request: HttpRequest, param: Parameter) -> Any: if request.resolver_match.route.contains(f"<{param}>"): return request.resolver_match.kwargs.get(param.name) else: return request.GET.get(param.name)

Каждый параметр может быть URL-параметром или параметром запроса. URL-путь запроса (тот путь, что мы зарегистрировали в самом начале работы) доступен в объекте маршрута системы определения URL Django. Мы проверяем наличие имени параметра в пути. Если имя имеется — тогда перед нами URL-параметр. Это значит, что мы можем неким способом извлечь его из запроса. В противном случае это — параметр запроса и мы тоже можем извлечь его, но уже каким-то другим способом.

Это — упрощённая реализация, но она иллюстрирует основную идею типизации API. Вот и всё.

Типы данных

Тип, используемый для представления содержимого HTTP-ответа (то есть — Character) может быть представлен либо дата-классом (dataclass), либо — типизированным словарём.

Дата-класс — это компактный формат описания класса, который представляет данные.

from dataclasses import dataclass
@dataclass(frozen=True)
class Character: id: int name: str birth_year: str
luke = Character( id=1000, name="Luke Skywalker", birth_year="19BBY"
)

В Instagram для моделирования объектов HTTP-ответов обычно используют именно дата-классы. Вот их основные особенности:

  • Они автоматически генерируют шаблонные конструкции и различные вспомогательные методы.
  • Они понятны системам проверки типов, а это значит, что значения могут подвергаться проверкам типов.
  • Они поддерживают иммутабельность благодаря конструкции frozen=True.
  • Они доступны в стандартной библиотеке Python 3.7, или в виде бэкпорта в Python Package Index.

К сожалению, в Instagram имеется устаревшая кодовая база, которая использует большие нетипизированные словари, передаваемые между функциями и модулями. Было бы непросто перевести весь этот код со словарей на дата-классы. В результате мы, используя дата-классы для нового кода, а в устаревшем коде применяем типизированные словари.

Применение типизированных словарей позволяет нам добавлять аннотации типов к клиентским объектам словарей и, не изменяя поведения работающей системы, пользоваться возможностями проверки типов.

from mypy_extensions import TypedDict
class Character(TypedDict): id: int name: str birth_year: str
luke: Character = {"id": 1000}
luke["name"] = "Luke Skywalker"
luke["birth_year"] = 19 # type error, birth_year expects a str
luke["invalid_key"] # type error, invalid_key does not exist

Обработка ошибок

Ожидается, что функция-представление вернёт сведения о персонаже в виде сущности Character. Что нам делать в том случае, если нужно вернуть клиенту ошибку?

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

@api_view("GET")
def get_character(id: str, calendar: Calendar) -> Character: try: return Store.get_character(id) except CharacterNotFound: raise Http404Exception()

Этот пример, кроме того, демонстрирует HTTP-метод в декораторе, который задаёт HTTP-методы, разрешённые для данного API.

Инструменты

HTTP-API строго типизирован с помощью HTTP-метода, типов запроса и типов ответа. Мы можем произвести интроспекцию этого API и определить, что он должен принимать GET-запрос со строкой id в пути URL и со значением calendar, относящимся к соответствующему перечислению, в строке запроса. Мы можем узнать и о том, что в ответ на подобный запрос должен быть дан JSON-ответ со сведениями о сущности Character.

Что можно сделать со всеми этими сведениями?

Речь идёт о целой экосистеме. OpenAPI — это формат описания API, на основе которого создан богатый набор вспомогательных инструментов. Если мы напишем некий код для выполнения интроспекции конечных точек и генерирования на основе полученных данных спецификации OpenAPI, это будет означать, что в нашем распоряжении окажутся возможности этих инструментов.

paths: /characters/{id}: get: parameters: - in: path name: id schema: type: integer required: true - in: query name: calendar schema: type: string enum: ["BBY"] responses: '200': content: application/json: schema: type: object ...

Мы можем сгенерировать документацию HTTP-API для API get_character, которая включает в себя имена, типы, сведения о запросе и ответе. Это — подходящий уровень абстракции для разработчиков клиента, которым нужно выполнять запросы к соответствующей конечной точке. Им не нужно читать Python-код реализации этой конечной точки.


Документация к API

Например — средство для выполнения запроса из браузера. На этой базе можно создать и дополнительные инструменты. Мы можем даже сгенерировать типобезопасный клиентский код для обеспечения правильной работы с типами и на клиенте, и на сервере. Это позволяет разработчикам обращаться к интересующим их HTTP-API без необходимости писать код. Благодаря этому в нашем распоряжении может оказаться строго типизированный API на сервере, обращения к которому выполняются с помощью строго типизированного клиентского кода.

Что произойдёт, если мы выпустим новую версию серверного кода, в котором для обращения к рассматриваемому API необходимо использовать id, name и birth_year, а потом поймём, что нам известны годы рождения не всех персонажей? Мы, кроме того, можем создать систему проверки обратной совместимости. Хотя наши API и отличаются явной типизацией, соответствующие типы могут меняться (скажем, API изменится, если использование года рождения персонажа сначала было обязательным, а потом стало необязательным). В таком случае параметр birth_year нужно будет сделать необязательным, но при этом старые версии клиентов, которые ожидают наличия подобного параметра, могут просто перестать работать. Мы можем отслеживать изменения API и предупреждать разработчиков API, давая им в нужное время подсказки о том, что, выполняя некие изменения, они могут нарушить работоспособность клиентов.

Итоги

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

Они отличаются тем, что обычно задают строгие типы для запросов и ответов и генерируют клиентский и серверный код для организации работы запросов. Одна сторона этого спектра представлена RPC-фреймворками наподобие Thrift и gRPC. Они способны обходиться без HTTP и даже без JSON.

Применённый нами подход даёт возможности, характерные для более чётко структурированных фреймворков, но при этом позволяет продолжать использовать связку HTTP+JSON и способствует тому, что в код приложения приходится вносить минимум изменений. С другой стороны находятся неструктурированные веб-фреймворки, написанные на Python, в которых нет явно заданных контрактов для запросов и ответов.

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

Мы смогли применить описываемый подход к типизации API во всей нашей кодовой базе из-за того, что он хорошо применим к существующим функциям-представлениям. Мы успешно ввели в строй использование типов для HTTP-API. А именно, речь идёт о том, что документация, генерируемая автоматически, стала эффективным средством общения тех, кто занимается разработкой сервера, с теми, кто пишет клиент Instagram. Ценность того, что у нас получилось, очевидна для всех наших программистов.

Уважаемые читатели! Как вы подходите к проектированию HTTP-API в своих Python-проектах?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть