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

Обработка запросов с помощью GraphQL

Исходники

5 апреля 2021

Тэги: Collections gradle GraphQL json Kotlin rest

Содержание

  1. Добавляем GraphQL в проект
  2. Определяем предметную область
  3. Простой запрос
  4. Схема GraphQL
  5. Выполнение запроса
  6. Запрос с вложенными сущностями
  7. Проблема N+1 и DataLoader
  8. Сравнение GraphQL и REST

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

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

Сначала рассмотрим простой пример интеграции GraphQL в типовой проект на Spring Boot.

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

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("com.graphql-java-kickstart:graphql-spring-boot-starter:11.0.0")
}

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

Определим модели, которыми будем оперировать. Предположим, мы хотим получать от сервера список книг, а также информацию об авторе каждой книги в одном запросе.

У автора есть уникальный идентификатор, имя и фамилия:

data class Author(
    val id: Int,
    val name: String,
    val surname: String
)

У каждой книги есть уникальный идентификатор, название и ссылка на автора в виде его идентификатора.

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

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

Простой запрос

Для начала будем возвращать список книг с помощью GraphQL. Определим такой обработчик:

@Component
class QueryResolver(
    private val bookRepository: BookRepository
) : GraphQLQueryResolver {

    fun getBooks(limit: Int?): List<Book> =
        bookRepository.findAll(limit ?: DEFAULT_LIMIT)

    private companion object {
        const val DEFAULT_LIMIT = 10
    }
}

Мы определяем бин, который реализует интерфейс GraphQLQueryResolver. Все публичные методы в таком бине будут доступны для вызова через GraphQL.

Затем создаём метод getBooks() с опциональным параметром limit для ограничения максимального количества записей. Этот параметр может быть явно передан в запросе. Но если он не передан, то по умолчанию мы будем подгружать из репозитория то количество записей, которое определено в константе DEFAULT_LIMIT.

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

interface BookRepository {
    fun findAll(limit: Int): List<Book>
}

В реальном проекте данные о книгах физически хранятся в БД, однако в нашем примере мы определим их хардкодом. Вы же можете определить другую реализацию, которая будет грузить данные при помощи, например, Spring Data.

override fun findAll(limit: Int): List<Book> =
    BOOKS.take(limit)

// в реальном проекте эти данные хранятся в БД
private companion object {
    val BOOKS = listOf(
        Book(
            id = 1,
            title = "Ромео и Джульетта",
            authorId = 1
        ),
        Book(
            id = 2,
            title = "Гамлет",
            authorId = 1
        ),
        Book(
            id = 3,
            title = "Отелло",
            authorId = 1
        ),
        Book(
            id = 4,
            title = "Евгений Онегин",
            authorId = 2
        )
    )
}

Схема GraphQL

Чтобы все определённые нами классы стали видны через GraphQL, нам нужно создать схему в папке resources. Схема - это текстовый файл с расширением .graphqls. Содержимое файла для класса книги и метода запроса книг будет выглядеть так:

type Book {
    id: ID!,
    title: String!
}

type Query {
    getBooks(limit: Int):[Book!]
}

Определение схемы синтаксически очень похоже на Kotlin, но имеет ряд особенностей. Все классы определяются ключевым словом type. В схеме обязательно должен быть класс Query - именно в нём описываются все доступные методы.

Схема «из коробки» предоставляет довольно ограниченный набор типов. ID используется для идентификаторов, String - для строк и Int - для целых чисел. Наличие восклицательного знака рядом с типом означает, что он не может принимать значение null. Квадратные скобки означают список из элементов указанного типа.

Обратие внимание, что в схеме мы не определяем поле authorId для книги, т.к. это служебное поле. Оно служит только для связи с автором и само по себе ценности для клиента не представляет.

Выполнение запроса

Теперь мы можем запустить проект и выполнить POST-запрос по адресу http://127.0.0.1:8080/graphql. Этот эндпоинт является общим для всех запросов GraphQL, но может быть переопределён в настройках application.yml (параметр graphql.mapping). Содержимое запроса может быть таким:

{  
  "query": "{ getBooks(limit: 3) { id, title } }"
}

В ответ получим json из трёх элементов:

{
    "data": {
        "getBooks": [
            {
                "id": "1",
                "title": "Ромео и Джульетта"
            },
            {
                "id": "2",
                "title": "Гамлет"
            },
            {
                "id": "3",
                "title": "Отелло"
            }
        ]
    }
}

Теперь вы можете поиграться с запросом и убрать из него, например, поле id. Тогда в ответ мы получим более компактный json, в котором будет только title. Если же мы уберём в запросе круглые скобки вместе с limit, тогда будет действовать ограничение по умолчанию, т.е. не более 10 элементов в списке.

Мы получили довольно гибкий эндпоинт и при этом не определили ни одного RestController!

Запрос с вложенными сущностями

Список книг мы получили, но теперь неплохо бы для каждой книги подгрузить ещё и автора. Да всё это в одном запросе.

Создадим специальный компонент, реализующий интерфейс GraphQLResolver. Интерфейс типизируем родительской сущностью, в нашем примере это Book.

@Component
class BookResolver(
    private val authorRepository: AuthorRepository
) : GraphQLResolver<Book> {

    fun author(book: Book): Author =
        authorRepository.findById(book.authorId)
            ?: throw RuntimeException("Author with id=${book.authorId} not found")
}

Внутри бина создадим метод, который будет по родительской сущности книги возвращать информацию об её авторе. При этом имя метода определяет имя поля в сущности Book, в котором будет лежать эта информация. В самом методе мы берём значение поля authorId родительской сущности и подгружаем информацию из репозитория.

Интерфейс репозитория выглядит так:

interface AuthorRepository {
    fun findById(id: Int): Author?
}

Репозиторий авторов также создадим хардкодом, а в реальном приложении мы бы ходили в БД.

@Repository
class AuthorRepositoryImpl : AuthorRepository {

    override fun findById(id: Int): Author? =
        AUTHORS[id]

    // в реальном проекте эти данные хранятся в БД
    private companion object {
        val AUTHORS = mapOf(
            1 to Author(
                id = 1,
                name = "Уильям",
                surname = "Шекспир"
            ),
            2 to Author(
                id = 2,
                name = "Александр",
                surname = "Пушкин"
            )
        )
    }
}

Теперь нам осталось добавить в схему GraphQL описание сущности автора, а в сущность Book добавить соответствующее поле. В итоге схема будет выглядеть так:

type Book {
    id: ID!,
    title: String!,
    author: Author!
}

type Author {
    id: ID!,
    name: String!,
    surname: String!
}

Следующий запрос вернёт нам не только сами книги, но и информацию об их авторах:

{  
  "query": "{ getBooks(limit: 3) { title, author { name, surname } } }"
}

Тогда ответ сервера будет таким:

{
    "data": {
        "getBooks": [
            {
                "title": "Ромео и Джульетта",
                "author": {
                    "name": "Уильям",
                    "surname": "Шекспир"
                }
            },
// ...

Если же вы уберёте из запроса поле author и всё, что с ним связано, то AuthorRepository даже не будет вызван! В этом вы можете убедиться, поставив breakpoint внутри метода findById(). Данный пример наглядно демонстрирует, что GraphQL грузит только те данные, которые действительно нужны и ничуть не больше.

Проблема N+1 и DataLoader

При работе с вложенными сущностями в GraphQL есть обратная сторона: так называемая «проблема N+1». Если вы будете запрашивать авторов вместе с книгами, то увидите, что метод findById() будет вызываться ровно столько раз, сколько нам вернётся книг. То есть если книг вернётся 5, то мы запросим не только книги, но и 5 раз сходим в репозиторий за авторами. Если книг будет 10, то всего запросов в БД улетит 11! Это и есть проблема N+1, где N - это количество элементов в родительском списке. При больших выборках это может создавать заметную нагрузку на БД.

По-хорошему, нам было бы достаточно сходить в БД за авторами всего 2 раза, т.к. в нашем примере всего два уникальных автора. А ещё лучше подгрузить всё это за один запрос, просто перечислив уникальные id авторов.

Для решения этой проблемы GraphQL предлагает специальный инструмент, называемый DataLoader. В процессе формирования ответа он позволяет накапливать все id сущностей, чтобы затем их подгрузить за один раз. DataLoader работает в контексте одного запроса, поэтому конфликтов между разными запросами не возникает.

Определим специальный бин, реализующий интерфейс GraphQLServletContextBuilder и переопределим в нём три служебных метода. Их можете скопировать один-в-один.

@Component
class CustomGraphQLContextBuilder(
    private val authorRepository: AuthorRepository
) : GraphQLServletContextBuilder {

    override fun build(): GraphQLContext {
        return DefaultGraphQLContext(buildDataLoaderRegistry(), null)
    }

    override fun build(request: HttpServletRequest, response: HttpServletResponse): GraphQLContext {
        return DefaultGraphQLServletContext.createServletContext(buildDataLoaderRegistry(), null)
            .with(request)
            .with(response)
            .build()
    }

    override fun build(session: Session, request: HandshakeRequest): GraphQLContext {
        return DefaultGraphQLWebSocketContext.createWebSocketContext(buildDataLoaderRegistry(), null)
            .with(session)
            .with(request)
            .build()
    }
    // ...
}

Все они вызывают приватный метод buildDataLoaderRegistry(), в котором мы будем регистрировать наши DataLoader. Сам метод выглядит так:

private fun buildDataLoaderRegistry(): DataLoaderRegistry {
    val dataLoaderRegistry = DataLoaderRegistry()
    val authorLoader = DataLoader { authorIds: List<Int> ->
        supplyAsync {
            val authors = authorRepository.findAllByIds(authorIds.toSet())
            authorIds.map { id -> authors[id] }
        }
    }
    dataLoaderRegistry.register("authorLoader", authorLoader)
    return dataLoaderRegistry
}

Здесь мы вначале создаём реестр, в котором надо зарегистрировать все наши DataLoader. После этого через лямбда-выражение определяем authorLoader, который на вход принимает список id авторов. Затем грузим авторов за один запрос из репозитория в виде мапы. И в конце мапим входящий список на соотвествующих авторов. Обратите внимание, что мы должны вернуть ровно столько элементов и в таком порядке, в каком они поступили на вход в dataLoader. Поэтому и требуется дополнительный перемаппинг в конце.

Сам DataLoader регистрируем в реестре под именем «authorLoader».

В AuthorRepositoryImpl добавим такой метод:

override fun findAllByIds(ids: Set<Int>): Map<Int, Author> =
    AUTHORS.filterKeys { id -> id in ids }

В реальном приложении здесь был бы sql-запрос вида «select * from author where id in (...)».

Ну и наконец в BookResolver надо добавить такой метод:

fun author(book: Book, dfe: DataFetchingEnvironment): CompletableFuture<Author> {
    val registry: DataLoaderRegistry = (dfe.getContext() as GraphQLContext).dataLoaderRegistry
    val authorLoader = registry.getDataLoader<Int, Author>("authorLoader")
    return authorLoader?.let { authorLoader.load(book.authorId) }
        ?: throw RuntimeException("Author data loader not found")
}

Аналогично предыдущей реализации, тут мы принимаем родительскую сущность Book, а также DataFetchingEnvironment - текущий контекст выборки данных. Из него мы получаем ранее определённый DataLoader по его названию. Затем с помощью метода load() подгружаем автора. Результат возвращается в виде CompletableFuture, типизированный автором. Это означает, что результат будет вычислен позднее, после накопления всех необходимых id авторов.

Теперь ещё раз выполним запрос книг вместе с авторами и убедимся, что в хранилище все авторы запрашиваются пачкой с помощью одного запроса!

Сравнение GraphQL и REST

Теперь нам осталось сравнить две концепции между собой:

СвойстваGraphQLREST
Кол-во эндпоинтов1много
ГибкостьКлиент определяет, какие поля вернёт серверНабор полей фиксирован
Нагрузка на хранилищеЗапрашиваются ровно те данные, которые сейчас необходимыВсегда подгружается полный набор данных

Исходники тестового приложения вы можете найти в начале этой статьи.


Облако тэгов

Kotlin, Java, Java 16, Java 11, Java 10, Java 9, Java 8, Spring, Spring Boot, Spring Data, SQL, PostgreSQL, Oracle, Hibernate, Collections, Stream API, многопоточность, ввод-вывод, Apache, maven, gradle, JUnit, YouTube, новости, ООП, алгоритмы, головоломки, rest, GraphQL, Excel, XML, json, yaml

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


Комментарии

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

×

devmark.ru