gRPC — RPC is back?

SPD-Ukraine
14 min readMar 19, 2021

Ділимось досвідом побудови API на гугловому gRPC: переваги та складнощі під час роботи з ним.

Історія почалась майже 4 роки тому, з початком росту можливостей та сервісів на проєкті. Майже кожен з них мав вичитувати та інколи модифікувати дані, які стосувались користувачів системи. Однією з вимог була консистентність даних.

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

Всі погляди зводились до REST API. В той самий час почала знову набирати популярність технологія RPC — виклик віддаленого коду на інших машинах. Ця технологія набагато старша, ніж REST, проте з часом вона втратила свою популярність. Але у 2015 році Google вдихнув в неї нове життя, випустивши фреймворк — gRPC.

Введення в gRPC

gRPC — це новий етап еволюції фреймворку Stubby, який розроблявся в Google понад 10 років. Компанія хотіла зробити його open-source, проте проєкт не задовольняв жодні стандарти. Тим часом у 2014–2015 відбувся реліз протоколу HTTP/2. У Google вирішили зробити свій фреймворк, базований на цьому ж протоколі.

Кілька термінів

  • Канал (channel) — довготривале з’єднання.
  • Стаб (stub) — клієнт, у якого є три методи: onNext(Message), onError (Throwable), onCompleted()
  • Повідомлення (message) — структура даних, яка передається через канал.

Зазвичай комунікація реалізується за допомогою формату protobuf (формат від Google 2008 року). Основна його специфіка в тому, що він зберігає дані в бінарному вигляді, а ключами виступають числові індекси, а не імена полів.

Як це працює?

gRPC підтримується понад 11 мовами (Java, C++, Ruby, Rython і т.д.). Плюсом цього є те, що клієнт та сервер можуть бути написані на різних мовах. Це дає чимало свободи.

Для передачі даних використовуються proto-запити та proto-відповіді. Що таке proto? Це мова розмітки інтерфейсів, декларативний опис нашої моделі та контракту в цілому. Розглянемо приклад.

Кожний сервіс має ім’я, список методів (з ключовим словом RPC), вхідні дані та результуючі дані. В message теж є ім’я та список полів, ім’я поля та індекс. І саме індекс використовується при серіалізації в формат protobuf.

Повноцінний клієнт генерується з proto-класу (наприклад, Java-код в нашому випадку). Це можна зробити за допомогою як окремого proto-компілятора, так і Gradle\Maven плагінів. Плагін додає в проєкт декілька нових taks таких як: generateProto, extractProto, тощо. Більше деталей можна почерпнути з офіційної документації.

Разом з тим ця вся універсальність і крутість частіше неочевидна. Тож ділюсь досвідом нашої команди.

Наш досвід повний спроб та помилок

Gotcha 1: less code “is better”

Розглянемо hello-сервіс, який просто відповідає на привітання. На вхід передається ім’я того, кого ми хочемо привітати, а в результаті повертається повідомлення Hello ${UserName}. Опиcуємо proto та генеруємо клієнт та сервер:

На клієнті виклик виглядає приблизно так:

Звернемо увагу на згенерований код — втрачена інформація про те, що сервер очікує в запиті. Немає жодної інформації про імена параметрів, є лише типи параметрів. Знаючи тип, важко вгадати, що клієнт має вказувати в запиті — UserName чи адресу. Що робити? Використовувати свою модель для кожного метода. На перший погляд, це порушує DRY-принцип, проте не варто поспішати з висновками.

Введемо hello-запит та hello-відповідь. proto-опис виглядає приблизно так.

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

Зауважимо, що інтеграцію структури даних ми зробили повністю зворотно-сумісною. Якщо в меседжі першим полем виступає той же тип даних, який був у нас до цього, замість всього меседжа (string в нашому випадку) можна замінити один тип даних на інший (пам’ятаєте, що ключами виступають індекси полів і не більше?).

Gotcha 2: optional fields

Продовжимо розгляд вищезгаданого сервісу привітань. Тепер на вхід він отримує ім’я і флаг, чи є в користувача домашній улюбленець — кіт. В залежності від цього сервер поверне повідомлення “погодуй кота” або “заведи кота”.

