Хабрахабр

GraphQL — API по-новому

Что такое язык запросов GraphQL? Какие преимущества дает эта технология и с какими проблемами столкнутся разработчики при ее использовании? Как эффективно использовать GraphQL? Обо всем этом под катом.

В основе статьи — доклад вводного уровня Владимира Цукура (volodymyrtsukur) с конференции Joker 2017.

Меня зовут Владимир, я руковожу разработкой одного из департаментов в компании WIX. Более сотни миллионов пользователей WIX создают сайты самой разной направленности — от сайтов-визиток и магазинов до сложных веб-приложений, на которых можно писать код и произвольную логику. В качестве живого примера проекта на WIX я бы хотел показать вам успешный сайт-магазин unicornadoptions.com, который предлагает возможность приобрести набор для приручения единорога — прекрасный подарок для ребенка.

Далее у покупателя есть возможность добавить товар в корзину, просмотреть ее содержимое и оформить заказ. Посетитель этого сайта может выбрать понравившийся набор для приручения единорога, скажем, розовый, затем посмотреть, что именно входит в этот набор: игрушка, сертификат, значок. Все они построены на одной и той же платформе, с одним бэкендом, с набором клиентов, которые мы поддерживаем, используя для этого API. Это простой пример сайта-магазина, и таких сайтов у нас много, сотни тысяч. Именно об API и пойдет речь дальше.

Простой API и его проблемы

Давайте представим себе, какой API общего назначения (то есть один API для всех магазинов поверх платформы) мы бы могли создать, чтобы обеспечить функциональность магазинов. Сконцентрируемся пока сугубо на получении данных.

В полноценном решении для магазинов на WIX таких полей данных — более чем два десятка. Для страницы продукта на таком сайте должно возвращаться название продукта, его цена, картинки, описание, дополнительная информация и многое другое. Ниже указан пример данных ответа: Стандартное решение для такой задачи поверх HTTP API — это описать ресурс /products/:id, который на GET-запрос возвращает данные продукта.

{ "id": "59eb83c0040fa80b29938e3f", "title": "Combo Pack with Dreamy Eyes 12\" (Pink) Soft Toy", "price": 26.99, "description": "Spread Unicorn love amongst your friends and family by purchasing a Unicorn adoption combo pack today. You'll receive your very own fabulous adoption pack and a 12\" Dreamy Eyes (Pink) cuddly toy. It makes the perfect gift for loved ones. Go on, you know you want to, adopt today!", "sku":"010", "images": [ "http://localhost:8080/img/918d8d4cc83d4e5f8680ca4edfd5b6b2.jpg", "http://localhost:8080/img/f343889c0bb94965845e65d3f39f8798.jpg", "http://localhost:8080/img/dd55129473e04f489806db0dc6468dd9.jpg", "http://localhost:8080/img/64eba4524a1f4d5d9f1687a815795643.jpg", "http://localhost:8080/img/5727549e9131440dbb3cd707dce45d0f.jpg", "http://localhost:8080/img/28ae9369ec3c442dbfe6901434ad15af.jpg" ]
}

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

В случае коллекции таких продуктов потенциально будет несколько. Допустим, для простоты, мы решаем использовать одинаковую модель данных продукта для ресурсов /products и /products/:id. Схему ответа можно представить следующим образом:

GET /products
[
]

А теперь давайте посмотрим на «полезную нагрузку» ответа от сервера для коллекции продуктов. Вот что в действительности используется клиентом среди более чем двух десятков полей:

99,
"info": "Spread Unicorn love amongst your friends and family by purchasing a Unicorn adoption combo pack today. {
"id": "59eb83c0040fa80b29938e3f",
"title": "Combo Pack with Dreamy Eyes 12\" (Pink) Soft Toy",
"price": 26. It makes the perfect gift for loved ones. You'll receive your very own fabulous adoption pack and a 12\" Dreamy Eyes (Pink) cuddly toy. Go on, you know you want to, adopt todayl",
"description": "Your fabulous Unicorn adoption combo pack contains:\nA 12\" Dreamy Eyes (Pink) Unicorn Soft Toy\nA blank Unicorn adoption certificate — name your Unicorn!\nA confirmation letter\nA Unicorn badge\nA Unicorn key ring\nA Unicorn face mask (self assembly)\nA Unicorn bookmark\nA Unicorn colouring in sheet\nA A4 Unicorn posters\n2 x Unicorn postcards\n3 x Unicorn stickers",
"images": [
"http://localhost:8080/img/918d8d4cc83d4e5f8680ca4edfd5b6b2.jpg",
"http://localhost:8080/img/f343889c0bb94965845e65d3f39f8798.jpg",
"http://localhost:8080/img/dd55129473604f489806db0dC6468dd9.jpg",
"http://localhost:8080/img/64eba4524a1f4d5d9f1687a815795643.jpg",
"http://localhost:8080/img/5727549e9l3l440dbb3cd707dce45d0f.jpg",
"http://localhost:8080/img/28ae9369ec3c442dbfe6901434ad15af.jpg"
],
...
}

