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, H2, Linux, Hibernate, Collections, Stream API, многопоточность, чат-боты, нейросети, файлы, devops, Docker, Nginx, Apache, maven, gradle, JUnit, YouTube, новости, руководство, ООП, алгоритмы, головоломки, rest, GraphQL, Excel, XML, json, yaml.