Сегодня мы публикуем второй материал из цикла, посвящённого использованию 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 должен принимать идентификатор (id
) персонажа в виде URL-параметра и значение перечисления calendar
в качестве параметра запроса. API должен возвращать JSON-ответ со сведениями о персонаже.
Вот как выглядит запрос к 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 = {
param_name: param.annotation(extract(request, param))
for param_name, param in inspect.signature(view).parameters.items()
}
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
, а потом поймём, что нам известны годы рождения не всех персонажей? В таком случае параметр birth_year
нужно будет сделать необязательным, но при этом старые версии клиентов, которые ожидают наличия подобного параметра, могут просто перестать работать. Хотя наши API и отличаются явной типизацией, соответствующие типы могут меняться (скажем, API изменится, если использование года рождения персонажа сначала было обязательным, а потом стало необязательным). Мы можем отслеживать изменения API и предупреждать разработчиков API, давая им в нужное время подсказки о том, что, выполняя некие изменения, они могут нарушить работоспособность клиентов.
Итоги
Существует целый спектр прикладных протоколов, которые компьютеры могут использовать для взаимодействия друг с другом.
Одна сторона этого спектра представлена RPC-фреймворками наподобие Thrift и gRPC. Они отличаются тем, что обычно задают строгие типы для запросов и ответов и генерируют клиентский и серверный код для организации работы запросов. Они способны обходиться без HTTP и даже без JSON.
С другой стороны находятся неструктурированные веб-фреймворки, написанные на Python, в которых нет явно заданных контрактов для запросов и ответов. Применённый нами подход даёт возможности, характерные для более чётко структурированных фреймворков, но при этом позволяет продолжать использовать связку HTTP+JSON и способствует тому, что в код приложения приходится вносить минимум изменений.
Важно отметить то, что идея эта не нова. Существует много фреймворков, написанных на строго типизированных языках и предоставляющих разработчикам описанные нами возможности. Если же говорить о Python, то это, например, фреймворк APIStar.
Мы успешно ввели в строй использование типов для HTTP-API. Мы смогли применить описываемый подход к типизации API во всей нашей кодовой базе из-за того, что он хорошо применим к существующим функциям-представлениям. Ценность того, что у нас получилось, очевидна для всех наших программистов. А именно, речь идёт о том, что документация, генерируемая автоматически, стала эффективным средством общения тех, кто занимается разработкой сервера, с теми, кто пишет клиент Instagram.
Уважаемые читатели! Как вы подходите к проектированию HTTP-API в своих Python-проектах?
Автор: ru_vds