В данном случае это проявилось на странице каталога продуктов, но вообще, любые экраны UI, которые так или иначе связаны с продуктом, потребуют от него потенциально только части (а не всех) данных. Очевидно, что если я хочу держать модель продукта простой, возвращая одинаковые данные, то в итоге сталкиваюсь с over-fetching проблемой, получая в некоторых случаях больше данных, чем мне необходимо.

В корзине, кроме самих продуктов, есть еще их количество (в этой корзине), цена, а также суммарная стоимость всего заказа: Давайте теперь рассмотрим страницу корзины.

Если продолжать подход простого моделирования HTTP API, то корзина может быть представлена через ресурс /carts/:id, представление которого ссылается на ресурсы продуктов, добавленных в эту корзину:

{ "id": 1, "items": [ { "product": "/products/59eb83c0040fa80b29938e3f", "quantity": 1, "total": 26.99 }, { "product": "/products/59eb83c0040fa80b29938e40", "quantity": 2, "total": 25.98 }, { "product": "/products/59eb88bd040fa8125aa9c400", "quantity": 1, "total": 26.99 } ], "subTotal": 79.96
}

Теперь, например, для того чтобы отрисовать корзину с тремя продуктами на фронтенде, необходимо сделать четыре запроса: один для того, чтобы загрузить саму корзину, и три запроса, чтобы загрузить данные по продуктам (название, цену и артикул SKU).

Разграничение ответственности между ресурсами корзины и продукта привело к необходимости делать дополнительные запросы. Вторая проблема, которая у нас возникла — under-fetching. И к масштабируемости нашего решения тоже возникают вопросы. Тут очевидно есть ряд недостатков: из-за большего количества запросов батарею мобильного телефона мы сажаем быстрее и полный ответ получаем медленнее.

Один из способов избавиться от проблемы — это добавить поддержку проекций для корзины. Конечно же, такое решение не подходит для продакшена. Причем эта проекция будет очень специфическая, потому что именно на странице корзины нужен инвентарный номер (SKU) продукта. Одна из таких проекций могла бы кроме данных самой корзины возвращать и данные по продуктам. Нигде в других местах SKU пока что не был нужен.

GET /carts/1?projection=with-products

Такая «подгонка» ресурсов под конкретный UI обычно не заканчивается, и мы начинаем генерировать другие проекции: краткую информацию по корзине, проекцию корзины для мобильного web, а после этого — и вовсе проекцию для единорогов.

(А вообще, в конструкторе WIX вы как пользователь можете сконфигурировать, какие данные продукта вы хотите отображать на странице продукта и какие данные показывать в корзине)

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

Что еще важно, теперь становится тяжелее работать, потому что, когда меняются требования на клиентской стороне, бэкенд должен их постоянно «догонять» и удовлетворять.

В случае простого HTTP API серверный разработчик понятия не имеет, какие именно данные используются клиентом. В качестве «вишенки на торте» давайте рассмотрим еще одну важную проблему. Описание? Используется ли цена? Одно или все изображения?

Как работать с deprecated / устаревшими данными? Соответственно, возникает несколько вопросов. Как относительно безопасно убрать данные с ответа, не поломав большинство клиентов? Как узнавать, какие данные действительно больше не используются? Вопреки тому, что мы оптимистичны и вроде бы API у нас простой, ситуация выглядит не ахти. Ответа на эти вопросы с привычным HTTP API нет. С ними пришлось иметь дело большому количеству компаний. Такой спектр проблем с API возник не только у WIX. А теперь интересно посмотреть на потенциальное решение.

GraphQL. Начало

В 2012 году в процессе разработки мобильного приложения с подобной проблемой столкнулась компания Facebook. Инженерам хотелось достичь минимального количества обращений мобильного приложения к серверу, при этом на каждом шаге получая только нужные данные и ничего, кроме них. Результатом их усилий стал GraphQL, представленный в 2015 году на конференции React Conf. GraphQL — это язык описания запросов, а также среда исполнения этих запросов.

Рассмотрим типичный подход к работе с GraphQL-серверов.

Описываем схему

Схема данных в GraphQL определяет типы и связи между ними и делает это в строго-типизированной манере. Например, представим себе простую модель социальной сети. Пользователь User знает про своих друзей friends. Пользователи живут в городе City, и город знает про своих жителей через поле citizens. Вот что является графом такой модели в GraphQL:

Например, такой точкой входа может быть получение пользователя по имени. Конечно же, для того чтобы граф был полезным, нужны еще так называемые «точки входа».

Запрашиваем данные

Давайте посмотрим, в чем суть языка запросов GraphQL. Переведем на этот язык такой вопрос: «Для пользователя с именем Vanya Unicorn, хочу узнать имена его друзей, а также название и население города, в котором Ваня проживает»:

{ user(name: "Vanya Unicorn") { friends { name } city { name population } }
}

И вот приходит ответ от GraphQL-сервера:

{ "data": { "user": { "friends": [ { "name": "Lena" }, { "name": "Stas" } ] "city": { "name": "Kyiv", "population": 2928087 } } }
}

Обратите внимание, как форма запроса «созвучна» с формой ответа. Возникает ощущение, что этот язык запросов создавался для JSON. Со строгой типизацией. И все это делается за один запрос HTTP POST — не нужно делать несколько обращений к серверу.

Откроем стандартную консоль для GraphQL-сервера, которая называется GraphiQL («графикл»). Давайте посмотрим, как это выглядит на практике. Из информации важны название, цена, инвентарный номер и изображения (причем только первое). Для запроса на корзину я выполню следующий запрос: «Хочу получить корзину по идентификатору 1, интересуют все позиции этой корзины и информация по продуктам. Также меня интересует количество этих продуктов, какова их цена и общая стоимость в рамках корзины».

{ cart(id: 1) { items { product { title price sku images(limit: 1) } quantity total } subTotal }
}

После успешного выполнения запроса получем ровно то, что попросили:

Главные преимущества

  • Гибкая выборка. Клиент может составить запрос под свои конкретные требования.
  • Эффективная выборка. В ответе возвращаются только запрошенные данные.
  • Более быстрая разработка. Много изменений на клиенте могут происходить без необходимости менять что-либо на серверной стороне. Например, исходя из нашего примера, запросто можно показать другое представление корзины для мобильного web.
  • Полезная аналитика. Так как клиент обязан в запросе указывать поля явно, сервер точно знает, какие поля действительно нужны. А это важная информация для deprecation-политики.
  • Работает поверх любого источника данных и транспорта. Важно, что GraphQL позволяет работать поверх любого источника данных и любого транспорта. В данном случае HTTP — это не панацея, GraphQL может также работать через WebSocket, и мы чуть позже затронем этот момент.

Сегодня GraphQL-сервер можно сделать практически на любом языке. Наиболее полная версия GraphQL-сервера — GraphQL.js для Node-платформы. В Java-комьюнити эталонной реализацией является GraphQL Java.

Создаем GraphQL API

Давайте посмотрим, как создать GraphQL-сервер на конкретном жизненном примере.

Рассмотрим упрощенную версию интернет-магазина на основе микросервисной архитектуры с двумя компонентами:

  • Cart-сервис, обеспечивающий работу с пользовательской корзиной. Хранит данные в реляционной БД и использует SQL для доступа к данным. Очень простой сервис, без лишней магии 🙂
  • Product-сервис, обеспечивающий доступ к продуктовому каталогу, из которого, собственно, и наполняется корзина. Предоставляет HTTP API для доступа к продуктовым данным.

Оба сервиса реализованы поверх классического Spring Boot и уже содержат всю базовую логику.

Этот API призван обеспечить доступ к данным корзины и добавленным в нее продуктам. Мы же намерены создать GraphQL API поверх Cart-сервиса.

Первая версия

Нам поможет эталонная реализация GraphQL для экосистемы Java, о которой мы упоминали ранее — GraphQL Java.

Добавим несколько зависимостей в pom.xml:

<dependency> <groupId>com.graphql-java</groupId> <artifactId>graphql-java</artifactId> <version>9.3</version>
</dependency>
<dependency> <groupId>com.graphql-java</groupId> <artifactId>graphql-java-tools</artifactId> <version>5.2.4</version>
</dependency>
<dependency> <groupId>com.graphql-java</groupId> <artifactId>graphql-spring-boot-starter</artifactId> <version>5.0.2</version>
</dependency>
<dependency> <groupId>com.graphql-java</groupId> <artifactId>graphiql-spring-boot-starter</artifactId> <version>5.0.2</version>
</dependency>

В дополнение к ранее упомянутой graphql-java нам понадобится библиотека инструментов graphql-java-tools, а также Spring Boot «стартеры» для GraphQL, которые значительно упростят первые шаги по созданию GraphQL-сервера:

  • graphql-spring-boot-starter предоставляет механизм быстрой связки GraphQL Java с Spring Boot;
  • graphiql-spring-boot-starter добавляет интерактивную веб-консоль GraphiQL для выполнения GraphQL-запросов.

Следующий важный шаг — это определить схему GraphQL-сервиса, наш граф. Узлы этого графа описываются с помощью типов, а ребра с помощью полей. Пустое определение графа выглядит так:

schema {
}

В этой самой схеме, как вы помните, есть «точки входа» или запросы верхнего уровня. Они определяются через поле query в схеме. Назовем наш тип для точек входа EntryPoints:

schema { query: EntryPoints
}

Определим в нем поиск корзины по идентификатору как первую точку входа:

type EntryPoints { cart(id: Long!): Cart
}

Cart — это и есть не что иное как поле в терминах GraphQL. id — параметр этого поля со скалярным типом Long. Восклицательный знак ! после указания типа означает, что параметр обязательный.

Самое время определить и тип Cart:

type Cart { id: Long! items: [CartItem!]! subTotal: BigDecimal!
}

Кроме стандартного идентификатора id в корзину входят ее элементы items и сумма за все товары subTotal. Обратите внимание, что items определены как список, о чем свидетельствуют квадратные скобки []. Элементы этого списка являются типами CartItem. Наличие восклицательного знака после названия типа поля ! указывает, что поле обязательное. Это значит, что сервер обязуется вернуть непустое значение для этого поля, если оно было запрошено.

