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

от автора

Запомните, если вы не бросите 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 на стероидах. Возможно, я расскажу об этом в следующих статьях.


ссылка на оригинал статьи https://habr.com/ru/articles/587388/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *