GraphQL

Разработчик пишет GraphQL-запрос в редакторе на ноутбуке Технологии

GraphQL описывает запросы к API и выполняет их на сервере: это язык запросов вместе со средой выполнения. Клиент может запрашивать только те данные, которые ему нужны, вплоть до отдельных полей, а сервер возвращает ровно их и ничего сверх того. Язык появился в 2012 году, с 2015-го он развивается как открытый стандарт, актуальная редакция спецификации датирована сентябрём 2025 года.

Что такое GraphQL простыми словами

Возьмём экран профиля: нужны данные пользователя, его имя и заголовки постов. Клиент пишет запрос, похожий на JSON без значений:

{
  user(id: "1") {
    name
    posts { title }
  }
}

И получает ответ той же формы:

{"data": {"user": {"name": "Аня", "posts": [{"title": "Первый пост"}, {"title": "Про GraphQL"}]}}}

Поля email в ответе нет, потому что его не запросили. В обычном API без GraphQL форму ответа определяет сервер: точка /users/1 отдаёт все данные пользователя, а за постами клиент идёт вторым запросом.

GraphQL часто путают с базой данных. Он работает слоем API: спецификация не привязывает его ни к языку программирования, ни к хранилищу. За схемой может стоять, например, PostgreSQL, другой сервис по REST или словарь в памяти, как в нашем примере.

Как работает GraphQL

На сервере у GraphQL три шага:

  1. Разбор (parse): текст запроса превращается в дерево. Опечатка в синтаксисе останавливает всё на этом шаге.
  2. Проверка (validate): каждое поле и аргумент сверяются со схемой. Поля, которого нет в схеме, запросить нельзя.
  3. Выполнение (execute): для каждого поля вызывается резолвер, функция, которая достаёт данные для этого поля. Данные ответа собираются в той же вложенной структуре, что и запрос.

Первые два шага не трогают данные: неверный запрос не доходит до базы данных. Все запросы идут в одну точку входа API, обычно /graphql, вместо десятков адресов.

Схема GraphQL и типы данных

Схема описывает, какие типы данных есть в API, какие у них поля и какие операции доступны клиенту. Её пишут на языке определения схем (SDL). Для примера используем библиотеку graphql-core: это Python-реализация GraphQL, версия 3.3.0 требует Python 3.10 и новее.

pip install graphql-core

Если Python ответит ModuleNotFoundError: No module named 'graphql', пакет встал не в то окружение, причины разобраны в статье про ModuleNotFoundError. Схема нашего API:

# graphql_demo.py: Python 3.14, graphql-core 3.3.0
import json
from graphql import build_schema, graphql_sync

schema = build_schema("""
    type User {
        id: ID!
        name: String!
        email: String
        posts: [Post!]!
    }

    type Post {
        title: String!
    }

    type Query {
        user(id: ID!): User
        users: [User!]!
    }

    type Mutation {
        addPost(authorId: ID!, title: String!): Post!
    }
""")

Доска со схемой из связанных блоков, как типы в схеме GraphQL

  • User и Post объявлены как объектные типы, у каждого свой набор полей.
  • ID, String, Int, Float, Boolean встроены в язык как скалярные типы. Int хранит знаковое 32-битное целое, Float хранит число двойной точности, ID всегда передаётся строкой, даже если внутри число.
  • Восклицательный знак значит «не может быть null». email: String без знака, сервер вправе вернуть там null.
  • [Post!]! читается так: список обязателен, и каждый элемент в нём тоже не null.
  • Query и Mutation служат корневыми типами. Query обязателен в любой схеме, Mutation нет: без него сервер просто не принимает мутации.

Директивой @deprecated помечают поле, которое больше не нужно, вместо того чтобы сразу удалить его при изменении схемы: старые клиенты продолжают работать, а инструменты, которые читают схему, могут предупредить разработчика об устаревшем поле.

Теперь данные и резолверы. Отдельный резолвер на каждое поле писать не нужно: graphql-core берёт у объекта атрибут или ключ словаря с именем поля, а если там лежит функция, вызывает её с info и аргументами запроса. Поэтому корневые поля описаны словарём root, а у пользователя в ключах email и posts лежат функции:

# "База данных": обычные словари в памяти
USERS = {"1": "Аня", "2": "Борис"}
POSTS = [
    {"author_id": "1", "title": "Первый пост"},
    {"author_id": "1", "title": "Про GraphQL"},
    {"author_id": "2", "title": "Привет"},
]
db_calls = 0  # сколько раз резолверы сходили в "базу"


def deny_email(info):
    raise PermissionError("нет доступа к email")


def make_user(user_id):
    def posts(info):
        global db_calls
        db_calls += 1
        return [p for p in POSTS if p["author_id"] == user_id]

    return {"id": user_id, "name": USERS[user_id], "email": deny_email, "posts": posts}


def resolve_user(info, id):
    return make_user(id) if id in USERS else None


def resolve_users(info):
    global db_calls
    db_calls += 1
    return [make_user(user_id) for user_id in USERS]


def resolve_add_post(info, authorId, title):
    if authorId not in USERS:
        raise ValueError(f"автор {authorId} не найден")
    post = {"author_id": authorId, "title": title}
    POSTS.append(post)
    return post


root = {"user": resolve_user, "users": resolve_users, "addPost": resolve_add_post}


def run(query, variables=None):
    result = graphql_sync(schema, query, root_value=root, variable_values=variables)
    print(json.dumps(result.formatted, ensure_ascii=False))
    return result

Поле email специально падает с исключением: так видно, что делает GraphQL, когда часть данных не удалось получить.

Читайте также:  Что такое фреймворк

Запросы GraphQL: поля, аргументы и переменные

Запрос (query) служит для получения данных и ничего не меняет. Клиент указывает, какие поля получить, и передаёт аргументы, например id пользователя:

# 1. Запрос: клиент сам выбирает поля
run('{ user(id: "1") { name posts { title } } }')

# 2. Тот же запрос с именем операции и переменной
run(
    "query GetUser($id: ID!) { user(id: $id) { id name } }",
    {"id": "2"},
)
{"data": {"user": {"name": "Аня", "posts": [{"title": "Первый пост"}, {"title": "Про GraphQL"}]}}}
{"data": {"user": {"id": "2", "name": "Борис"}}}

Во втором запросе значение передано переменной $id, отдельно от текста запроса. Текст запроса остаётся постоянным, меняются только переменные, а значение проверяется по типу ID! до выполнения: на {"id": None} graphql-core ответит "data": null и ошибкой Variable '$id' has invalid value, резолвер не вызовется. Склеивать запрос из пользовательского ввода не стоит: кавычка или скобка во вводе меняют сам текст запроса, это та же ошибка, из-за которой случаются SQL-инъекции. Имя операции GetUser необязательно, пока в документе одна операция. Если их несколько, сервер выбирает нужную по operationName.

Экран ноутбука с вложенным запросом и ответом сервера в двух колонках

# 3. Псевдонимы, фрагмент и директива в одном запросе
run(
    """
    query Profiles($withPosts: Boolean!) {
        first: user(id: "1") { ...UserInfo }
        second: user(id: "2") {
            ...UserInfo
            posts @include(if: $withPosts) { title }
        }
    }
    fragment UserInfo on User { id name }
    """,
    {"withPosts": False},
)
{"data": {"first": {"id": "1", "name": "Аня"}, "second": {"id": "2", "name": "Борис"}}}

Без псевдонимов first и second запрос не прошёл бы проверку: два поля user с одинаковым ключом в ответе и разными аргументами проверка не пропускает. Фрагмент UserInfo хранит общий набор полей, чтобы не повторять его в каждом месте. Директива @include(if: $withPosts) включает поле только при true, поэтому постов в ответе нет. Обратная директива @skip исключает поле при true.

Мутации в GraphQL

Мутация (mutation) нужна, чтобы создавать и изменять данные. Результат она возвращает сразу, отдельный запрос на чтение после неё не нужен:

# 4. Мутация: запись и сразу чтение результата
run(
    "mutation { addPost(authorId: \"2\", title: \"Новый пост\") { title } }"
)
{"data": {"addPost": {"title": "Новый пост"}}}

Клиент сам выбирает, какие поля созданного объекта вернуть, как и в обычном запросе. Если в одной мутации несколько корневых полей, сервер обязан выполнять их строго по очереди, в порядке записи. Поля обычного query сервер вправе выполнять в любом порядке, в том числе параллельно.

Третья операция GraphQL, подписка (subscription), держит долгое соединение и присылает данные при каждом событии: новое сообщение в чате, смена статуса заказа. Схема «один HTTP-запрос, один ответ» подходит для query и мутаций, а для подписок обычно берут WebSockets или server-sent events.

Ошибки в GraphQL: data и errors

Ответ GraphQL API состоит из двух частей: data с полученными данными и errors со списком ошибок. Если ошибок нет, ключа errors в ответе нет вовсе. У каждой ошибки обязательно поле message, а locations (строка и столбец в тексте запроса) и path (путь к полю в ответе) необязательны. Вот три разных случая:

# 5. Ошибка валидации: такого поля нет в схеме
run('{ user(id: "1") { name age } }')

# 6. Ошибка выполнения: email падает, остальное приходит
run('{ user(id: "1") { name email } }')

# 7. Ошибка в мутации: поле addPost объявлено как Post!
run('mutation { addPost(authorId: "99", title: "Тест") { title } }')
{"data": null, "errors": [{"message": "Cannot query field 'age' on type 'User'. Did you mean 'name'?", "locations": [{"line": 1, "column": 24}]}]}
{"data": {"user": {"name": "Аня", "email": null}}, "errors": [{"message": "нет доступа к email", "locations": [{"line": 1, "column": 24}], "path": ["user", "email"]}]}
{"data": null, "errors": [{"message": "автор 99 не найден", "locations": [{"line": 1, "column": 12}], "path": ["addPost"]}]}

Первый запрос не прошёл проверку схемой и до выполнения не дошёл. Спецификация относит такой случай к ошибкам запроса, и ключа data в ответе быть не должно вообще. graphql-core в graphql_sync всё равно кладёт "data": null; в HTTP-обработчике ниже ответ на такие ошибки собирается вручную, без data.

Второй случай показывает частичные данные. Поле email упало, но оно объявлено как String без восклицательного знака, и на его месте null, а имя пришло. В path указано, какое именно поле сломалось. Клиент, который проверяет только data, ошибку не заметит.

Читайте также:  REST API

Третий случай про обязательные поля. addPost возвращает Post!, null там быть не может, и null поднимается к ближайшему родителю, который его допускает. Выше addPost есть только data, и весь data стал null. В схеме из статьи то же случится с users: поле объявлено как [User!]!, и если у одного пользователя упадёт обязательное name, null дойдёт до самого верха и весь data станет null. Чтобы пропадал только один элемент, тип списка делают [User].

Монитор с логом запросов, одна строка подсвечена красным

Проблема N+1 в GraphQL

Резолвер вызывается для каждого поля каждого объекта, и если он сам ходит за данными, обращений становится много. Запрос всех пользователей с их постами в нашем примере выглядит безобидно:

# 8. Проблема N+1: один запрос за списком и по одному на каждого автора
db_calls = 0
run("{ users { name posts { title } } }")
print("обращений к базе:", db_calls)
{"data": {"users": [{"name": "Аня", "posts": [{"title": "Первый пост"}, {"title": "Про GraphQL"}]}, {"name": "Борис", "posts": [{"title": "Привет"}, {"title": "Новый пост"}]}]}}
обращений к базе: 3

Одно обращение за списком и ещё по одному на каждого из двух авторов. Количество обращений растёт вместе со списком: на двух пользователях 3, на тысяче 1001. Это и называют проблемой N+1. Обычное решение называется батчинг: запросы резолверов к хранилищу копятся короткое время и уходят одним запросом, например WHERE author_id IN (...). Для JavaScript для этого есть библиотека DataLoader, в Python встроенный DataLoader есть, например, в Strawberry.

Вторая сторона той же гибкости в том, что клиент может прислать запрос с глубокой вложенностью: друзья друзей друзей. Даже без N+1 такой запрос даёт серверу лишнюю нагрузку. Глубину запроса ограничивают, а длинные списки данных отдают страницами.

Серверная стойка с множеством кабелей к одной базе данных

GraphQL и HTTP

GraphQL не привязан к HTTP, но на практике GraphQL API чаще всего работает поверх него. Запрос уходит методом POST на одну точку /graphql, в теле JSON с ключами query и variables. GET допустим только для query; мутацию по GET отправлять нельзя. Ниже минимальный GraphQL API на стандартной библиотеке Python и клиент к нему:

# 9. GraphQL поверх HTTP: одна точка входа /graphql
import threading
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from graphql import GraphQLError, execute_sync, parse, validate


class GraphQLHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers["Content-Length"])
        body = json.loads(self.rfile.read(length))
        try:
            document = parse(body["query"])
        except GraphQLError as error:  # запрос не разобрать
            return self.reply(400, {"errors": [error.formatted]})
        errors = validate(schema, document)
        if errors:  # запрос не сходится со схемой
            return self.reply(422, {"errors": [e.formatted for e in errors]})
        result = execute_sync(
            schema, document, root_value=root,
            variable_values=body.get("variables"),
        )
        if result.data is None and not any(e.path for e in result.errors):
            # переменные не подошли к типам: выполнение не началось
            return self.reply(422, {"errors": [e.formatted for e in result.errors]})
        self.reply(200, result.formatted)

    def reply(self, status, payload):
        data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/graphql-response+json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):  # не засорять вывод логом запросов
        pass