Осталось посмотреть на определение типа CartItem, в который входит ссылка на продукт (productId), сколько раз он добавлен в корзину (quantity) и сумма продукта, пересчитанная на количество (total):

type CartItem { productId: String! quantity: Int! total: BigDecimal!
}

Здесь всё просто — все поля скалярных типов и являются обязательными.

В Cart-сервисе уже определена корзина Cart и ее элементы CartItem с точно такими же названиями и типами полей, как и в схеме GraphQL. Такая схема выбрана не случайно. JPA используется для персистенции в БД. Модель корзины использует библиотеку Lombok для автогенерации геттеров/сеттеров, конструкторов и других методов.

Класс Cart:

import lombok.Data; import javax.persistence.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List; @Entity
@Data
public class Cart { @Id @GeneratedValue private Long id; @ElementCollection(fetch = FetchType.EAGER) private List<CartItem> items = new ArrayList<>(); public BigDecimal getSubTotal() { return getItems().stream() .map(Item::getTotal) .reduce(BigDecimal.ZERO, BigDecimal::add); }
}

Класс CartItem:

import lombok.AllArgsConstructor;
import lombok.Data; import javax.persistence.Column;
import javax.persistence.Embeddable;
import java.math.BigDecimal; @Embeddable
@Data
@AllArgsConstructor
public class CartItem { @Column(nullable = false) private String productId; @Column(nullable = false) private int quantity; @Column(nullable = false) private BigDecimal total;
}

Итак, корзина (Cart) и элементы корзины (CartItem) описаны и в GraphQL-схеме, и в коде, и «совместимы» между собой по набору полей и их типам. Но этого еще недостаточно для того, чтобы наш сервис заработал.

Для этого создадим крайне простую Java-конфигурацию для Spring с bean-ом типа GraphQLQueryResolver. Нам необходимо уточнить, как именно будет работать точка входа "cart(id: Long!): Cart". Определим метод с именем, идентичным полю в точке входа (cart), сделаем его совместимым по типу параметров и воспользуемся cartService для того, чтобы найти ту самую корзину по идентификатору: GraphQLQueryResolver как раз и описывает «точки входа» в схеме.

@Bean
public GraphQLQueryResolver queryResolver() { return new GraphQLQueryResolver () { public Cart cart(Long id) { return cartService.findCart(id); } }
}

Этих изменений нам достаточно для получения работающего приложения. После перезапуска Cart-сервиса в консоли GraphiQL начнет успешно исполняться следующий запрос:

{ cart(id: 1) { items { productId quantity total } subTotal }
}

На заметку

  • В качестве уникальных идентификаторов корзины и продукта мы используем скалярные типы Long и String. В GraphQL есть специальный тип для таких целей — ID. Семантически это более правильный выбор для настоящего API. Значения типа ID могут использоваться как ключ для кэширования.
  • На данном этапе разработки нашего приложения внутренняя и внешняя модель предметной области полностью идентичны. Речь идет о классах Cart и CartItem и их непосредственном использовании в GraphQL-резолверах. В боевых приложениях эти модели рекомендуется разделять. Для GraphQL-резолверов должна существовать отдельная от внутренней предметной области модель.

Делаем API полезным

Вот мы и получили первый результат, и это замечательно. Но сейчас наш API слишком примитивен. Например, пока что нет возможности запросить полезные данные по продукту, такие как его название, цена, артикул, картинки и так далее. Вместо этого есть всего лишь productId. Давайте сделаем API действительно полезным и добавим полноценную поддержку понятия продукта. Вот как выглядит его определение в схеме:

type Product { id: String! title: String! price: BigDecimal! description: String sku: String! images: [String!]!
}

Добавим нужное поле в CartItem, а поле productId пометим как устаревшее:

type Item { quantity: Int! product: Product! productId: String! @deprecated(reason: "don't use it!") total: BigDecimal!
}

Со схемой разобрались. А теперь самое время описать, как именно будет работать выборка для поля product. Ранее мы полагались на наличие геттеров в классах Cart и CartItem, что позволяло GraphQL Java автоматически связывать значения. Но тут следует напомнить, что как раз свойства product в классе CartItem нет:

@Embeddable
@Data
@AllArgsConstructor
public class CartItem { @Column(nullable = false) private String productId; @Column(nullable = false) private int quantity; @Column(nullable = false) private BigDecimal total;
}

Перед нами стоит выбор:

  1. добавить свойство product в CartItem и «научить» его получать данные по продуктам;
  2. определить, как получать product, не изменяя класс CartItem.

Второй путь более предпочтителен, потому что модель описания внутренней предметной области (класс CartItem) в этом случае не будет обрастать деталями реализации GraphiQL API.

Реализуя его, можно определить (или переопределить), как именно получать значения полей для типа T. В достижении этой цели поможет маркер-интерфейс GraphQLResolver. Вот как выглядит соответствующий bean в Spring-конфигурации:

@Bean
public GraphQLResolver<CartItem> cartItemResolver() { return new GraphQLResolver<CartItem>() { public Product product(CartItem item) { return http.getForObject("http://localhost:9090/products/{id}", Product.class, item.getProductId()); } };
}

