Статьи

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

Вернуться назад Исходники

5 апреля 2021

Тэги: GraphQL gradle json Collections rest Kotlin

Содержание

  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много
ГибкостьКлиент определяет, какие поля вернёт серверНабор полей фиксирован
Нагрузка на хранилищеЗапрашиваются ровно те данные, которые сейчас необходимыВсегда подгружается полный набор данных

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



Комментарии

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

×

devmark.ru