Как стать автором
Обновить

Генерируем Kotlin клиент по GraphQL схеме

Время на прочтение6 мин
Количество просмотров4.1K
Запомните, если вы не бросите REST, очень скоро разоритесь...
Слово «Kotlin» и слово «GraphQL» для вас означают одно и то же!
Запомните, если вы не бросите REST, очень скоро разоритесь... Слово «Kotlin» и слово «GraphQL» для вас означают одно и то же!

С одной стороны, GraphQL схема однозначно определяет модель данных и доступные операции реализующего ее сервиса. С другой, Kotlin предоставляет потрясающие возможности для создания предметно-ориентированных языков (DSL). Таким образом, возможно написать предметно-ориентированный язык для взаимодействия с GraphQL сервисом в соответствии с опубликованной схемой. Но, написание такого кода вручную, это сизифов труд. Лучше его просто генерировать. И в этом нам поможет плагин Kobby. Он анализирует GraphQL схему и генерирует клиентский DSL. Давайте попробуем его в деле!

Что у нас получится в итоге?

GraphQL:

query {
  film(id: 0) {
    id
    title
    actors {
      id
      firstName
      lastName
    }
  }
}

Kotlin:

val result = context.query {
    film(id = 0L) {
        id()
        title()
        actors {
            id()
            firstName()
            lastName()
        }
    }
}

GraphQL:

mutation {
  createFilm(title: "My Film") {
    id
    title
  }
}

Kotlin:

val result = context.mutation {
    createFilm(title = "My Film") {
        id()
        title()
    }
}

GraphQL:

subscription {
  filmCreated {
    id
    title
  }
}

Kotlin:

launch(Dispatchers.Default) {
    context.subscription {
        filmCreated {
            id()
            title()
        }
    }.subscribe {
        while (true) {
            val result = receive()
        }
    }
}

Исходный код всех примеров доступен на GitHub в проектах Kobby Gradle Tutorial и Kobby Maven Tutorial.


Конфигурация плагина

Начнем со схемы нашего сервиса. По умолчанию Kobby ищет GraphQL схему в файлах с расширением graphqls в ресурсах проекта. Для простоты разместим нашу схему в одном файле cinema.graphqls:

type Query {
    film(id: ID!): Film
    films: [Film!]!
}

type Mutation {
    createFilm(title: String!): Film!
}

type Subscription {
    filmCreated: Film!
}

type Film {
    id: ID!
    title: String!
    actors: [Actor!]!
}

type Actor {
    id: ID!
    firstName: String!
    lastName: String
}

Эта простая схема позволит нам опробовать все виды операций GraphQL - запросы, мутации и подписки.

Далее нам нужно настроить сам плагин. Для Gradle это просто:

plugins {
    kotlin("jvm")
    id("io.github.ermadmi78.kobby") version "1.3.0"
}

dependencies {
    // Add this dependency to enable 
    // Jackson annotation generation in DTO classes
    compileOnly("com.fasterxml.jackson.core:jackson-annotations:2.12.2")

    // Add this dependency to enable 
    // default Ktor adapters generation
    compileOnly("io.ktor:ktor-client-cio:1.5.4")
}

Конфигурация плагина для Maven не столь элегантна:

<project>
    <build>
        <plugins>
            <plugin>
                <groupId>io.github.ermadmi78</groupId>
                <artifactId>kobby-maven-plugin</artifactId>
                <version>${kobby.version}</version>
                <executions>
                    <execution>
                        <phase>generate-sources</phase>
                        <goals>
                            <goal>generate-kotlin</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <!--Add this dependency to enable-->
        <!--Jackson annotation generation in DTO classes-->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-annotations</artifactId>
            <version>${jackson.version}</version>
            <scope>compile</scope>
        </dependency>

        <!--Add this dependency to enable-->
        <!--default Ktor adapters generation-->
        <dependency>
            <groupId>io.ktor</groupId>
            <artifactId>ktor-client-cio-jvm</artifactId>
            <version>${ktor.version}</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>
</project>

Kobby поддерживает два способа конфигурации плагина — явную конфигурацию в коде и неявную на основе соглашений. Мы воспользовались конфигурацией на основе соглашений, добавив в проект зависимости от библиотек Jackson и Ktor. Дело в том, что в процессе сборки проекта, Kobby анализирует его зависимости. И, если находит зависимость от Jackson, то генерирует Jackson аннотации для DTO классов, чтобы упростить их десериализацию из JSON. А если плагин находит зависимость от Ktor, то он генерирует DSL адаптер по умолчанию. Мы поговорим об адаптерах в следующем разделе.


Создание контекста DSL

Мы настроили наш плагин. Выполните команду gradle build для Gradle или mvn compile для Maven, и плагин найдет файл cinema.graphqls и создаст DSL на его основе:

Плагин создал файл cinema.kt с функцией cinemaContextOf, которая позволяет создать экземпляр интерфейса CinemaContext. Этот интерфейс является точкой входа для нашего DSL:

fun cinemaContextOf(adapter: CinemaAdapter): CinemaContext =
    CinemaContextImpl(adapter)

В качестве аргумента функция cinemaContextOf принимает ссылку на адаптер - CinemaAdapter. Что такое адаптер? Дело в том, что созданный нами контекст, ничего не знает о транспортном уровне и о протоколе взаимодействия GraphQL. Он просто собирает строку запроса, и передает ее адаптеру. А адаптер, в свою очередь, должен выполнить всю грязную работу — передать запрос серверу, получить и десериализовать ответ. Можно написать собственную реализацию адаптера или воспользоваться адаптером по умолчанию, созданным плагином.