Название метода product выбрано не случайно. GraphQL Java ищет методы-загрузчики данных по имени поля, а нам как раз нужно было определить загрузчик для поля product! Объект типа CartItem, переданный как параметр, определяет контекст, в котором выбирается продукт. Дальше — дело техники. С помощью клиента http типа RestTemplate мы выполняем GET-запрос к Product-сервису и преобразуем результат в Product, который выглядит так:

@Data
public class Product { private String id; private String title; private BigDecimal price; private String description; private String sku; private List<String> images;
}

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

После перезапуска приложения можно попробовать новый запрос в консоли GraphiQL.

{ cart(id: 1) { items { product { title price sku images } quantity total } subTotal }
}

А вот как выглядит результат исполнения запроса:

Но консоль GraphiQL не будет предлагать автозаполнение для таких полей и специальным образом подсветит их использования: Несмотря на то, что productId был помечен как @deprecated, запросы с указанием этого поля будут продолжать работать.

Вот как выглядит Document Explorer для типа CartItem: Самое время показать и Document Explorer, часть GraphiQL-консоли, которая строится на основе GraphQL-схемы и показывает информацию по всем определенным типам.

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

images(limit: 1)

Для этого поменяем схему и добавим новый параметр для поля images в тип Product:

type Product { id: ID! title: String! price: BigDecimal! description: String sku: String! images(limit: Int = 0): [String!]!
}

А в коде приложения опять воспользуемся GraphQLResolver, только на этот раз по типу Product:

@Bean
public GraphQLResolver<Product> productResolver() { return new GraphQLResolver<Product>() { public List<String> images(Product product, int limit) { List<String> images = product.getImages(); int normalizedLimit = limit > 0 ? limit : images.size(); return images.subList(0, Math.min(normalizedLimit, images.size())); } };
}

Опять обращаю внимание, что название метода не случайно: он совпадает с названием поля images. Контекстный объект Product дает доступ к изображениям, а limit является параметром самого поля.

Если же клиент указал конкретное значение, то сервис вернет ровно столько (но не больше, чем их есть вообще в продукте). Если клиент ничего не указал в качестве значения для limit, то наш сервис вернет все изображения продукта.

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

{ cart(id: 1) { items { product { title price sku images(limit: 1) } quantity total } subTotal }
}

Согласитесь, все это очень здорово. За короткое время мы не только узнали, что такое GraphQL, но и перевели простую микросервисную систему на поддержку такого API. И нам было неважно, откуда приходили данные: как SQL, так и HTTP API хорошо уложились под одной крышей.

Подход Code-First и GraphQL SPQR

Вы могли обратить внимание, что в процессе разработки было некоторое неудобство, а именно необходимость постоянно держать GraphQL-схему и код в синхронизации. Изменения типов всегда нужно было делать в двух местах. Во многих случаях удобнее использовать подход code-first. Суть его состоит в том, что схема для GraphQL автоматически генерируется на основе кода. В этом случае не нужно поддерживать схему отдельно. Сейчас я покажу, как это выглядит.

Хорошие новости в том, что GraphQL SPQR — это надстройка над GraphQL Java, а не альтернативная реализация GraphQL-сервера на Java. Только базовых возможностей GraphQL Java нам уже недостаточно, понадобится еще библиотека GraphQL SPQR.

Добавим нужную зависимость в pom.xml:

<dependency> <groupId>io.leangen.graphql</groupId> <artifactId>spqr</artifactId> <version>0.9.8</version>
</dependency>

Вот как выглядит код, реализующий ту же самую функциональность на основе GraphQL SPQR для корзины:

@Component
public class CartGraph { private final CartService cartService; @Autowired public CartGraph(CartService cartService) { this.cartService = cartService; } @GraphQLQuery(name = "cart") public Cart cart(@GraphQLArgument(name = "id") Long id) { return cartService.findCart(id); }
}

И для продукта:

@Component
public class ProductGraph { private final RestTemplate http; @Autowired public ProductGraph(RestTemplate http) { this.http = http; } @GraphQLQuery(name = "product") public Product product(@GraphQLContext CartItem cartItem) { return http.getForObject( "http://localhost:9090/products/{id}", Product.class, cartItem.getProductId() ); } @GraphQLQuery(name = "images") public List<String> images(@GraphQLContext Product product, @GraphQLArgument(name = "limit", defaultValue = "0") int limit) { List<String> images = product.getImages(); int normalizedLimit = limit > 0 ? limit : images.size(); return images.subList(0, Math.min(normalizedLimit, images.size())); }
}

Аннотация @GraphQLQuery используется для того, чтобы помечать методы-загрузчики полей. Аннотация @GraphQLContext задает, в рамках какого типа происходит выборка для поля. А аннотация @GraphQLArgument помечает явно параметры-аргументы. Все это частички одного механизма, который помогает GraphQL SPQR генерировать схему автоматически. Теперь если удалить старую Java-конфигурацию и схему, перезапустить Cart-сервис с использованием новых фишек от GraphQL SPQR, то можно убедиться, что функционально все работает точно так же, как и раньше.

Решаем проблему N+1

