С одной стороны, 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 для GraphQL скаляров, объявленных в схеме.
Я не рассказал о том, как Kobby работает с абстрактными типами данных.
Я не рассказал, как настроить генерируемый DSL с помощью директив GraphQL.
Я не рассказал о том, как плагин поддерживает разработку на стороне сервера.
И, самое главное, я не рассказал о том, как с помощью напильника и функций расширения Kotlin превратить генерируемый DSL в rich domain model на стероидах. Возможно, я расскажу об этом в следующих статьях.