server = ThreadingHTTPServer(("127.0.0.1", 0), GraphQLHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{server.server_port}/graphql"


def post(query, variables=None):
    payload = json.dumps({"query": query, "variables": variables}).encode()
    request = urllib.request.Request(url, data=payload, headers={
        "Content-Type": "application/json",
        "Accept": "application/graphql-response+json",
    })
    try:
        with urllib.request.urlopen(request) as response:
            status, text = response.status, response.read().decode()
    except urllib.error.HTTPError as error:
        status, text = error.code, error.read().decode()
    print(status, text)


post('{ user(id: "1") { name email } }')
post('{ user(id: "1") { name age } }')
post('{ user(id: "1") ')
post("query GetUser($id: ID!) { user(id: $id) { name } }", {"id": None})
server.shutdown()
200 {"data": {"user": {"name": "Аня", "email": null}}, "errors": [{"message": "нет доступа к email", "locations": [{"line": 1, "column": 24}], "path": ["user", "email"]}]}
422 {"errors": [{"message": "Cannot query field 'age' on type 'User'. Did you mean 'name'?", "locations": [{"line": 1, "column": 24}]}]}
400 {"errors": [{"message": "Syntax Error: Expected Name, found <EOF>.", "locations": [{"line": 1, "column": 17}]}]}
422 {"errors": [{"message": "Variable '$id' has invalid value: Expected value of non-null type 'ID!' not to be None.", "locations": [{"line": 1, "column": 15}]}]}

Коды здесь выбраны по черновику стандарта GraphQL over HTTP: 400, если документ не разобрать, 422, если он не прошёл проверку схемой или переменная не подошла к типу. Первый ответ пришёл с кодом 200 и ошибкой внутри: запрос выполнен, данные частично есть. Некоторые старые серверы с типом application/json отвечают 2xx даже на ошибку валидации, а сам черновик стандарта ещё меняется. Поэтому клиенту GraphQL нельзя судить об успехе по статусу HTTP: решает список errors в теле.

Читайте также:  Что такое фреймворк

Если клиент работает в браузере, а API на другом домене, запрос упрётся в те же правила, что и любой fetch к чужому серверу; как настроить заголовки, разобрано в статье про CORS.

GraphQL и REST: отличия

Про устройство REST у нас есть отдельная статья, REST API, с сервером и запросами curl. Сравнение по тем пунктам, которые видно в коде выше:

REST GraphQL
Адреса у каждого ресурса свой URL одна точка входа, обычно /graphql
Форма ответа задаёт сервер задаёт клиент, ровно нужные поля
Связанные данные часто несколько запросов: /users/1, затем /users/1/posts один запрос с вложенными полями
Схема стиль её не требует, описание пишут отдельно обязательна, сервер отдаёт её по интроспекции
Ошибки код HTTP: 404, 422, 500 список errors в теле, при частичных данных статус 2xx
Запись методы POST, PUT, PATCH, DELETE мутации, обычно POST
HTTP-кэш GET по URL кэшируется штатно по умолчанию POST; кэш через GET для query, длинный запрос заменяют хешем сохранённого (persisted queries)

Две коммутационные панели: на одной много кабелей, на другой один толстый кабель

Преимущества и ограничения GraphQL

Главное преимущество видно уже в первом примере: клиент сам решает, какие данные запросить, и получает их одним запросом. Например, мобильному приложению нужны три поля пользователя, веб-версии двадцать, и под каждый экран не нужно заводить отдельную точку API. Ошибку в имени поля сервер ловит до выполнения, как в запросе 5 выше. Обратная сторона той же гибкости: N+1, лимит глубины и контроль сложности ложатся на сервер.

Из сравнения не следует, что GraphQL быстрее или лучше REST. Новый публичный API с простыми ресурсами мы бы начинали с REST: curl, HTTP-кэш и коды ответа работают у него сразу. GraphQL стоит брать, когда фронтенд постоянно просит то добавить поле, то убрать лишнее, а между данными много связей.

Инструменты и библиотеки для GraphQL

GraphQL API описывает сам себя: схему можно запросить у сервера обычным запросом GraphQL. Такой механизм называется интроспекцией, на нём строят инструменты разработчика и клиентские библиотеки:

# 10. Интроспекция: схему можно спросить у самого сервера
run('{ __type(name: "User") { fields { name } } }')
{"data": {"__type": {"fields": [{"name": "id"}, {"name": "name"}, {"name": "email"}, {"name": "posts"}]}}}

Если API обслуживает только ваши приложения, интроспекцию можно выключить вне среды разработки. Схему это само по себе не скрывает: её можно восстановить перебором имён полей по сообщениям об ошибках вроде «Did you mean ‘name’?», так что подробности ошибок в продакшене тоже прячут.

Что взять для работы:

  • GraphiQL: интерактивная среда разработки для GraphQL прямо в браузере.
  • Python: graphql-core из нашего примера даёт ядро без обвязки. Поверх строят серверы Graphene, Strawberry (схема из классов с аннотациями типов) и Ariadne (сначала схема на SDL, потом резолверы, как у нас).
  • JavaScript: Apollo Server для сервера и Apollo Client для фронтенда, в том числе с React.

HTTP-обработчик руками, как в нашем примере, в рабочем проекте лучше не писать: возьмите серверную библиотеку из списка выше.

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

Частые вопросы о GraphQL

GraphQL это база данных?

Нет. GraphQL описывает запросы к API и выполняет их на сервере. Данные лежат где угодно: в PostgreSQL, в другом сервисе, в файлах. Резолверы сами решают, откуда их взять.

Можно ли использовать GraphQL без HTTP?

Да. Спецификация не требует определённого транспорта и формата: в нашем примере graphql_sync выполняет запрос вообще без сети. HTTP и JSON просто встречаются чаще всего.

Нужно ли переписывать REST API на GraphQL?

Нет, если текущий API справляется. Переписывать стоит, когда клиенту приходится делать много запросов на один экран или он тянет лишние данные.

Оцените статью
bestprogrammer.ru
Добавить комментарий