Статьи
YouTube-канал

GraphQL в Spring Boot

Видеогайд Исходники

30 октября 2022

Тэги: Collections, gradle, GraphQL, json, Kotlin, Spring Boot, руководство.

Содержание

  1. Добавляем GraphQL в проект
  2. Определяем предметную область
  3. Хранилище данных
  4. Контроллер
  5. Схема GraphQL
  6. Веб-интерфейс для отладки
  7. Тестирование
  8. Решаем проблему N+1
  9. Итоги

GraphQL - это стандарт клиент-серверного взаимодействия, который позволяет довольно гибко запрашивать данные с сервера. Основное отличие от традиционных REST-запросов состоит в том, что клиент сам выбирает, какие поля он будет запрашивать у сервера, тогда как REST предполагает заранее определённый фиксированный формат. При этом сервер будет подгружать из хранилища ровно те поля, которые необходимы и ничуть не больше.

Ранее я уже писал статью Обработка запросов с помощью GraphQL по этой теме, но основной компонент, используемый в ней, уже устарел и не входит в стандартный набор компонентов Spring Boot.

Добавляем GraphQL в проект

Проще всего заготовку проекта инициализировать с помощью Spring Initializr. Там мы выберем в качестве сборщика проекта gradle, а в качестве языка - Kotlin. Из зависимостей выберем Spring Web и Spring for GraphQL. В итоге секция dependecies в файле build.gradle.kts должна выглядеть так:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-graphql")
    implementation("org.springframework.boot:spring-boot-starter-web")
    // другие стандартные зависимости...
}

Рабочую версию проекта вы можете найти на github.

Определяем предметную область

В качестве примера наше приложение будет возвращать список книг. У каждой книги есть идентификатор, название и id автора. Такую модель мы представим в виде data-класса:

data class Book(
    val id: Int,
    val name: String,
    val authorId: Int,
)

Также представим автора книги набором полей: id автора, имя и фамилия.

data class Author(
    val id: Int,
    val firstName: String,
    val lastName: String,
)

Автор связан с книгами отношением «один-ко-многим», т.е. у одного автора может быть несколько книг.

Хранилище данных

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

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

@Repository
class BookRepository {

    fun getAll(limit: Int): List<Book> =
        BOOKS
            .take(limit)
            .also { println("Get all books, limit: $limit.") }

    private companion object {
        val BOOKS = listOf(
            Book(11, "Война и мир", 1),
            Book(12, "Евгений Онегин", 2),
            Book(13, "Воскресение", 1),
            Book(14, "Анна Каренина", 1),
        )
    }
}

Единственный метод getAll() принимает параметр limit и ограничивает результат с помощью метода take(), беря данные из подготовленного нами списка.

Теперь определим репозиторий авторов:

@Repository
class AuthorRepository {

    fun getById(authorId: Int): Author {
        println("Find author by id: $authorId")
        return AUTHORS[authorId]
            ?: throw RuntimeException("Not found")
    }

    private companion object {
        val AUTHORS = listOf(
            Author(1, "Лев", "Толстой"),
            Author(2, "Александр", "Пушкин"),
        ).associateBy { it.id }
    }
}

Здесь есть метод getById(), который по id автора пытается найти его в заранее подготовленной мапе. Если такого id нет, то метод кидает исключение.

Контроллер

Теперь определим контроллер, который будет содержать все публичные методы, доступные в GraphQL. Сразу заинжектим в него созданные нами репозитории.

@Controller
class BookQuery(
    private val bookRepository: BookRepository,
    private val authorRepository: AuthorRepository,
) {

    @QueryMapping
    fun allBooks(@Argument limit: Int): List<Book> =
        bookRepository.getAll(limit)

    // ...
}

Аннотацией @QueryMapping мы помечаем каждый публичный метод, которые должен быть доступен на клиентской стороне. В данном случае метод называется allBooks() и принимает он единственный параметр limit. Этот параметр нужно обязательно пометить аннотацией @Argument. В самом методе мы просто вызываем репозиторий книг для получения списка.

В «боевых» приложениях вызывать репозиторий напрямую из контроллера неправильно. Между ними должен быть ещё сервисный слой. Но в данном случае пропустим его для краткости.

@SchemaMapping(typeName = "Book", field = "author")
fun author(book: Book): Author =
    authorRepository.getById(book.authorId)

Добавим в контроллер второй метод и назовём его author(). Он принимает в качестве параметра Book. Этот метод будет вызываться при необходимости самим движком GraphQL, если вместе с книгой пользователь запросит и автора. Если не запросит, то вызова не произойдёт. Аннотация @SchemaMapping говорит, что данный метод отвечает за обработку поля author в сущности Book.

Схема GraphQL

Теперь чтобы весь написанный нами код был доступен движку GraphQL, нужно создать файл схемы, который называется schema.graphqls. Этот файл надо положить в папку resources.

# Книга
type Book {
    id: ID!
    # Название книги
    name: String!
    # Автор книги
    author: Author!
}

# Автор книги
type Author {
    id: ID!
    # Имя автора
    firstName: String!
    # Фамилия автора
    lastName: String!
}

# Публичные методы GraphQL
type Query {
    # Список книг
    allBooks(limit: Int): [Book]
}

Здесь используется специальный язык разметки graphql, похожий на kotlin. Каждый созданный нами data-класс обозначается в ней как type. ID - специальный тип для идентификатора. Наличие восклицательного знака после названия типа обозначает, что данное поле НЕ допускает null. Комментарии начинаются с символа решётки.

Тип с именем Query содержит в себе все доступные публичные методы. В нашем случае там только allBooks() с параметром. Квадратные скобки вокруг Book говорят, что мы возвращаем список таких сущностей.

Веб-интерфейс для отладки

Теперь мы почти готовы к запуску проекта. Эндпоинт для любых запросов graphql используется один и тот же: /graphql. Но вручную писать запросы не очень удобно, поэтому мы активируем отладочный веб-интерфейс graphiql, который идёт в этом же компоненте. Нам требуется добавить всего одну строку в application.properties:

spring.graphql.graphiql.enabled=true

Тестирование

Теперь запускаем приложение и переходим по урлу http://127.0.0.1:8080/graphiql. Мы увидим веб-интерфейс из двух панелей. Давайте введём в левой панели следующий запрос:

{
  allBooks(limit: 2) {
    id
    name
  }
}

По мере ввода у вас будет работать автодополнение. Также вы сможете посмотреть описание каждого поля, которое мы сделали в комментариях в схеме graphql.

Данный запрос будет возвращать нам список книг без авторов (т.к. мы не указали поле author) и ограничивать его по указанному лимиту.

{
  "data": {
    "allBooks": [
      {
        "id": "11",
        "name": "Война и мир"
      },
      {
        "id": "12",
        "name": "Евгений Онегин"
      }
    ]
  }
}

Теперь если мы захотим посмотреть ещё и фамилии авторов, просто расширим наш запрос:

{
  allBooks(limit: 2) {
    id
    name
    author {
      lastName
    }
  }
}

В ответ получим следующее:

{
  "data": {
    "allBooks": [
      {
        "id": "11",
        "name": "Война и мир",
        "author": {
          "lastName": "Толстой"
        }
      },
      {
        "id": "12",
        "name": "Евгений Онегин",
        "author": {
          "lastName": "Пушкин"
        }
      }
    ]
  }
}

Как видите, graphql возвращает только те поля, которые мы запросили, несмотря на то, что на самом деле их больше.

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

Если вы делаете запрос книг вместе с авторами, то увидите по записям в логах, что для каждой книги делается отдельный запрос для поиска автора по id (для наглядности поставьте лимит побольше). В нашем примере 2 автора и 4 книги. И поскольку книг 4, то будет выполнено 4 запроса поиска авторов. Вместе с запросом списка книг, всего получается, к репозиториям мы делаем 5 запросов вместо 2! Это и есть проблема N+1, когда для каждого элемента результирующего списка выполняется дополнительный запрос.

Чтобы решить эту проблему, нам нужно модифицировать классы AuthorRepository и BookQuery.

В репозиторий авторов добавляем метод getAllByIds(), который получает набор уникальных id и извлекает записи из хранилища (в нашем случае просто фильтрует мапу).

fun getAllByIds(ids: Set<Int>): Map<Int, Author> =
    AUTHORS
        .filterKeys { it in ids }
        .also { println("Get by ids: $ids") }

Перейдём к классу BookQuery. Удаляем оттуда второй метод, который называется author(). И вместо него добавляем следующее:

@BatchMapping
fun author(books: List<Book>): Mono<Map<Book, Author>> {
    val ids = books.map { it.authorId }.toSet()
    val authors = authorRepository.getAllByIds(ids)
    val resultMapping = books.associateWith { authors.getValue(it.authorId) }
    return Mono.just(resultMapping)
}

Аннотация @BatchMapping говорит о том, что данный метод author() используется для пакетной обработки всех книг за 1 раз. Он получает список книг, которые будут возвращены в ответе. Из этих книг мы извлекаем все уникальные id авторов. Затем за 1 вызов репозитория getAllByIds() подгружаем авторов и делаем мапу с помощью associateWith(), в которой ключом является книга, а значением - соответствующий автор. В конце просто оборачиваем полученную мапу в Mono.just(), т.к. данный метод должен иметь сигнатуру в реактивном стиле.

Теперь если вы перезапустите приложение и ещё раз выполните запрос книг с авторами, то по логам увидите, что выполнилось ровно 2 запроса: один для книг и один для авторов.

Итоги

Мы наглядно убедились, что компонент spring-boot-starter-graphql предоставляет гибкий механизм для запроса только нужной информации. Набор полей определяется клиентом. И если какие-то поля не требуются, то их обработка даже не будет выполняться. То есть можно «на ходу» оптимизировать запрос без каких-либо правок на стороне сервера.

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


Облако тэгов

Kotlin, Java, Java 16, Java 11, Java 10, Java 9, 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.

Последние статьи


Комментарии

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

×

devmark.ru