
С одной стороны, 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 на стероидах. Возможно, я расскажу об этом в следующих статьях.
ссылка на оригинал статьи https://habr.com/ru/articles/587388/
Добавить комментарий