Спробуймо викликати наше API:

Ось воно! З часом знаходиться користувач, який не вказав в запиті цей параметр і в відповідь отримав повідомлення Hello Johny, Please get cats! Ми, не знаючи чи є коти чи немає, дали рекомендацію. Для виправлення цього додамо перевірку.

Як бачимо не все працює, як хотілось. Чому? В gRPC всі поля не nullable — тобто вони мають альтернативне значення, як значення по замовчуванням. Таким чином все, що не передається, є рівним такому значенню, яке по мережі не передається в цілях оптимізації трансферу. Тож для перевірки наявності поля в запиті потрібно використовувати методи згенеровані прото-компілятором під час побудови моделі з прото-файлу, наприклад hasCats().

Gotcha 3: optional enumeration field

Продовжимо розгляд. Давайте додатково на вхід передавати стать користувача. В залежності від статі генерується відповідь з використанням звертання Mr/Ms.

Анонім вирішив не вказувати свою стать і отримав відповідь зі звертанням Ms, що некоректно. Стандартним значення enum в gRPC виступає значення під нульовим індексом. Тобто якщо не вказати це поле в запиті, то на сервер приходить enum зі значенням поля з нульовим індексом. Для цього потрібно завжди резервувати нульовий елемент з назвою unspecified. Тоді сервер зможе коректно розпізнати, якщо прийшов unspecified, то стать не було вказана.

Gotcha 4: enumeration uniqueness scope

Це ще не все з типом перечислень. Давайте введемо ще один enum — регіон, де живе персонаж (Північ, Південь, Захід, Схід або не вказано). Цього разу в нас не проходить навіть компіляція.

Причиною цього є та ж універсальність gRPC — фреймворк підтримується понад 11 мовами. Всі ці реалізації повинні бути сумісними. І от на C++ реалізація enum виглядає не так, як на Java. Там не можна робити значення двох enum однаковими, якщо вони знаходяться в рамках одного пакета. Тож Google рекомендує додавати префікс з іменем до кожного значення. Поправимо це і в нас:

Компіляція проходить

> Task :core:generateProto

BUILD SUCCESSFUL in 208ms

Виглядає не очевидно і трохи надлишково, але це допомагає робити наш сервіс сумісним з реалізаціями на різних мовах та не зв’язувати руки розробникам одним стеком технологій.

Gotcha 5: first request time out

День за днем випадковим чином почав падати перший запит на сервер в різних додатках. Причину довго не вдавалось зрозуміти. Розглянемо найпростіший варіант, де на запит виділяється одна секунда:

Обидва запити вкладаються в одну секунду. Зменшимо ліміт часу:

Як бачимо, обробка запиту вкладається навіть в 500 мілісекунд. Йдемо далі та зменшимо ліміт на перший запит.

Перший запит не вклався в ліміт часу. Другий успішно завершився. Хоч і в обох був однаковий ліміт часу.

Виявилось, що в gRPC з’єднання ініціалізується в лінивому режимі. Тобто воно починається тільки в момент першого запиту. Щоб з’єднання ініціалізувалось не з першим запитом, а раніше, можна використати такий метод каналу як getState з аргументом true. GetState вертає стан з’єднання із сервером, а флажок true вказує, що потрібно ініціювати з’єднання, якщо його ще немає.

Таким чином зменшується шанс того, що перший запит не вкладеться в ліміт часу. Проте все ще існує ймовірність, що перший запит буде падати одразу після того, як викликати метод getState. Такий метод примусової ініціалізації зв’язку рекомендує Google у своєму GitHub репозиторії. Але на варто недооцінювати форуми по gRPC, що є більш джерелом інформації.

Gotcha 6: some requests time out

Розібравшись з падінням першого запиту, інколи все ще падали реквести в випадковому порядку. Якоїсь закономірності не відстежувалось. Наприклад:

Виявляється, коли ми вказуємо ліміт для нашого запиту, відлік часу починається вже з моменту виклику методу withDeadline. Відповідно побудова повідомлення вже входить в цей ліміт часу. Інколи підготовка запитів займає певний час. Тож всі побудови меседжів рекомендується виконувати ще до моменту вказання дедлайну запиту. Переробивши метод наступним чином, наш запит почав виконуватись нормально.

Варто зауважити, що дедлайн вказує граничний час виконання запиту відносно UNIX часу. Тобто варто бути готовими до пригод, якщо на сервері годинник “спішить” чи відстає відносно клієнтів. Загалом дана особливість була виявлена завдяки IDE, яка постійно норовить “заінлайнити” змінну, якщо вона використовується лише один раз.

Gotcha 7: flow termination on Error

Зазвичай на сервері наявна валідація даних перед їх обробкою. Наприклад, оновимо наший сервіс, щоб він не вітав Луїджі. В gRPC для цього використовується метод onError. Відповідна валідація на сервері повертає IllegalArgumentException. Клієнти, які викликали цей API з аргументом Луїджі, отримували не багатослівну відповідь — unknown status, а в логах сервера красувався незрозумілий exception.

В чому ж справа? На прикладі вище сервер завжди намагається повернути результат (onNext та onCompleted), незважаючи на те, чи викликався onError чи ні. Як зазначено в документації, onError — для помилок, а пара onNext i onComplete — для успішного виконання. Одночасно їх використовувати не можна (було б добре ловити це ще на етапі компіляції). Відповідно нам потрібно завершити наш метод після того, як ми повернули помилку через метод onError. Після мінорних змін на клієнті вже отримується помилка зі зрозумілим статусом та інформативним повідомленням.

Gotcha 8: field removal

З часом деякі дані в запитах та відповідях стають не актуальними, і їх потрібно видаляти або заміняти на нові. Наприклад, видалення поля name можна було б реалізувати наступним чином:

Проте що станеться, коли прийде новий розробник і захоче додати нове поле? Який індекс він буде використовувати? Швидше за все наступний вільний індекс — 2! Але старі клієнти все ще можуть очікувати, що під індексом 2 буде приходити старе поле name — те, яке ми видалили.

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

Коректним варіантом буде резервування полів і зменшення ризику, перенісши перевірки з розробника на компілятор. Це реалізовується за допомогою ключового слова reserved. Важливо, що варто резервувати не лише індекс поля, а й ім’я. Це потрібно, щоб ніхто пізніше не використав повторно ані індекс, ані ім’я через роки еволюції проєкту.

Gotcha 9: stream 1M+ messages

Ще одна неочікуваність — це стримінг. Так як gRPC побудований на HTTP/2 протоколі — підтримується можливість стримінгу як із сервера, так і з клієнта. І навіть більше — в стримінг в обидві сторони. Контракт стримінгу з клієнта на сервер виглядатиме наступним чином

Одного разу нам довелось переслати з клієнта на сервер 1 мільйон повідомлень. Під час запиту виникла цікава помилка — OutOfMemory.Direct buffer memory:

Що ми робимо коли бачимо OutOfMemoryError? Збільшуємо об’єм хіпа. Спочатку підняли до 4GB. Це дозволило нормально стрімити 1 млн 250 тис повідомлень. Далі 8GB і 2,5 млн. Проте все одно була границя, коли падав OutOfMemory.

Трішки передісторії про таємничий Direct buffer memory. Ще з часів Java 1.4 у JVM на додачу до хіпа з’явився додатковий блок пам’яті NativeMemory. Це набагато ефективніша область оперативної пам’яті в плані ІО у порівнянні з хіпом. Коли передаються дані через ІО, ці дані не потрапляють в хіп, а взаємодіють напряму з оперативною пам’яттю. Таким чином ефективніше використовуються ресурси сервера. Цікаво, що в коді вона вказана 64 Мб, проте вище в документації зазначено, що це значення не впливає ні на що, а її об’єм рівний об’єму хіпа (контрольований аргументом запуску JVM Xmx).

