5 апреля 2021
Тэги: Collections, gradle, GraphQL, json, Kotlin, rest.
GraphQL – это стандарт клиент-серверного взаимодействия, который позволяет довольно гибко запрашивать данные с сервера. Основное отличие от традиционных REST-запросов состоит в том, что клиент сам выбирает, какие поля он будет запрашивать у сервера, тогда как REST предполагает заранее определённый фиксированный формат. При этом сервер будет подгружать из хранилища ровно те поля, которые необходимы и ничуть не больше.
Внимание! Данная статья устарела и если вы только внедряете GraphQL в новый проект, то см. GraphQL в Spring Boot.
Сначала рассмотрим простой пример интеграции GraphQL в типовой проект на Spring Boot.
Проще всего заготовку проекта инициализировать с помощью Spring Initializr. Там мы выберем в качестве сборщика проекта gradle, а в качестве языка – Kotlin. Из зависимостей выберем только Web. Затем скачаем полученную заготовку и вручную добавим дополнительную зависимость graphql-spring-boot-starter в файл build.gradle.kts. В итоге секция dependecies должна выглядеть так:
Определим модели, которыми будем оперировать. Предположим, мы хотим получать от сервера список книг, а также информацию об авторе каждой книги в одном запросе.
У автора есть уникальный идентификатор, имя и фамилия:
У каждой книги есть уникальный идентификатор, название и ссылка на автора в виде его идентификатора.
То есть эти две сущности связаны отношением «один-ко-многим». У одного автора может быть несколько книг.
Для начала будем возвращать список книг с помощью GraphQL. Определим такой обработчик:
Мы определяем бин, который реализует интерфейс GraphQLQueryResolver. Все публичные методы в таком бине будут доступны для вызова через GraphQL.
Затем создаём метод getBooks() с опциональным параметром limit для ограничения максимального количества записей. Этот параметр может быть явно передан в запросе. Но если он не передан, то по умолчанию мы будем подгружать из репозитория то количество записей, которое определено в константе DEFAULT_LIMIT.
Интерфейс репозитория, работающего с книгами, выглядит так:
В реальном проекте данные о книгах физически хранятся в БД, однако в нашем примере мы определим их хардкодом. Вы же можете определить другую реализацию, которая будет грузить данные при помощи, например, Spring Data.
Чтобы все определённые нами классы стали видны через GraphQL, нам нужно создать схему в папке resources. Схема – это текстовый файл с расширением .graphqls. Содержимое файла для класса книги и метода запроса книг будет выглядеть так:
Определение схемы синтаксически очень похоже на Kotlin, но имеет ряд особенностей. Все классы определяются ключевым словом type. В схеме обязательно должен быть класс Query – именно в нём описываются все доступные методы.
Схема «из коробки» предоставляет довольно ограниченный набор типов. ID используется для идентификаторов, String – для строк и Int – для целых чисел. Наличие восклицательного знака рядом с типом означает, что он не может принимать значение null. Квадратные скобки означают список из элементов указанного типа.
Обратие внимание, что в схеме мы не определяем поле authorId для книги, т.к. это служебное поле. Оно служит только для связи с автором и само по себе ценности для клиента не представляет.
Теперь мы можем запустить проект и выполнить POST-запрос по адресу http://127.0.0.1:8080/graphql. Этот эндпоинт является общим для всех запросов GraphQL, но может быть переопределён в настройках application.yml (параметр graphql.mapping). Содержимое запроса может быть таким:
В ответ получим json из трёх элементов:
Теперь вы можете поиграться с запросом и убрать из него, например, поле id. Тогда в ответ мы получим более компактный json, в котором будет только title. Если же мы уберём в запросе круглые скобки вместе с limit, тогда будет действовать ограничение по умолчанию, т.е. не более 10 элементов в списке.
Мы получили довольно гибкий эндпоинт и при этом не определили ни одного RestController!
Список книг мы получили, но теперь неплохо бы для каждой книги подгрузить ещё и автора. Да всё это в одном запросе.
Создадим специальный компонент, реализующий интерфейс GraphQLResolver. Интерфейс типизируем родительской сущностью, в нашем примере это Book.
Внутри бина создадим метод, который будет по родительской сущности книги возвращать информацию об её авторе. При этом имя метода определяет имя поля в сущности Book, в котором будет лежать эта информация. В самом методе мы берём значение поля authorId родительской сущности и подгружаем информацию из репозитория.
Интерфейс репозитория выглядит так:
Репозиторий авторов также создадим хардкодом, а в реальном приложении мы бы ходили в БД.
Теперь нам осталось добавить в схему GraphQL описание сущности автора, а в сущность Book добавить соответствующее поле. В итоге схема будет выглядеть так:
Следующий запрос вернёт нам не только сами книги, но и информацию об их авторах:
Тогда ответ сервера будет таким:
Если же вы уберёте из запроса поле author и всё, что с ним связано, то AuthorRepository даже не будет вызван! В этом вы можете убедиться, поставив breakpoint внутри метода findById(). Данный пример наглядно демонстрирует, что GraphQL грузит только те данные, которые действительно нужны и ничуть не больше.
При работе с вложенными сущностями в GraphQL есть обратная сторона: так называемая «проблема N+1». Если вы будете запрашивать авторов вместе с книгами, то увидите, что метод findById() будет вызываться ровно столько раз, сколько нам вернётся книг. То есть если книг вернётся 5, то мы запросим не только книги, но и 5 раз сходим в репозиторий за авторами. Если книг будет 10, то всего запросов в БД улетит 11! Это и есть проблема N+1, где N – это количество элементов в родительском списке. При больших выборках это может создавать заметную нагрузку на БД.
По-хорошему, нам было бы достаточно сходить в БД за авторами всего 2 раза, т.к. в нашем примере всего два уникальных автора. А ещё лучше подгрузить всё это за один запрос, просто перечислив уникальные id авторов.
Для решения этой проблемы GraphQL предлагает специальный инструмент, называемый DataLoader. В процессе формирования ответа он позволяет накапливать все id сущностей, чтобы затем их подгрузить за один раз. DataLoader работает в контексте одного запроса, поэтому конфликтов между разными запросами не возникает.
Определим специальный бин, реализующий интерфейс GraphQLServletContextBuilder и переопределим в нём три служебных метода. Их можете скопировать один-в-один.
Все они вызывают приватный метод buildDataLoaderRegistry(), в котором мы будем регистрировать наши DataLoader. Сам метод выглядит так:
Здесь мы вначале создаём реестр, в котором надо зарегистрировать все наши DataLoader. После этого через лямбда-выражение определяем authorLoader, который на вход принимает список id авторов. Затем грузим авторов за один запрос из репозитория в виде мапы. И в конце мапим входящий список на соотвествующих авторов. Обратите внимание, что мы должны вернуть ровно столько элементов и в таком порядке, в каком они поступили на вход в dataLoader. Поэтому и требуется дополнительный перемаппинг в конце.
Сам DataLoader регистрируем в реестре под именем «authorLoader».
В AuthorRepositoryImpl добавим такой метод:
В реальном приложении здесь был бы sql-запрос вида «select * from author where id in (...)».
Ну и наконец в BookResolver надо добавить такой метод:
Аналогично предыдущей реализации, тут мы принимаем родительскую сущность Book, а также DataFetchingEnvironment – текущий контекст выборки данных. Из него мы получаем ранее определённый DataLoader по его названию. Затем с помощью метода load() подгружаем автора. Результат возвращается в виде CompletableFuture, типизированный автором. Это означает, что результат будет вычислен позднее, после накопления всех необходимых id авторов.
Теперь ещё раз выполним запрос книг вместе с авторами и убедимся, что в хранилище все авторы запрашиваются пачкой с помощью одного запроса!
Теперь нам осталось сравнить две концепции между собой:
Свойства | GraphQL | REST |
Кол-во эндпоинтов | 1 | много |
Гибкость | Клиент определяет, какие поля вернёт сервер | Набор полей фиксирован |
Нагрузка на хранилище | Запрашиваются ровно те данные, которые сейчас необходимы | Всегда подгружается полный набор данных |
Исходники тестового приложения вы можете найти в начале этой статьи.
Kotlin, Java, Java 11, Java 8, Spring, Spring Boot, Spring Data, SQL, PostgreSQL, Oracle, Linux, Hibernate, Collections, Stream API, многопоточность, файлы, Nginx, Apache, maven, gradle, JUnit, YouTube, новости, руководство, ООП, алгоритмы, головоломки, rest, GraphQL, Excel, XML, json, yaml.