Статьи
Утилиты Telegram YouTube Отзывы

GraphQL в Spring Boot

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

27 марта 2024

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

Содержание

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

Логотип GraphQL

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

Добавляем 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("allBooks")
    fun allBooks(@Argument("limit") limit: Int): List<Book> =
        bookRepository.getAll(limit)

    // ...
}

Аннотацией @QueryMapping мы помечаем каждый публичный метод, который должен быть доступен на клиентской стороне. В аннотации мы также указываем имя, под которым данный метод будет доступен в graphql. Поскольку в нашем примере реальное имя и имя в аннотации совпадает, имя можно явно не указывать.

Метод принимает параметр limit. Каждый параметр метода нужно обязательно пометить аннотацией @Argument и в ней указать имя этого параметра в graphql. Поскольку имя параметра тут тоже совпадает, то имя можно явно не указывать.

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

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

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

Аннотации @QueryMapping и @SchemaMapping могут находиться только в классе, помеченном аннотацией @Controller. Если вы разместите эти аннотации в другом Spring-компоненте (например, @Component или @Service), аннотации работать не будут.

Схема 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-значения, но наличие восклицательного знака рядом с типом обозначает, что данное поле НЕ допускает null. Комментарии начинаются с символа решётки.

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

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

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

spring.graphql.graphiql.enabled=true

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

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

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

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

GraphiQL – отладочный веб-интерфейс для 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(typeName = "Book", field = "author")
fun author(books: List<Book>): Map<Book, Author> {
    val ids = books.map { it.authorId }.toSet()
    val authors = authorRepository.getAllByIds(ids)
    return books.associateWith { authors.getValue(it.authorId) }
}

Аннотация @BatchMapping говорит о том, что данный метод author() используется для пакетной обработки всех книг за 1 раз. Он получает список книг, которые будут возвращены в ответе. Параметр typeName в аннотации позволяет указать имя родительской сущности (т.е. книги) из схемы graphql. Параметр field позволяет указать имя поля дочерный сущности (т.е. автора) в родительской сущности graphql. Поскольку в нашем случае имена в коде и имена в схеме graphql совпадают, явный маппинг можно не делать.

Из списка полученных книг мы извлекаем все уникальные id авторов и преобразуем их в Set. Затем за 1 вызов метода getAllByIds() подгружаем авторов и делаем мапу с помощью associateWith(), в которой ключом является книга, а значением – её автор. То есть мы явно сопоставили списку входных значений список дочерних сущностей в виде мапы.

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

Добавление кастомных типов

Стандартный набор типов GraphQL крайне мал: Int, Float, String, Boolean и ID. Но что делать, если для каждой книги мы хотим отображать её цену как BigDecimal, а для каждого автора – дату его рождения как LocalDate? Сначала доработаем наше приложение и добавим в data-классы новые поля. Также эти значения потребуется проинициализировать в репозиториях-заглушках.

data class Book(
    // ...
    val price: BigDecimal,
)

data class Author(
    // ...
    val birthDate: LocalDate,
)

Затем добавим в зависимости проекта библиотеку com.graphql-java:graphql-java-extended-scalars, которая расширяет стандартный набор типов GraphQL.

dependencies {
    // ...
    implementation("com.graphql-java:graphql-java-extended-scalars:21.0")
}

После этого создадим новую конфигурацию и в ней бин runtimeWiringConfigurer(), который активирует нужные нам типы Date и GraphQLBigDecimal:

@Configuration
class ExtendedScalarTypeConfig {
    @Bean
    fun runtimeWiringConfigurer(): RuntimeWiringConfigurer {
        return RuntimeWiringConfigurer { wiringBuilder: RuntimeWiring.Builder ->
            wiringBuilder
                .scalar(ExtendedScalars.Date)
                .scalar(ExtendedScalars.GraphQLBigDecimal)
        }
    }
}

Все доступные типы содержатся как константы в классе ExtendedScalars. Их там порядка 25.

В завершение дополним файл schema.graphqls.

Сначала добавим определения для новых типов с помощью ключевого слова scalar:

scalar Date
scalar BigDecimal

Затем добавим поле price к типу Book и поле birthDate к типу Author:

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

type Author {
    id: ID!
    # Имя автора
    firstName: String!
    # Фамилия автора
    lastName: String!
    # Дата рождения
    birthDate: Date!
}

После перезапуска веб-интерфейса graphiQL мы увидим новые поля и сможем добавлять их в запрос.

Graphiql с кастомными типами данных

Итоги

  • Компонент spring-boot-starter-graphql предоставляет гибкий механизм для запроса только нужной информации. Набор полей определяется клиентом. И если какие-то поля не требуются, то их обработка даже не будет выполняться. То есть можно «на ходу» оптимизировать запрос без каких-либо правок на стороне сервера.
  • Аннотация @BatchMapping эффективно решает проблему N+1, агрегируя все дочерние сущности в 1 запрос.
  • Для расширения стандартного набора типов GraphQL можно использовать библиотеку graphql-java-extended-scalars.
  • Компонент предоставляет удобный веб-интерфейс graphiql, который упрощает написание отладочных запросов благодаря автодополнению.


Комментарии

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

×

devmark.ru