30 октября 2022
Тэги: Collections, gradle, GraphQL, json, Kotlin, Spring Boot, руководство.
GraphQL – это стандарт клиент-серверного взаимодействия, который позволяет довольно гибко запрашивать данные с сервера. Основное отличие от традиционных REST-запросов состоит в том, что клиент сам выбирает, какие поля он будет запрашивать у сервера, тогда как REST предполагает заранее определённый фиксированный формат. При этом сервер будет подгружать из хранилища ровно те поля, которые необходимы и ничуть не больше.
Ранее я уже писал статью Обработка запросов с помощью GraphQL по этой теме, но основной компонент, используемый в ней, уже устарел и не входит в стандартный набор компонентов Spring Boot.
Проще всего заготовку проекта инициализировать с помощью 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 мы помечаем каждый публичный метод, которые должен быть доступен на клиентской стороне. В данном случае метод называется allBooks() и принимает он единственный параметр limit. Этот параметр нужно обязательно пометить аннотацией @Argument. В самом методе мы просто вызываем репозиторий книг для получения списка.
В «боевых» приложениях вызывать репозиторий напрямую из контроллера неправильно. Между ними должен быть ещё сервисный слой. Но в данном случае пропустим его для краткости.
Добавим в контроллер второй метод и назовём его author(). Он принимает в качестве параметра Book. Этот метод будет вызываться при необходимости самим движком GraphQL, если вместе с книгой пользователь запросит и автора. Если не запросит, то вызова не произойдёт. Аннотация @SchemaMapping говорит, что данный метод отвечает за обработку поля author в сущности Book.
Теперь чтобы весь написанный нами код был доступен движку GraphQL, нужно создать файл схемы, который называется schema.graphqls. Этот файл надо положить в папку resources.
Здесь используется специальный язык разметки graphql, похожий на kotlin. Каждый созданный нами data-класс обозначается в ней как type. ID – специальный тип для идентификатора. Наличие восклицательного знака после названия типа обозначает, что данное поле НЕ допускает null. Комментарии начинаются с символа решётки.
Тип с именем Query содержит в себе все доступные публичные методы. В нашем случае там только allBooks() с параметром. Квадратные скобки вокруг Book говорят, что мы возвращаем список таких сущностей.
Теперь мы почти готовы к запуску проекта. Эндпоинт для любых запросов graphql используется один и тот же: /graphql. Но вручную писать запросы не очень удобно, поэтому мы активируем отладочный веб-интерфейс graphiql, который идёт в этом же компоненте. Нам требуется добавить всего одну строку в 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 раз. Он получает список книг, которые будут возвращены в ответе. Из этих книг мы извлекаем все уникальные id авторов. Затем за 1 вызов репозитория getAllByIds() подгружаем авторов и делаем мапу с помощью associateWith(), в которой ключом является книга, а значением – соответствующий автор. В конце просто оборачиваем полученную мапу в Mono.just(), т.к. данный метод должен иметь сигнатуру в реактивном стиле.
Теперь если вы перезапустите приложение и ещё раз выполните запрос книг с авторами, то по логам увидите, что выполнилось ровно 2 запроса: один для книг и один для авторов.
Мы наглядно убедились, что компонент spring-boot-starter-graphql предоставляет гибкий механизм для запроса только нужной информации. Набор полей определяется клиентом. И если какие-то поля не требуются, то их обработка даже не будет выполняться. То есть можно «на ходу» оптимизировать запрос без каких-либо правок на стороне сервера.
Также компонент предоставляет удобный веб-интерфейс graphiql, который упрощает написание отладочных запросов благодаря автодополнению.
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.