Проте об’єм пам’яті, який контролює direct memory, можна модифікувати окремо — за допомогою аргументу MaxDirectMemorySize. Наприклад, вказавши його рівним в 4GB, 1,2 млн повідомлень проходило добре, а далі та ж помилка. Цікаво, що при 12 мегабайтах, стрімінг проходив успішно навіть для 2–3 млн елементів, але ефективність роботи самої JVM помітно падала. Це зв’язано з тим, всі операції JMV по роботі з IO розділяють ці 12МB, і цього мало.

Причина криється трохи глибше. JVM надсилає повідомлення в чергу на мережевий адаптер, доки вони не закінчаться або не вичерпається пам’ять. В gRPC це можна вирішити, обмеживши відправу нових повідомлень до того моменту, коли сервер буде готовий їх обробляти. Це називається flow control. Клієнт тепер виглядатиме наступним чином:

Спочатку потрібно відключити стандартну поведінку та описати логіку, коли можна надсилати новий елемент. Наприклад, у нашому випадку ми зробили передачу по одному повідомленню — тобто не передавати наступне повідомлення, доки сервер не отримає попереднє з клієнта. За потреби можна надсилати пачками. Таким чином задача стрімінгу великої кількості повідомлень вирішена.

Gotcha 10: dependencies hell

З часом все більше і більше наших сервісів реалізовувалось за допомогою gRPC. Для зручності кожен з цих сервісів надавав свій java-клієнт, який підключався на інших проєктах. Невдовзі транзитивні залежності дали про себе знати.

Одного чудового дня, загадковим на той момент чином під час запуску падала помилка INTERNAL: Panic! This is a bug! Швидкий пошук показав, що ця помилка повертається в одній з grpc бібліотек. Спроба перейти до реалізації не завершилась успішно. Виявилось, що в ClassPath проєкту було декілька різних версій одних і тих бібліотек.

Поясненням тому наступне. Підключено декілька клієнтів сервісів, де один клієнт використовує gRPC версії 1.17, другий — 1,19, третій — 1.23 і т.д.. Як виявилось, не завжди код, згенерований різними версіями компілятора, сумісний. Ми це вирішити так званим Monkey-Patch — примусове використання певної фіксованої версії транзитивних залежностей. Таким чином на консьюмері використовувалась завжди лише ця версія транзитивних залежностей.

Цього неелегантного рішення треба було позбуватись, і щось вирішувати з першопричиною — клієнтами. Прийняли рішення адаптувати систему на публікацію тільки контрактів замість готових клієнтів. У випадку з gRPC це прото-файл. Консьюмери ж згенерують собі з цього файлу код вже з їх версією компілятора. На перший погляд, це призведе до дублікації коду між проєктами. Проте не забуваймо, що це автозгенерований код, який не містить бізнес-логіки та не потребує ручних модифікацій.

Незручності використання gRPC

1 Складніший процес перевірки якості

Коли команда тестувальників ввійшла в роботу, то відразу помітила, що, на відміну від REST, запити не можна затригерити з браузера. Браузер на даний момент не вміє перетворювати JSON в бінарний формат, з яким працює наш gRPC сервер. Для цього треба прошарок, який би робив цю адаптацію.

Ми це реалізували за допомогою окремого Gаtеway сервісу (по типу Swagger-UI), та бібліотеки Vertex. Сервіс переводить вхідні дані в бінарний формат, далі відправляє на gRPC сервер, так само і в іншу сторону. Це трохи незручно, оскільки при махорних змінах дизайну прото-формату Google, потрібно апдейтити і Gаtеway. У нашому випадку з кожним gRPC сервером автоматично запускається ще й сусідній Gаtеway сервер на окремому порту.

Звісно можна використовувати й сторонні програми (аналоги Postman), наприклад Bloom RPC.

2 Незручна передача деталей про помилки

Повернемось до випадку з забороною вітати Луіджі. Як повернути додаткові дані про помилку? Як сервер зрозуміє, що він повинен вичитати ці дані? Звідки він їх має брати?

В gRPC це реалізовано з допомогою meta-data / трейлерів / хедерів. Це може бути не обов’язково інформація про помилки, а будь-яка інформація, об’єм файлів, наприклад, чи підказки. Передача метаданих виглядає наступним чином:

