27 марта 2024
Тэги: Collections, gradle, GraphQL, json, Kotlin, Spring Boot, руководство.
GraphQL – это стандарт клиент-серверного взаимодействия, который позволяет довольно гибко запрашивать данные с сервера. Основное отличие от традиционных REST-запросов состоит в том, что клиент сам выбирает, какие поля он будет запрашивать у сервера, тогда как REST предполагает заранее определённый фиксированный формат. При этом сервер будет подгружать из хранилища ровно те поля, которые необходимы и ничуть не больше.
Проще всего заготовку проекта инициализировать с помощью Spring Initializr. Там мы выберем в качестве сборщика проекта gradle, а в качестве языка – Kotlin. Из зависимостей выберем Spring Web и Spring for GraphQL. В итоге секция dependecies в файле build.gradle.kts должна выглядеть так:
Рабочую версию проекта вы можете найти на github.
В качестве примера наше приложение будет возвращать список книг. У каждой книги есть идентификатор, название и id автора. Такую модель мы представим в виде data-класса:
Также представим автора книги набором полей: id автора, имя и фамилия.
Автор связан с книгами отношением «один-ко-многим», т.е. у одного автора может быть несколько книг.
Теперь давайте создадим слой хранилища данных. В реальном приложении за данными мы будем ходить в БД, но сейчас нам это не принципиально, поэтому данные просто захардкодим.
Репозиторий книг выглядит следующим образом:
Единственный метод getAll() принимает параметр limit и ограничивает результат с помощью метода take(), беря данные из подготовленного нами списка.
Теперь определим репозиторий авторов:
Здесь есть метод getById(), который по id автора пытается найти его в заранее подготовленной мапе. Если такого id нет, то метод кидает исключение.
Теперь определим контроллер, который будет содержать все публичные методы, доступные в GraphQL. Сразу заинжектим в него созданные нами репозитории.
Аннотацией @QueryMapping мы помечаем каждый публичный метод, который должен быть доступен на клиентской стороне. В аннотации мы также указываем имя, под которым данный метод будет доступен в graphql. Поскольку в нашем примере реальное имя и имя в аннотации совпадает, имя можно явно не указывать.
Метод принимает параметр limit. Каждый параметр метода нужно обязательно пометить аннотацией @Argument и в ней указать имя этого параметра в graphql. Поскольку имя параметра тут тоже совпадает, то имя можно явно не указывать.
В самом методе мы просто вызываем репозиторий книг для получения списка. В реальных приложениях вызывать репозиторий напрямую из контроллера неправильно. Между ними должен быть ещё сервисный слой. Но в данном случае пропустим его для краткости.
Добавим в контроллер второй метод и назовём его author(). Он принимает в качестве параметра Book. Этот метод будет вызываться при необходимости самим движком GraphQL, если вместе с книгой пользователь запросит и автора. Если не запросит, то вызова не произойдёт. Аннотация @SchemaMapping говорит, что данный метод отвечает за обработку поля author в сущности Book.
Аннотации @QueryMapping и @SchemaMapping могут находиться только в классе, помеченном аннотацией @Controller. Если вы разместите эти аннотации в другом Spring-компоненте (например, @Component или @Service), аннотации работать не будут.
Теперь чтобы весь написанный нами код был доступен движку GraphQL, нужно создать файл схемы, который называется schema.graphqls. Этот файл надо положить в папку resources.
Здесь используется специальный язык разметки graphql, похожий на kotlin. Каждый созданный нами data-класс обозначается в нём как type. ID – специальный тип для идентификатора. По умолчанию все типы допускают null-значения, но наличие восклицательного знака рядом с типом обозначает, что данное поле НЕ допускает null. Комментарии начинаются с символа решётки.
Тип с именем Query содержит в себе все доступные публичные методы. В нашем случае там только allBooks() с параметром. Квадратные скобки вокруг Book говорят, что мы возвращаем список таких сущностей.
Теперь мы почти готовы к запуску проекта. Эндпоинт для любых запросов graphql используется один и тот же: /graphql. Но вручную писать запросы не очень удобно, поэтому мы активируем отладочный веб-интерфейс graphiql, который идёт в комплекте с движком GraphQL. Нам требуется добавить всего одну строку в application.properties:
Теперь запускаем приложение и переходим по урлу http://127.0.0.1:8080/graphiql. Мы увидим веб-интерфейс из двух панелей. Давайте введём в левой панели следующий запрос:
По мере ввода у вас будет работать автодополнение. Также вы сможете посмотреть описание каждого поля, которое мы сделали в комментариях в схеме graphql.
Данный запрос будет возвращать нам список книг без авторов (т.к. мы не указали поле author) и ограничивать его по указанному лимиту.
Теперь если мы захотим посмотреть ещё и фамилии авторов, просто расширим наш запрос:
В ответ получим следующее:
Как видите, graphql возвращает только те поля, которые мы запросили, несмотря на то, что на самом деле их больше.
Если вы делаете запрос книг вместе с авторами, то увидите по записям в логах, что для каждой книги делается отдельный запрос для поиска автора по id (для наглядности поставьте лимит побольше). Всего в нашем примере 2 автора и 4 книги. И поскольку книг 4, то будет выполнено 4 запроса поиска авторов. Вместе с запросом списка книг, всего получается, к репозиториям мы делаем 5 запросов вместо 2! Это и есть проблема N+1, когда для каждого дочернего элемента результирующего списка выполняется дополнительный запрос.
Чтобы решить эту проблему, нам нужно модифицировать классы AuthorRepository и BookQuery.
В репозиторий авторов добавляем метод getAllByIds(), который получает набор уникальных id и извлекает записи из хранилища (в нашем случае просто фильтрует мапу).
Перейдём к классу BookQuery. Удаляем оттуда второй метод, который называется author(). И вместо него добавляем следующее:
Аннотация @BatchMapping говорит о том, что данный метод author() используется для пакетной обработки всех книг за 1 раз. Он получает список книг, которые будут возвращены в ответе. Параметр typeName в аннотации позволяет указать имя родительской сущности (т.е. книги) из схемы graphql. Параметр field позволяет указать имя поля дочерней сущности (т.е. автора) в родительской сущности graphql. Поскольку в нашем случае имена в коде и имена в схеме graphql совпадают, явный маппинг можно не делать.
Из списка полученных книг мы извлекаем все уникальные id авторов и преобразуем их в Set. Затем за 1 вызов метода getAllByIds() подгружаем авторов и делаем мапу с помощью associateWith(), в которой ключом является книга, а значением – её автор. То есть мы явно сопоставили списку входных значений список дочерних сущностей в виде мапы.
Теперь, если вы перезапустите приложение и ещё раз выполните запрос книг с авторами, то по логам увидите, что выполнилось ровно 2 запроса: один для книг и один для авторов.
Стандартный набор типов GraphQL крайне мал: Int, Float, String, Boolean и ID. Но что делать, если для каждой книги мы хотим отображать её цену как BigDecimal, а для каждого автора – дату его рождения как LocalDate? Сначала доработаем наше приложение и добавим в data-классы новые поля. Также эти значения потребуется проинициализировать в репозиториях-заглушках.
Затем добавим в зависимости проекта библиотеку com.graphql-java:graphql-java-extended-scalars, которая расширяет стандартный набор типов GraphQL.
После этого создадим новую конфигурацию и в ней бин runtimeWiringConfigurer(), который активирует нужные нам типы Date и GraphQLBigDecimal:
Все доступные типы содержатся как константы в классе ExtendedScalars. Их там порядка 25.
В завершение дополним файл schema.graphqls.
Сначала добавим определения для новых типов с помощью ключевого слова scalar:
Затем добавим поле price к типу Book и поле birthDate к типу Author:
После перезапуска веб-интерфейса graphiQL мы увидим новые поля и сможем добавлять их в запрос.
Kotlin, Java, 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.