Введення в GraphQL: що це за мова і як використовувати його під Android

Всім привіт! Мене звуть Марія Агеєва, я Android-розробник. Близько 2 років працюю з GraphQL. Хотіла б поділитися досвідом роботи з цією технологією і, можливо, зацікавити в її використанні тих з вас, хто ще з нею не знайомий, збирається її використовувати або тільки почав інтеграцію GraphQL в проект. Також у статті буде коротко описана робота з GraphQL для платформи Android.

Що таке GraphQL

Насамперед розглянемо, що ж таке GraphQL. За визначенням з офіційного сайту, GraphQL — це мова запитів і маніпулювання даними для API, а також середовище для виконання цих запитів. Мова був розроблений в 2012 році в Facebook для внутрішніх потреб компанії, у 2015-му вийшов у відкритий доступ, а з 7 листопада 2018 року роботу над ним веде не Facebook, а GraphQL Foundation. Звичайно, проект розвивався досить активно з 2012 року, але особливу популярність заробив після того, як отримав статус open source.

Навіть якщо раніше ви ніколи не чули про GraphQL, великий шанс, що ви знаєте або використовували продукти, які написані з його застосуванням. По-перше, це, природно, соціальна мережа Facebook. Крім неї, з GraphQL працюють при розробці таких продуктів, як Airbnb, GitHub, Pinterest, Shopify, New York Times і багатьох інших.

Повернемося до створенню мови. Те, що він був створений саме в Facebook для проекту з великим об'ємом різнорідних даних, говорить про те, що при роботі над подібним продуктом можуть виникати ситуації з обмеженнями REST-архітектури. Наприклад, одержання профілю користувача, його постів і коментарів спочатку не здається складним завданням. Але якщо врахувати обсяг даних в системі і припустити, що всі ці дані зберігаються в різних базах даних (наприклад, MySQL і MongoDB), стає зрозуміло, що для цього знадобиться створити кілька REST-endpoint'ів. Ну а якщо уявити, наскільки великий обсяг даних і різнорідні джерела даних, то стає зрозуміло, чому знадобилося розробляти новий підхід до роботи з API. В основі цього підходу лежить наступний принцип: краще мати один «розумний» endpoint, який буде здатний працювати зі складними запитами і повертати дані саме в тій формі і в тому обсязі, які необхідні клієнту.

У центрі будь імплементації GraphQL API лежить схема даних — це опис того, з якими типами даних він може працювати і які типи даних може повернути у відповідь на запит (вони описані в системі типів GraphQL). Простіше кажучи, для роботи з будь-яким API користувачеві необхідно знати, які типи об'єктів можна отримати, які поля вибрати поля, які доступні у внутрішніх об'єктах і т. д. Саме в цьому нам допоможе схема.

Як можна помітити, клієнту при роботі з GraphQL API абсолютно не важливо, звідки надходять дані, які він запитує. Він просто робить запит в потрібному обсязі, а сервер GraphQL повертає результат. Тому можна уявити, що схема — це контракт між API і клієнтом, так як, перш ніж клієнт виконає будь-який запит, запит валидируется у відповідності зі схемою даного API.

Взаємозв'язок клієнта і сервера при роботі з GraphQL

Тут я хотіла б зупинитися на тому, як, власне, влаштована робота клієнта і сервера при використанні GraphQL. Так як я не back-end-розробник, то розповім тільки коротко про роботу з серверною частиною, не вдаючись у подробиці.

Схема взаємодії клієнта і сервера GraphQL

Роботу з GraphQL підтримує зараз велика кількість платформ: веб, Android, iOS і багато інших. GraphQL-клієнт надсилає запит на отримання даних або на їх зміну, складений у відповідності зі схемою, на GraphQL-сервер. GraphQL-сервер, у свою чергу, являє собою HTTP-сервер, з яким пов'язана схема GraphQL. Тобто мається на увазі, що через цю схему «пропускаються» всі запити, отримані від клієнта, і повертаються відповіді.

Сервер GraphQL не може знати, що робити з запитом, якщо йому не «пояснити» це за допомогою спеціальних функцій. Завдяки їм GraphQL розуміє, як отримати дані для потрібних полів. Ці функції пов'язані з відповідними полями і називаються распознавателями, або резолверами (resolvers). Після цього клієнту повертається відповідь, який відображає запитувану з клієнта структуру даних, зазвичай у форматі JSON. І, повторюся, тут можна працювати з абсолютно різними джерелами даних: бази даних (реляційні/NoSQL), результати веб-пошуку, Docker і т. д.

Порівняння GraphQL API і REST API

Як я зазначала раніше, GraphQL розроблявся як більш ефективна альтернатива архітектурі REST для розробки програмних інтерфейсів додатків. Тому у цих 2 підходів є спільні риси:

Але у GraphQL є багато переваг у порівнянні з REST:

На практиці використання GraphQL збільшує незалежність front-end - і back-end-розробників. Після узгодження схеми front-end-розробники більше не будуть просити створити нові endpoint'и або додати нові параметри для наявних: back-end-розробники один раз описують схему даних, а front-end-фахівець створює запити і комбінує дані так, як це необхідно.

Система типів

В основі будь-якого GraphQL API лежить опис типів, з якими можна працювати і які він може повернути як було сказано раніше, схема. Так як сервіси GraphQL можуть бути написані на багатьох мовах, то був розроблений універсальний GraphQL schema language. Розглянемо основні типи даних, які він підтримує.

Об'єктні

Найбільш базові типи GraphQL — об'єктні типи, які являють собою об'єкт і набір полів, що описують його.

Всі приклади в цій статті я буду наводити з використанням Star Wars API.

Наприклад:

type Planet {
 id: ID!
 diameter: Int
 name: String!
 population: Float
 residents: [Person!]
}

Скалярні

Об'єкти GraphQL можуть мати поля різних типів, але в кінці кінців вони повинні бути приведені до одного з підтримуваних скалярних типів. Таким чином, скалярні типи являють собою листя запиту. До стандартних скалярним типами GraphQL відносяться:

У багатьох имплементациях сервісів GraphQL є можливість створювати свої власні скалярні типи.

Хотілося б ще відзначити, що в GraphQL можна додати і так звані модифікатори типів (type modifiers), які впливають на валідацію полів. Дуже поширений non-null модифікатор, який гарантує, що дане значення ніколи не буде null (інакше отримаємо помилку виконання). Позначається як !, наприклад:

id: ID!

Інший поширений модифікатор — List. Можна позначити як тип List, в такому випадку очікується, що в цьому місці буде повернутий масив з таких значень. Позначається як [], наприклад:

films: [Film!]

Модифікатори можна комбінувати і використовувати на будь-якому рівні вкладеності.

Аргументи

Представляють собою набір пар «ключ — значення», які прив'язані до певного поля. Вони передаються на сервер і впливають на те, як будуть отримані дані для певного поля. Аргументи можуть бути і текстовими значеннями, і змінними. Їх можна застосовувати на будь-яких полях незалежно від рівня їх вкладеності. Вони обов'язково повинні бути іменованими, а також можуть бути обов'язковими або опціональними (якщо аргументи опціональні, то їх значення повинно бути задано за замовчуванням). За типом даних значення аргументів можуть бути скалярними або спеціальними об'єктними input-типами.

Наприклад, тут до поля Film прив'язаний аргумент id (в даному прикладі літерал типу ID):

query {
 Film(id:"1234abcd") {
id
title
 characters {
name
}
}
}

Перерахування

Спеціальний скалярний тип, який обмежений певним набором значень. Приклад:

enum HAIR_COLOR {
BLACK
BLONDE
BROWN
GREY
}

Також в GraphQL є 2 абстрактних типу: union та interface. Вони можуть використовуватися тільки як поворотний тип, тобто можна лише отримати дані такого типу, але не передати запит в якості аргументу.

Інтерфейси

Абстрактний тип, що включає в себе набір обов'язкових полів, які повинні включати в себе типи, які успадковують цей інтерфейс. Якщо це не буде виконано, то станеться помилка валідації схеми. Приклад:

interface Character {
 id: ID!
 name: String!
}
type Droid implements Character {
 id: ID!
 name: String!
 function: String
}
type Human implements Character {
 id: ID!
 name: String!
 starships: [Starship]
}

Інший абстрактний тип, не включає обов'язкові поля. Може застосовуватися там, де необхідно використовувати сімейство з типів, у яких немає спільних полів, наприклад при реалізації пошуку або складних підписок. Приклад:

union SearchResult = Human | Starship | Film
... on Human {
name
height
}
... on Starship {
model
capacity
manufacturer
}
... on Film {
title
episode
}

Можна поставити питання: навіщо використовувати 2 абстрактних типу? Тому що в цих типів принципово різне застосування. Union-типи використовуються в тих місцях схеми, де можна сказати, що тут можна повернути один з перерахованих типів. У свою чергу, інтерфейси використовуються там, де про типі можна сказати, що він реалізує цей контракт.

Query, mutations, subscriptions

Існують спеціальні типи, що визначають тип операції, яку клієнт хоче виконати, наприклад отримання даних або їх зміна. Ці типи є точками входу для API. Будь GraphQL API повинен обов'язково мати хоча б один query, але mutations і subscriptions необов'язкові. Варто відзначити, що, незважаючи на свій особливий статус, ці спеціальні типи такі ж, як і інші об'єктні типи GraphQL. Розглянемо кожен із цих типів докладніше.

Query. Спеціальний тип, який конвенції являє собою запит на отримання даних (можна сказати, що це аналог GET у REST). Саме цей тип даних обов'язковий для будь-якого GraphQL API. Розглянемо простий приклад:

query GetAllFilms {
 allFilms {
id
title
episode
}
}

Це простий запит на отримання інформації про всіх фільмах (як зрозуміло з назви). Вже на цьому етапі видно важлива перевага використання GraphQL: ми отримуємо тільки ті дані, які нам потрібні в додатку (в даному випадку id, назва та номер епізоду фільму). Таким чином, на практиці не припадає на клієнта отримувати всі дані незалежно від того, потрібні вони чи ні, і виконувати часом досить складну фільтрацію, якщо можна отримати лише потрібні дані і далі працювати тільки з ними. Для дебага і логування також корисно іменувати запити (при роботі з деякими технологіями, наприклад при розробці для Android, анонімні query не підтримуються в принципі).

Mutations. Спеціальний тип, який, на відміну від query, використовується для зміни даних можна сказати, що це аналог POST). Так, звичайно, це рекомендація, і на практиці не заборонено використовувати для зміни даних query, але все ж так робити не варто. Адже основна відмінність query від mutation — їх поведінка при отриманні результатів запитів: при виконанні query обробка полів результату буде виконуватися паралельно, так як вони не змінюють дані. У свою чергу, в mutations це виконується послідовно, щоб забезпечити цілісність даних. Приклад:

mutation DeleteEpisode {
 deleteFilm(id: "123456") {
title
}
}

У прикладі можна помітити, що відмінностей в синтаксисі немає, запит починається з назви операції (mutation) і також є іменованим.

Subscriptions. Самий новий спеціальний тип даних, який дозволяє реалізувати систему publisher/subscriber. Тобто це спосіб надсилання даних клієнта, що «слухає» ці дані в реальному часі. На відміну від попередніх 2 спеціальних типів, тут відповідь сервера приходить не одноразово, а кожного разу при спрацьовуванні певних умов на сервері за умови, що клієнт «слухає» ці дані. Можна сказати, що це аналог push-нотифікацій, які працюють в одній системі з усіма іншими типами запитів — query і mutations. Великий плюс даного підходу — те, що клієнт і в цьому випадку може з допомогою аргументів визначити, при якому умови результат з сервера повинен приходити на клієнт. Приклад:

subscription WaitForNewFilm {
 Film(filter:{ action:CREATED }) {
id
title
episode
 characters { 
name
}
}
}

Зверніть увагу, що в прикладі очікуються тільки дані про створеному фільмі. Якщо ж станеться зміна наявної інформації про фільм, його видалення або якесь інше подія, клієнт ці дані не отримає.

На практиці GraphQL зручний ще і тому, що при його використанні витрачається менше часу на пошук і усунення проблем, пов'язаних з зміною API. Наприклад, якщо на сервері змінили тип даних будь-якого поля, видалили або додали новий тип даних або нові поля, то клієнт API дізнається про це ще до етапу виконання програми, тобто на вирішення проблеми буде витрачено менше часу і сил.

Introspection query і GraphQL Playground

Для отримання інформації про всі типи, які підтримує даний GraphQL API, можна виконати так званий introspection query. За замовчуванням на дебаг-версії сервера цей запит доступний на production, природно, повинен бути відключений. Також роботу з API спрощує використання GraphQL Playground — це інтерактивна графічна середовище розробки, що базується на GraphiQL, повноцінної IDE для роботи з GraphQL. При розробці і використанні Apollo Server, поширеного open source — сервера для розробки з використанням GraphQL, Playground стає доступним на тому ж endpoint, що і сервер (наприклад, localhost:4000/playground ). І, як і у випадку з introspection query, він доступний тільки в дебаг-версії.

Робота з GraphQL API при розробці для Android

У заключній частині своєї статті я хотіла б коротко описати роботу з GraphQL для Android-розробки. Найбільш поширений сервіс для роботи з GraphQL — Apollo GraphQL Server. Це open source — проект, який активно розвивається Apollo GraphQL. Версії його існують для багатьох платформ, в тому числі для веб, iOS і Android. І саме про нього піде мова далі.
Apollo GraphQL клієнт для Android підтримує багато хто з основних функцій на даному етапі, в тому числі:

В основі його роботи — кодогенерация сильно типізованих моделей за схемою GraphQL. За замовчуванням підтримується кодогенерация на Java, але можна в якості експериментальної фічі використовувати Kotlin-кодогенерацию.
Для полегшення роботи з GraphQL в Android Studio добре підходить плагін JS GraphQL. Основні його плюси:

Розглянемо, як же підключити підтримку GraphQL в проект. Остання версія клієнта на момент написання статті — 1.4.4.

У build.gradle рівня програми необхідно додати наступні рядки для роботи з Gradle-плагіном Apollo:

buildscript {
 repositories {
jcenter()
}
 dependencies {
classpath("com.apollographql.apollo:apollo-gradle-plugin:x.y.z")
}
}

У build.gradle рівня модуля додаємо наступне:

apply plugin: 'com.apollographql.apollo'
repositories {
jcenter()
}
dependencies {
implementation("com.apollographql.apollo:apollo-runtime:x.y.z")
// If not already on your classpath, you might need the jetbrains annotations
compileOnly("org.jetbrains:annotations:13.0")
testCompileOnly("org.jetbrains:annotations:13.0")
}

Далі необхідно створити директорію в проекті (модулі), наприклад src/main/graphql/com/my_package/, в яку слід додати файл schema.json — схему GraphQL у форматі JSON, отриману в результаті виконання introspection query того API, який буде використовуватися у проекті.

У цій же директорії створюємо файли з запитами у форматі .graphql, наприклад queries.graphql. Єдиний нюанс при створенні запитів — запити (query, mutations, subscriptions) повинні бути іменованими.

За замовчуванням, як згадувалося вище, використовується кодогенерация моделей на Java. Якщо ж потрібно включити Kotlin-кодогенерацию, додаємо наступне:

apollo {
 generateKotlinModels.set(true) 
}

І потім виконуємо команду./gradlew generateApolloSources, щоб моделі за запитами з директорії src/main/graphql/com/my_package/ були згенеровані. При створенні моделей на основі GraphQL-типів будуть згенеровані Java-класи (або Kotlin-класи), типи полів класів відповідають скалярним типами GraphQL. При використанні типу ID GraphQL у створеному класі використовується тип String. При необхідності також можна додати власні скалярні типи і визначити те, як вони перетворюються (їх маппінг).
Для роботи з сервером (наприклад, для виконання запитів і їх конфігурації, налаштування subscriptions) і роботи з кешем використовується клас ApolloClient. Приклад його створення та конфігурації:

val apolloClient : ApolloClient = ApolloClient.builder()
.serverUrl(ENDPOINT)
.subscriptionTransportFactory(
 WebSocketSubscriptionTransport.Factory(SUBSCRIPTIONS_ENDPOINT, okHttp))
.okHttpClient(okHttp)
.build()

Тепер розглянемо, як же можна виконати основні типи операцій GraphQL. Клієнт Apollo GraphQL підтримує і стандартне виконання операцій з використанням callback-функцій, і RxJava2 і coroutines, для чого передбачається підключення окремих залежностей Gradle.

Припустимо, що ми хочемо виконати 2 запиту з одним і тим же типом даних GraphQL Film і вибором одних і тих же полів в ньому:

query GetFilmNotOptimized($id:ID!) {
 Film(id:$id) {
id
title
director
episodeId
 planets {
id
name
diameter
population
climate
}
}
}
query GetFilmsNotOptimized {
 allFilms {
id
title
director
episodeId
 planets {
id
name
diameter
population
climate
}
}
}

Ось як це буде виглядати в проекті. Перший запит:

apolloClient.query(
GetFilmNotOptimizedQuery.builder()
.id(id)
.build()
).enqueue(object : ApolloCall.Callback<GetFilmNotOptimizedQuery.Data>() {

 override fun onFailure(e: ApolloException) {
 /* Response to exception */
}

 override fun onResponse(response: Response<GetFilmNotOptimizedQuery.Data>) {
 val filmType = response.data()?.Film()?.__typename()
 val filmClassName = response.data()?.Film()?.javaClass?.simpleName
 Log.i("ApolloStarWars", """GetFilmNotOptimizedQuery: film type = $filmType, film Java class = $filmClassName""")
 //result: GetFilmNotOptimizedQuery: film type = Film, film Java class = Film
}
})

І другий запит:

apolloClient.query(
GetFilmsNotOptimizedQuery()
).enqueue(object : ApolloCall.Callback<GetFilmsNotOptimizedQuery.Data>() {
 override fun onFailure(e: ApolloException) {
 /* Response to exception */
}
 override fun onResponse(response: Response<GetFilmsNotOptimizedQuery.Data>) {
 val filmType = response.data()?.allFilms()?.first()?.__typename()
 val filmClassName = response.data()?.allFilms()?.first()?.javaClass?.simpleName
 Log.i("ApolloStarWars", """GetFilmsNotOptimizedQuery: films type = $filmType, film Java class = $filmClassName""")
 //GetFilmsNotOptimizedQuery: films type = Film, film Java class = AllFilm
}
})

Тут можна зауважити, що Apollo GraphQL генерує 2 різних Java-типу на один і той же GraphQL-тип навіть за умови того, що обрані одні і ті ж поля. У такому випадку можна скористатися фрагментами GraphQL — переиспользуемыми структурами, які дозволяють визначати набори полів, засновані на якому-небудь GraphQL-типі, і включати їх запити. Наприклад, попередні запити можна оптимізувати, використовуючи такі фрагменти:

fragment PlanetFragment on Planet {
id
name
diameter
population
climate
}
fragment FilmFragment on Film {
id
title
director
episodeId
 planets {
...PlanetFragment
}
}

Запити, в свою чергу, будуть виглядати набагато простіше, а саме:

query GetFilm($id:ID!) {
 Film(id:$id) {
...FilmFragment
}
}

Це дозволить у результаті отримати один і той же Java-тип як результат виконання різних запитів, який буде згенерований одноразово на кожен GraphQL-фрагмент.

Підтримка RxJava2 і Kotlin Coroutines

І на закінчення коротко про підтримку RxJava2 і Coroutines.

Для підключення в проект підтримки RxJava2 необхідно додати наступний імпорт у build.gradle рівня модуля:

implementation 'com.apollographql.apollo:apollo-rx2-support:x.y.z'

Він включає в себе набір extension-функцій (Kotlin) і клас Rx2Apollo (Java) для конвертації ApolloCall в Вами. Варіант використання:

apolloClient.rxQuery(
GetFilmsQuery()
).singleElement()
.toSingle()
 .map { response ->
 return@map response.data()?.allFilms()?.map { film ->
film.fragments().filmFragment().toFilm()
 }?: listOf()
}

Аналогічно для підтримки Coroutines додаємо наступний імпорт:

implementation 'com.apollographql.apollo:apollo-coroutines-support:x.y.z'

Тут включені extension-функції для конвертації ApolloCall під Flow, в Deferred і Channel.

apolloClient.query(
GetFilmsQuery()
).toDeferred()
.await()
.data()?.allFilms()?.map { film ->
film.fragments().filmFragment().toFilm()
}?: listOf()

Висновки

Підводячи підсумок, хотілося б відзначити, що GraphQL — це концепція створення API, яка забезпечує слабку зв'язність клієнта і сервера. Очевидно, що з появою цієї технології зовсім не обов'язково повністю відмовлятися від використання REST-архітектури.

Більш того, з моєї точки зору, якщо структура даних в проекті нескладна, обов'язковий перехід на GraphQL не має сенсу, все залежить тільки від бажання розробників. Але цей підхід дійсно спрощує роботу з великими різнорідними даними. Головне — пам'ятати про те, що GraphQL не припускає переробки всього того, що було раніше, і в результаті кожен повинен вибирати той підхід, який йому ближче. Але я вважаю, що GraphQL — визначено та технологія, з якою варто ознайомитися.

Мені, як Android-розробнику, дуже подобається використання цієї технології з багатьох причин. Це і те, що в результаті клієнтське додаток працює з згенерованими класами Java або Kotlin, і те, що немає необхідності фільтрувати дані на клієнта, і можливість використовувати не тільки запити на отримання даних та їх зміна, але і subscriptions (можна сказати, аналоги push-нотифікацій) в одній системі без необхідності використання та налаштування сторонніх сервісів і т. д.

Щоб ви могли отримати більше інформації по цій темі, а також дізнатися про розширені функції (наприклад, про роботу з кешем), наводжу список корисних посилань:

Опубліковано: 23/04/20 @ 10:00
Розділ Різне

Рекомендуємо:

Варіанти кроссплатформної розробки мобільних додатків
По той бік огорожі: бізнес-аналітик про роботу в ролі продакт-оунера
«Потрібно давати людям грати. Ставити складні завдання. Платити за їх помилки». Олександр Конотопський — про завдання Ajax Systems, найм інженерів і українському продукті
Набір на 6 потік мого курсу SEO Шаолінь
C++ дайджест #26: StayAtHome та вивчай Machine Learning