Накопичивши meta-data, кладемо в відповідь з певним ключем (в нашому випадку це badrequest). На клієнті відбувається вичитка по тому ж ключу. Іншими словами клієнт і сервер мають знати, під яким ключем, що лежить. Треба домовлятись на словах або прописувати в контрактах. Також вичитка на клієнті доволі громіздка.

3 Незручна згенерована з прото-файлів Java-документація

Наприклад, є прото-файл, в якому описано API:

/*
* Hello service, designed for SPD Talks
*
* Accepts user name as argument message {@link HelloRequest}
* Returns congratulations message {@link HelloResponse}
*/
rpc send(HelloRequest) returns (HelloResponse);

Згенерований Java-doc буде виглядати таким чином:

/**
* <pre>
**
* Hello service, designed for SPD Talks
* Accepts user name as argument message {&#64;link HelloRequest}
* Returns congratulations message {&#64;link HelloResponse}
* </pre>
*/

public void send(

HelloRequest request,
StreamObserver<HelloResponse> responseObserver) { }

}

Як бачимо, всі наші посилання та форматування зникають. Для того щоб згенерувати адекватну Java-доку, доводиться використовувати сторонні рішення.

4 HealthCheck

HealthCheck — це перевірка, чи наш сервіс працює належним чином. У випадку з REST ми можемо повернути JSON, де ім’я компоненту, що перевіряється — це ключ, а значення — строка ок/не-ок.

У випадку з gRPC просто повернути JSON не можна. Але можна реалізувати новий gRPC endpoint, який би вертав таку саму модель як і звичайний business endpoint. Але оскільки gRPC endpoints не можна викликати напряму з браузера, необхідна додаткова програма, яка б викликала цей healthcheck.

5 Моніторинг (prometheus) overhead

Для моніторингу сервісів ми використовуємо Prometheus, який періодично вичитує статистику з сервісу через технічний endpoint.

Проблема в тому, що Prometheus вміє працювати лише з http і не вміє з gRPC. Щоб можна було збирати метрики із gRPC додатку, потрібний ще один http сервер на окремому порті, який буде використовуватись лише для збору метрик. Тобто один додаток займає вже є аж три порти:

  • grpc для бізнес-логіки,
  • http для збору метрик,
  • http для Gateway aka swagger-ui.

Висновки з нашого досвіду використання gRPС

Мінуси

  • Непогана документація, проте більшість відповідей знаходиться на форумах та ішьюсах в офіційному репозиторії на GitHub. Там до речі є ціла екосистема з допоміжними бібліотеками від Google та сторонніх розробників.
  • Ускладнений процес тестування — необхідність мати транскодер сервіс ускладнювала початкові тестування, у порівнянні з REST.
  • Нечитабельний формат даних.
  • Не стандартизована і громіздка модель помилок.

Плюси

  • gRPC — дуже швидкий. Порівняння кожної з версій gRPC можна глянути тут.
  • Контракти з коробки в вигляді proto-файлів. Це дозволяє відловити більшість помилок ще на етапі компіляції.
  • Зворотня сумісність при багатьох видах змін.
  • Вбудований менеджер дедлайнів та deadline propagation.
  • Добре підтримується в інтегрованих середовищах розробки.

Висновок

Якщо ваша розробка проходить в основному на Go — gRPC must-have в інструментарії, оскільки для Go існує безліч плагінів та бібліотек, які спрощують роботу з gRPC. У випадку з Java все трохи гірше, і зазвичай доводиться писати свої рішення і більше комунікувати між командами.

У фреймворку досить не високий поріг входу та він приносить достатньо переваг в порівнянні з REST. Рекомендуємо спробувати.

Цікавий факт про gRPC

Що означає “g” в gRPC? “G” is not for Google. А що ж тоді? Для кожної версії існує своє трактування. Їх можна переглянути тут.

Корисні посилання

--

--

SPD-Ukraine

Ми — компанія, що займається розробкою софту від PoC та MVP до підтримки. Ми раді ділитись власним досвідом. Більше про нас на https://spd-ukraine.com/