Статьи
Утилиты Telegram 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, который упрощает написание отладочных запросов благодаря автодополнению.



Комментарии

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

×

devmark.ru