Настало время посмотреть в больших деталях, как работает выполнение всего запроса «под капотом». Мы быстро создали GraphQL API, но работает ли он эффективно?

Рассмотрим следующий пример:

Данные по items и subtotal возвращаются там же, ведь элементы корзины подгружаются вместе со всей коллекцией, исходя из JPA-стратегии eager fetch: Получение корзины cart происходит в один SQL-запрос к базе данных.

@Data
public class Cart { @ElementCollection(fetch = FetchType.EAGER) private List<Item> items = new ArrayList<>(); ...
}

Если в корзине три разных продукта, то мы получим три запроса к HTTP API сервиса продуктов, а если же их десять — то тому же сервису придется отвечать на десять таких запросов. Когда же дело доходит до загрузки данных по продуктам, то запросов на Product-сервис будет выполнено ровно столько, сколько в данной корзине продуктов.

Вот как выглядит коммуникация между Cart-сервисом и Product-сервисом в Charles Proxy:

Ровно той, от которой так старались уйти в самом начале доклада. Соответственно, мы возвращаемся к классической проблеме N+1. Но внутри серверной экосистемы производительность явно требует улучшений. Несомненно, у нас есть прогресс, ведь между конечным клиентом и нашей системой выполняется ровно один запрос.

Благо, Product-сервис уже поддерживает такую возможность через параметр ids в ресурсе коллекции: Я хочу решить эту проблему, получив все нужные продукты за один запрос.

GET /products?ids=:id1,:id2,...,:idn

Посмотрим, как можно модифицировать код метода выборки для поля product. Предыдущую версию:

@GraphQLQuery(name = "product")
public Product product(@GraphQLContext CartItem cartItem) { return http.getForObject( "http://localhost:9090/products/{id}", Product.class, cartItem.getProductId() );
}

Заменим на более эффективную:

@GraphQLQuery(name = "product")
@Batched
public List<Product> products(@GraphQLContext List<Item> items) { String productIds = items.stream() .map(Item::getProductId) .collect(Collectors.joining(",")); return http.getForObject( "http://localhost:9090/products?ids={ids}", Products.class, productIds ).getProducts();
}

Мы сделали ровно три вещи:

  • пометили метод-загрузчик аннотацией @Batched, дав понять GraphQL SPQR, что загрузка должна происходить батчем;
  • изменили возвращаемый тип и контекстный параметр на список, ведь работа с батчем предполагает, что принимается и возвращается несколько объектов;
  • поменяли тело метода, реализовав выборку всех нужных продуктов за один раз.

Этих изменений достаточно для того, чтобы решить нашу проблему N+1. В окне приложения Charles Proxy видно теперь один запрос к Product-сервису, который возвращает три продукта сразу:

Эффективные выборки по полям

Мы решили основную проблему, но можно сделать выборку еще быстрее! Сейчас Product-сервис возвращает все данные, независимо от того, что нужно конечному клиенту. Мы могли бы улучшить запрос и возвращать только запрошенные поля. Например, если конечный клиент не просил изображения, зачем нам вообще их передавать на Cart-сервис?

Отлично, что HTTP API Product-сервиса уже поддерживает эту возможность через параметр include для того же самого ресурса коллекции:

GET /products?ids=...?include=:field1,:field2,...,:fieldN

Для метода загрузчика добавим параметр типа Set с аннотацией @GraphQLEnvironment. GraphQL SPQR понимает, что код в этом случае «просит» список имен полей, которые запрошены для продукта, и автоматически заполняет их:

@GraphQLQuery(name = "product")
@Batched
public List<Product> products(@GraphQLContext List<Item> items, @GraphQLEnvironment Set<String> fields) { String productIds = items.stream() .map(Item::getProductId) .collect(Collectors.joining(",")); return http.getForObject( "http://localhost:9090/products?ids={ids}&include={fields}", Products.class, productIds, String.join(",", fields) ).getProducts();
}

Теперь наша выборка действительная эффективная, лишена проблемы N+1 и задействует только нужные данные:

«Тяжелые» запросы

Представим себе работу с графом пользователей в рамках классической социальной сети, такой как Facebook. Если такая система предоставляет GraphQL API, то клиенту ничего не мешает послать запрос следующего характера:

{ user(name: "Vova Unicorn") { friends { name friends { name friends { name friends { name ... } } } } }
}

На 5-6 уровне вложенности полноценное выполнение такого запроса приведет к выборке всех в мире пользователей. Сервер уж точно не справится с такой задачей за один присест и скорее всего просто напросто «упадет».

Есть ряд мер, которые следует обязательно предпринять для того, чтобы обезопаситься от подобных ситуаций:

  • Ограничить глубину запроса. Иными словами, нельзя позволять клиентам просить данные произвольной вложенности.
  • Ограничить сложность запроса. Назначив вес на каждое поле и подсчитав сумму весов всех полей в запросе, можно принимать или отклонять такие запросы на сервере.

Для примера рассмотрим следующий запрос:

{ cart(id: 1) { items { product { title } quantity } subTotal }
}

Очевидно, что глубина такого запроса — 4, ведь самый длинный путь внутри него cart -> items -> product -> title.

Если принять, что вес каждого поля 1, то с учетом 7 полей в запросе, его сложность составляет также 7.

В GraphQL Java наложение проверок достигается указанием дополнительного инструментирования при создании объекта GraphQL:

GraphQL.newGraphQL(schema) .instrumentation(new ChainedInstrumentation(Arrays.asList( new MaxQueryComplexityInstrumentation(20), new MaxQueryDepthInstrumentation(3) ))) .build();

Инструментирование MaxQueryDepthInstrumentation проверяет глубину запроса и не позволяет запускаться слишком «глубоким» запросам (в данном случае — с глубиной больше 3).

Если это число превышает указанное значение (20), то такой запрос отвергается. Инструментирование MaxQueryComplexityInstrumentation перед исполнением запроса подсчитывает и проверяет его сложность. Например, полю продукта можем быть назначена сложность 10 через аннотацию @GraphQLComplexity, поддерживаемую в GraphQL SPQR: Можно переопределить вес для каждого поля, ведь некоторые из них явно достаются «тяжелее», чем другие.

@GraphQLQuery(name = "product") @GraphQLComplexity("10")
public List<Product> products(...)

Вот пример проверки на глубину, когда она явно превышает указанное значение:

Его можно использовать и для других целей, таких как логирование или трейсинг. Между прочим, механизм инструментирования не ограничивается наложением ограничений.

Однако существует еще ряд приемов, на которые стоит обратить внимание независимо от типа API: Мы рассмотрели меры «защиты», специфические для GraphQL.

  • throttling / rate-limiting — ограничение количества запросов за единицу времени
  • timeouts — ограничение времени на операции с другими сервисами, БД и т.д.;
  • pagination — поддержка постраничного просмотра.

Изменение данных через мутации

До сих пор мы рассматривали сугубо выборку данных. Но GraphQL позволяет органично организовать не только получение данных, но и их изменение. Для этого существует механизм мутаций. В схеме для этого отведено специальное место — поле mutation:

schema { query: EntryPoints, mutation: Mutations
}

Например, добавление продукта в корзину может быть организовано через такую мутацию:

type Mutations { addProductToCart(cartId: Long!, productId: String!, count: Int = 1): Cart
}

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

Реализация мутации в коде сервера с помощью GraphQL SPQR выглядит следующим образом:

@GraphQLMutation(name = "addProductToCart")
public Cart addProductToCart( @GraphQLArgument(name = "cartId") Long cartId, @GraphQLArgument(name = "productId") String productId, @GraphQLArgument(name = "quantity", defaultValue = "1") int quantity) { return cartService.addProductToCart(cartId, productId, quantity);
}

Конечно же, основная часть полезной работы делается внутри cartService. А задача этого метода-прослойки — связать ее с API. Как и в случае с выборкой данных, благодаря аннотациям @GraphQL* очень просто понять, какая именно генерируется GraphQL-схема из этого определения метода.

В консоли GraphQL теперь можно выполнить запрос-мутацию на добавление определенного продукта в нашу корзину в количестве 2:

mutation { addProductToCart( cartId: 1, productId: "59eb83c0040fa80b29938e3f", quantity: 2) { items { product { title } quantity total } subTotal }
}

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

Несколько команд разработчиков в WIX активно используют GraphQL вместе со Scala и библиотекой Sangria — основной реализацией GraphQL на этом языке.

Мы это делаем для того, чтобы генерировать JSON непосредственно в код страницы. Одна из полезных техник, применяемых у нас в WIX — это поддержка GraphQL-запросов при рендеринге HTML. Вот пример наполнения HTML-шаблона:

// Pre-rendered
<html>
<script data-embedded-graphiql>
{ product(productId: $productId) title description price ... }
}
</script>
</html>

А вот что получается на выходе:

// Rendered
<html>
<script>
window.DATA = { product: { title: 'GraphQL Sticker', description: 'High quality sticker', price: '$2' ... }
}
</script>
</html>

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

Недостатки GraphQL