Мы возьмем адаптер по умолчанию. Он использует Ktor для взаимодействия с сервером. GraphQL запросы и мутации выполняются поверх HTTP, а сеансы подписки устанавливаются поверх WebSocket:

fun createKtorAdapter(): CinemaAdapter {
    // Create Ktor http client
    val client = HttpClient {
        install(WebSockets)
    }

    // Create Jackson object mapper
    val mapper = jacksonObjectMapper().registerModule(
        ParameterNamesModule(JsonCreator.Mode.PROPERTIES)
    )

    // Create default implementation of CinemaAdapter
    return CinemaCompositeKtorAdapter(
        client = client,
        httpUrl = "http://localhost:8080/graphql",
        webSocketUrl = "ws://localhost:8080/subscriptions",
        mapper = object : CinemaMapper {
            override fun serialize(value: Any): String =
                mapper.writeValueAsString(value)

            override fun <T : Any> deserialize(
                content: String,
                contentType: KClass<T>
            ): T = mapper.readValue(content, contentType.java)
        }
    )
}

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

Мы готовы выполнить наш первый запрос. Давайте попробуем найти фильм с актерами по его идентификатору. В GraphQL этот запрос выглядит так:

query {
    film(id: 0) {
        id
        title
        actors {
            id
            firstName
            lastName
        }
    }
}

Для Kotlin наш запрос выглядит практически точно так же:

// Instantiate DSL context
val context = cinemaContextOf(createKtorAdapter())

val result = context.query {
    film(id = 0L) {
        id()
        title()
        actors {
            id()
            firstName()
            lastName()
        }
    }
}

Функция context.query объявлена с модификатором suspend, поэтому она не блокирует текущий поток. А что же мы получаем в качестве результата выполнения запроса? В GraphQL результатом является JSON, который выглядит следующим образом:

{
  "data": {
    "film": {
      "id": "0",
      "title": "Amelie",
      "actors": [
        {
          "id": "0",
          "firstName": "Audrey",
          "lastName": "Tautou"
        },
        {
          "id": "1",
          "firstName": "Mathieu",
          "lastName": "Kassovitz"
        }
      ]
    }
  }
}

Для навигации по результатам запросов плагин генерирует интерфейсы «сущностей» на основе GraphQL типов из схемы:

interface Query {
    val film: Film?
    val films: List<Film>
}

interface Mutation {
    val createFilm: Film
}

interface Subscription {
    val filmCreated: Film
}

interface Film {
    val id: Long
    val title: String
    val actors: List<Actor>
}

interface Actor {
    val id: Long
    val firstName: String
    val lastName: String?
}

Функция context.query возвращает экземпляр сущности Query, поэтому навигация по результату выглядит следующим образом:

// Instantiate DSL context
val context = cinemaContextOf(createKtorAdapter())

val result = context.query {
    film(id = 0L) {
        id()
        title()
        actors {
            id()
            firstName()
            lastName()
        }
    }
}

result.film?.also { film ->
    println(film.title)
    film.actors.forEach { actor ->
        println("  ${actor.firstName} ${actor.lastName}")
    }
}

Выполнение мутаций

Давайте создадим новый фильм. GraphQL мутация для создания фильма выглядит так:

mutation {
    createFilm(title: "My Film") {
        id
        title
    }
}

И, в качестве результата, мы получим следующий JSON:

{
  "data": {
    "createFilm": {
      "id": "4",
      "title": "My Film"
    }
  }
}

Я думаю, что вы уже догадались, как наша мутация будет выглядеть в Kotlin:

// Instantiate DSL context
val context = cinemaContextOf(createKtorAdapter())

val result = context.mutation {
    createFilm(title = "My Film") {
        id()
        title()
    }
}

result.createFilm.also { film ->
    println(film.title)
}

Функция context.mutation возвращает экземпляр сущности Mutation, и, так же как и функция context.query, объявлена с модификатором suspend. Таким образом, текущий поток наша мутация не блокирует.


Создание подписок

Давайте подпишемся на уведомления о новых фильмах в GraphQL:

subscription {
    filmCreated {
        id
        title
    }
}

По этой подписке мы будем получать уведомления в JSON формате:

{
  "data": {
    "filmCreated": {
      "id": "4",
      "title": "My Film"
    }
  }
}

Семантика операции подписки в Kotlin отличается от семантики операций запроса и мутации. В отличие от функций context.query и context.mutation, которые просто отправляют запрос и получают ответ, подписка создает долговременный сеанс для прослушивания входящих сообщений. Нам понадобится асинхронный слушатель:

// Instantiate DSL context
val context = cinemaContextOf(createKtorAdapter())

launch(Dispatchers.Default) {
    context.subscription {
        filmCreated {
            id()
            title()
        }
    }.subscribe {
        while (true) {
            val result = receive()
            result.filmCreated.also { film ->
                println(film.title)
            }
        }
    }
}

Не беспокойтесь, мы не заблокируем текущий поток в бесконечном цикле, так как функция subscribe и функция receive объявлены с модификатором suspend.

Время жизни сеанса подписки такое же, как время выполнения функции subscribe. Когда мы входим в функцию, создается сеанс, а когда мы выходим из нее, сеанс уничтожается.

Функция receive возвращает экземпляр сущности Subscription для каждого входящего сообщения.


О чем я не рассказал в этой статье?

И, самое главное, я не рассказал о том, как с помощью напильника и функций расширения Kotlin превратить генерируемый DSL в rich domain model на стероидах. Возможно, я расскажу об этом в следующих статьях.

Теги:
Хабы:
Всего голосов 7: ↑7 и ↓0+7
Комментарии1

Публикации

Истории

Работа

Ближайшие события

Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург