Генерируем 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 не столь элегантна:
io.github.ermadmi78
kobby-maven-plugin
${kobby.version}
generate-sources
generate-kotlin
com.fasterxml.jackson.core
jackson-annotations
${jackson.version}
compile
io.ktor
ktor-client-cio-jvm
${ktor.version}
compile
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 deserialize(
content: String,
contentType: KClass
): 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
}
interface Mutation {
val createFilm: Film
}
interface Subscription {
val filmCreated: Film
}
interface Film {
val id: Long
val title: String
val actors: List
}
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 на стероидах. Возможно, я расскажу об этом в следующих статьях.