Сегодня GraphQL использует большое число компаний, включая таких гигантов, как GitHub, Yelp, Facebook и множество других. И если вы решите присоединиться к их числу, вы должны знать не только достоинства GraphQL, но и его недостатки, а их немало:

  • Во-первых, в GraphQL плохо обстоят дела с кэшированием данных. В GraphQL нет таких богатых возможностей по кэшированию, как в HTTP API. Заголовки Cache-Control или Last-Modified широко используемые в HTTP не помогают в случае с GraphQL API. Вы также не можете воспользоваться кэшированием на промежуточных узлах, типа proxy и gateways (Varnish, Fastly и другие). С одной стороны, GraphQL обеспечивает эффективность выполнения запроса, но с другой стороны, плохо обеспечивает кэширование.
  • Второй минус GraphQL — необходимость в дополнительных проверках отказоустойчивости. Вы сами могли убедиться, что для того чтобы поддерживать безопасную работу такого API, необходимо строить дополнительную защиту, прибегая к анализу сложности и глубины запросов.
  • Обработка ошибок в GraphQL требует дополнительного контракта и соглашений вне стандарта. Имена ошибок и их семантику нужно изобретать самостоятельно.
  • Вы не можете работать с произвольными ресурсами. GraphQL — не универсальное решение для всех типов данных. Вы можете работать с JSON и XML, но, например, загружать файлы на сервер вы вряд ли будете через GraphQL, потому что он не предназначен для этого.
  • В GraphQL нет понятия идемпотентности операции. Например, для изменения состояния на сервере в HTTP можно применять PUT для идемпотентных операций и POST для не-идемпотентных. Это отличие важно, потому что идемпотентные операции можно запросто повторять. Так вот в GraphQL соглашение про идемпотентность не является частью стандарта. Все детали нужно выносить в объяснение и документацию.
  • Нужно придумывать имена операциям. Например, операцию удаления можно назвать по-разному: «delete» или «kill», «annihilate» или «terminate», ну и так далее. Между разными GraphQL API такие соглашения будут разными. Конкретно с HTTP этот пример идеально ложился бы просто на использование метода DELETE.
  • На Joker 2016 я читал доклад о преимуществах гипермедиа. В GraphQL никакой гипермедиа нет и вряд ли появится. Этот API-стиль больше о том, как отдать данные, и меньше о том, как развязать клиент через HATEOAS, и совсем не о том, чтобы построить «правильный REST». Конечно, гипермедиа нужна далеко не всегда, однако GraphQL забирает у нас эту возможность.

Стоит также помнить, что если у вас не получалось хорошо разрабатывать HTTP API, то, скорее всего, не будет получаться разрабатывать и GraphQL API. Ведь что важнее всего в разработке любого API? Отделить внутреннюю модель предметной области от внешней API-модели. Построить API на основе сценариев использования, а не внутреннего устройства приложения. Открыть только необходимый минимум информации, а не все подряд. Выбрать правильные имена. Описать правильно граф. В HTTP API есть граф ресурсов, а в GraphQL API — граф полей. В обоих случаях этот граф нужно сделать качественно.

Например, есть стандарт OData, который поддерживает частичные и раскрывающие выборки, как и GraphQL, и работает поверх HTTP. В мире HTTP API есть альтернативы, и не обязательно всегда использовать GraphQL, когда возникает необходимость в сложных выборках. Есть также LinkRest, подробнее о котором вы можете узнать из https://youtu.be/EsldBtrb1Qc">доклада Андруся Адамчика на Joker 2017. Есть стандарт JSON API, который работает с JSON и поддерживает возможности гипермедиа и сложных выборок.

Для тех, кто желает попробовать GraphQL, я настоятельно советую почитать статьи сравнения от инженеров, которые глубоко разбираются в REST и GraphQL  c практической и философской точек зрения:

Напоследок о Subscriptions и defer

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

Например, возможность организовывать подписки subscriptions. Мы рассматривали с вами получение данных через query, изменение состояния сервера через mutation, но есть еще одна вкусность.

Через GraphQL API это можно сделать на основе такой схемы: Представим себе, что клиент хочет асинхронно получать нотификации о добавлении продукта в корзину.

schema { query: Queries, mutation: Mutations, subscription: Subscriptions
} type Subscriptions { productAdded(cartId: String!): Cart
}

Клиент может оформить подписку через следующий запрос:

subscription { productAdded(cart: 1) { items { product ... } subTotal }
}

Теперь каждый раз, когда продукт добавляется в корзину 1, сервер пошлет каждому подписавшемуся клиенту сообщение по WebSocket с запрошенными данными по корзине. Опять-таки, продолжая политику GraphQL — придут только те данные, которые клиент запрашивал при оформлении подписки:

{ "data": { "productAdded": { "items": [ { "product": …, "subTotal": … }, { "product": …, "subTotal": … }, { "product": …, "subTotal": … }, { "product": …, "subTotal": … } ], "subTotal": 289.33 } }
}

Клиент теперь может перерисовать корзину, не обязательно перерисовывая всю страницу.

Это удобно, потому что как синхронный API (HTTP), так и асинхронный API (WebSocket) можно описать через GraphQL.

Основная идея состоит в том, что клиент выбирает, какие данные он хочет получить сразу (синхронно), и те, которые он готов получить позже (асинхронно). Еще один пример задействования асинхронной коммуникации — это механизм defer. Например, для такого запроса:

query { feedStories { author { name } message comments @defer { author { name } message } }
}

Сервер вначале вернет автора и сообщение для каждой истории:

{ "data": { "feedStories": [ { "author": …, "message": … }, { "author": …, "message": … } ] }
}

После этого сервер, получив данные по комментариям, асинхронно их доставит клиенту через WebSocket, указав в пути, для какой именно истории сейчас готовы комментарии:

{ "path": [ "feedStories", 0, "comments" ], "data": [ { "author": …, "message": … } ]
}

Исходный код примера

Код, который использовался при подготовке этого доклада, вы можете найти на GitHub.

Подробнее о том, чего стоит ждать от конференции, можно узнать из нашего хабрапоста. Совсем недавно мы анонсировали JPoint 2019, который пройдет 5-6 апреля 2019 года. До первого декабря еще доступны Early Bird-билеты по самой низкой цене.

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

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

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

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

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