Типы для HTTP-API, написанных на Python: опыт Instagram

в 9:30, , рубрики: api, python, Блог компании RUVDS.com, разработка, Разработка веб-сайтов, Социальные сети и сообщества

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

Типы для HTTP-API, написанных на Python: опыт Instagram - 1

Эта статья посвящена тому, как в 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-код реализации этой конечной точки.

Типы для HTTP-API, написанных на Python: опыт Instagram - 2
Документация к 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-проектах?

Типы для HTTP-API, написанных на Python: опыт Instagram - 3


Типы для HTTP-API, написанных на Python: опыт Instagram - 4

Автор: ru_vds

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js