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

Облако тэгов

Kotlin, Java, 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