Высокопроизводительные микросервисы на Kotlin с использованием gRPC. Долгий путь к DSL

от автора

Очень часто при проектировании высоконагруженных систем, основанных на микросервисной архитектуре, обнаруживается что «узким» местом, ограничивающим производительность системы и возможности ее масштабирования, становится передача сообщений и временные затраты на сериализацию-десериализацию сообщений и дополнительные расходы на установку соединения и начальные согласования. 

Также большое значение имеет тот факт, что микросервисы могут быть реализованы на разных технологиях разработки и при создании единого протокола обмена сообщениями необходимо было избежать проблем с неоднозначной интерпретацией пересылаемых значений (различные кодировки символов, порядок байтов в многобайтовых числах и т.п.), а также с согласованием идентификаторов полей и типов данных отправляемого информационного сообщения.

Наиболее очевидным решением стало использование универсального текстового представления с использованием кодировки Unicode и передача любой информации в виде человекочитаемого документа, который использует специальную разметку для отображения структуры полей исходного объекта. Наиболее известными схемами для представления сообщений являются XML и JSON, где первый чаще всего используется при взаимодействии веб-сервисов, а второй — в реализации микросервисов на основе архитектурного стиля RESTful. 

Текстовое представление, однако, имеет значительную избыточность и это негативно сказывается на производительности из-за дополнительных задержек на передачу информации. Так, например, для кодирования объекта, содержащего фамилию, имя и отчество (для определенности будем считать их записанными латинскими буквами, чтобы исключить необходимость перехода в двухбайтовое кодирование в UTF-8).

lastname = Ivanov  firstname = Petr middlename = Sidorovich

XML-представление займет 106 байт, JSON: 77 байт.

Если сравнивать с длиной исходных строк (дополнительно предусмотрим +1 байт для окончания или длины строки), то объем исходного сообщения составит 23 байта, объем JSON-документа составляет 335% от исходного, а XML — 461%. Это очень значительные затраты и они становятся еще больше при передаче сложных объектов с большим количеством числовых полей. 

Можно ли передавать структуры данных без серъезных дополнительных расходов, но с возможностью сохранить структуру и информацию о типах полей и с корректной интерпретацией значений на различных технологиях разработки? 

Да, и cуществует целое семейство двоичных протоколов сериализации (MessagePack, Thrift), но сейчас для нас наибольший интерес будет представлять протокол Protocol Buffers (protobuf), предложенный в 2008 году корпорацией Google. Протокол позволяет передавать следующие типы данных:

  • Целые числа (32 и 64-разрядные, со знаком и без знака) — int32, int64, sint32, sint64

  • Строки — string

  • Числа с плавающей точкой (одинарной и двойной точности) — float, double

  • Логические типы — bool

  • Перечисление — enum

  • Любые другие зарегистрированные типы сообщений

  • Массивы из значений — repeated

  • Словари из значений — map

  • Произвольный массив байтов — bytes

Дополнительно могут подключаться расширения, для кодирования специальных типов данных (например, google/protobuf/timestamp.proto для отпечатка времени, тип google.protobuf.Timestamp)

Описание структуры сообщений и доступных действий выполняется на специальном языке (в настоящее время proto3) и записываются в proto-файле.

Для примера, опишем структуру сообщения для регистрации пользователя, в котором передается фамилия, имя, отчество, возраст и пол, а также действие регистрации пользователя (сервис).

syntax = "proto3";  package ru.grpctest;  message User {   string lastname = 1;   string firstname = 2;   string middlename = 3;   int32 age = 4;   enum Gender {     MALE = 0;     FEMALE = 1;   }   Gender gender = 5; }  message RegistrationResult {   bool succeeded = 1;   string error = 2; }  service RegistrationService {   rpc Register(User) returns (RegistrationResult); }

Значение справа от знака равенства обозначает порядковый номер поля в структуре сериализации, он должен сохраняться при обновлениях протокола (или исключаться, но не переиспользоваться), чтобы избежать некорректной интерпретации при изменении структуры сообщения.

В дальнейшем на основе proto-файла могут быть созданы исходные тексты заглушек для заполнения объекта указанного класса. Для этого используется инструмент protoc (может быть получен по ссылке) или дополнения к системе сборки, которые автоматизируют процесс генерации кода:

protoc -I=исходный_каталог --java_out=каталог_проекта --kotlin_out=каталог_проекта registration.proto

Важно отметить, что несмотря на тот факт, что Kotlin позволяет использовать богатые возможности по созданию предметно-специфических языков (включая функции расширения с получателем и инфиксные операторы), эти возможности стали использоваться для создания объектов-посредников в protobuf относительно недавно и только в ноябре 2021 года Google официально объявила о поддержке DSL при генерации классов с использованием protoc (подробности здесь: Announcing Kotlin support for protocol buffers)

Но кодирование сообщения — это только часть проблемы, необходимо еще ускорить транспортный канал и постараться избежать дополнительных расходов на установку соединения и обмен служебной информацией. Поскольку исторически взаимодействие микросервисов организуется посредством веб-протоколов, то это хорошая причина искать возможность среди обновленных протоколов Интернет. Наиболее подходящим кандидатом для использования в качестве транспорта является одна из двоичных реализаций протокола HTTP, среди которых актуальными на 2022 год являются протоколы QUIC (принят в качестве официального стандарта в RFC 9000) и HTTP/2 (RFC 7540). Общими чертами всех двоичных протоколов можно назвать сжатие заголовков, мультиплексирование запросов и поддержку полнодуплексного режима для длительного соединения, что делает их идеальными кандидатами для обмена сообщениями в микросервисных архитектурах.

Объединяя лучшее из двух миров, в 2016 году корпорацией Google был предложен протокол для передачи сообщений и вызова удаленных методов, получивший название gRPC (который стал развитием внутреннего проекта Stubby, созданного для ускорения взаимодействия микросервисов). Вопреки известному заблуждению, буква g не обозначает Google, она меняет свое значение в каждой новой версии протокола (в актуальной версии 1.45 она обозначает gravity). gRPC работает поверх протокола HTTP/2.0 и поддерживается практически всеми веб-серверами и API Gateway, объединяющих системы на основе микросервисов.

Исторически первой библиотекой для создания gRPC-совместимых сервисов на JVM была gRPC-Java, основанная на использовании StreamObserver для поддержки диалога при обмене сообщениями между микросервисами. Очевидным следствием такой реализации становилось увеличение количества вложенных блоков кода, что усложняло чтение и отладку и фактически являлось проявлением callback hell. Кроме этого, для создания сообщений (единица обмена информацией в gRPC) использовались Builder-ы с bean, что увеличивало объем кода и приводило к появлению длинных цепочек подготовки данных.

Например, для отправки сообщения с тремя полями и анализа ответа, код (на Kotlin) мог выглядеть подобным образом:

val request = RegisterRequest.newBuilder().setFirstName("Ivan").setLastName("Ivanov").setAge(22).build() stub.goRegister(request, object: StreamObserver<RegistrationResponse> by DefaultStreamObserver() {   override fun onNext(data: RegistrationResponse) {     stub.doRegisterConfirmation(data.token, object: StreamObserver<RegistrationConfirmation by DefaultStreamObserver() {       override fun onNext(data: RegistrationResponse) {         stub.doRegisterComplete(data.token);       }     })   } }) 

Очевидным решением для Kotlin являлось использование корутин вместо асинхронных подписок на потоки. В этом месте эволюция разделилась на несколько параллельных ветвей:

  1. часть библиотек начала создавать обертки вокруг gRPC-Java и добавлять полезные расширения, при сохранении общей концепции генерации сообщений (поскольку builder-классы создаются официальным инструментом Google для кодогенерации на основе proto-файла)

  2. другие библиотеки стали реализовывать полностью независимую реализацию протокола gRPC, одновременно решая задачу разработки plugin’ов для систем сборки (чаще всего gradle) для кодогенерации по информации из proto-файла.

К первой группе относятся библиотеки Kert (многопротокольный веб-сервер, поддерживает HTTP / GraphQL / с версии 3.0.0 поддерживает также gRPC, вызов функций выполняется с использованием корутин, потоковый обмен данными использует преимущества Flow, предлагает свою реализацию для кодогенерации, аналогичную protoc), Kroto+ (предоставляет возможность вызова сервисов как корутин с возможностью отмены ожидания, реализует собственный Gradle Plugin для создания DSL на основе информации о структуре сообщений, к сожалению не обновляется и не поддерживается уже почти 2 года). И конечно необходимо отметить библиотеку grpc-kotlin, которая в 2020 году опубликована Google под открытой лицензией и является развитием исходной библиотеки grpc-java с использованием возможностей языка программирования Kotlin.

Ко второй группе можно отнести библиотеку Wire, которая полностью реализует протокол gRPC и предлагает модель потоков данных на основе MessageSource / MessageSink и собственную кодогенерацию. Возможности DSL в настоящее время не используются.

Выполним сравнение кода определения сообщения с использованием различных библиотек и вызовом сервиса регистрации:

Kert / gRPC-java:

val user = User.Builder().setLastname("Ivanov").setFirstname("Petr").setMiddlename("Sidorovich").setAge(23).build(); stub.Register(user)

Wire:

val user = User(lastname = "Ivanov", firstname = "Petr", middlename = "Sidorovich", age = 23) GrpcClientProvider.grpcClient.create(RegisterClient::class).Register().execute().let {    (sendChannel, receiveChannel) -> sendChannel.offer(RegisterCommand(user=user))  } 

Kroto+

val user = User {   lastname = "Ivanov"   firstname = "Petr"   middlename = "Sidorovich"   age = 23 } stub.Register(user)

На стороне сервера в Kroto+ функция Register помечается как suspend и формирует ответ в виде сообщения (объекта соответствующего типа или DSL-инициализатора, создающего этот объект в return)

Особое внимание хотелось бы уделить библиотеке grpc-kotlin, которая официально поддерживается Google и поддерживает как использование корутин, так и манипуляции с сообщениями и вызовами с использованием DSL.

Сделаем два микросервиса и настроим обмен сообщениями между ними с использованием grpc-kotlin:

1) Добавим в build.gradle в repositories модуля источник google()

2) В plugins подключим 

id("com.google.protobuf") version "0.8.18"

 3) В dependencies подключим библиотеку

implementation("com.google.protobuf:protobuf-kotlin:3.19.4") api("io.grpc:grpc-protobuf:1.44.0") api("com.google.protobuf:protobuf-java-util:3.19.4") api("com.google.protobuf:protobuf-kotlin:3.19.4") api("io.grpc:grpc-kotlin-stub:1.2.1") api("io.grpc:grpc-stub:1.44.0")

4) Добавим блок конфигурации protobuf

protobuf {     protoc {         artifact = "com.google.protobuf:protoc:3.19.4"     }     plugins {         id("grpc") {             artifact = "io.grpc:protoc-gen-grpc-java:1.44.0"         }         id("grpckt") {             artifact = "io.grpc:protoc-gen-grpc-kotlin:1.2.1:jdk7@jar"         }     }     generateProtoTasks {         all().forEach {             it.plugins {                 id("grpc")                 id("grpckt")             }             it.builtins {                 id("kotlin")             }         }     } }

5) Для поддержки запуска сервера необходимо установить библиотеку встроенного веб-сервера с поддержкой grpc (например, grpc-netty) в dependencies. Аналогично может использоваться расширение ktor с поддержкой gRPC.

runtimeOnly("io.grpc:grpc-netty:1.44.0")

6) Добавим импорт в build.gradle

import com.google.protobuf.gradle.*

Далее создадим каталог protobuf в /src/main, разместим файл register.proto (был приведен выше) и добавим конфигурацию build.gradle:

sourceSets {     main {         proto {             srcDir("src/main/protobuf")         }     } }

Проверим сборку проекта, для этого выполним ./gradlew assemble

После генерации классов на основе proto-файлов для каждого сервиса создается объект с названием <ServiceName>GrpcKt, предоставляющего для использования в Kotlin несколько заглушек:

  • <ServiceName>CoroutineStub — заглушка для вызова сервиса как suspend-функции;

  • <ServiceName>CoroutineImplBase — базовый класс для реализации на сервере (как suspend-функции).

Также создается класс <ServiceName>Grpc с заглушками для использования в Java-коде (и для совместимости с ранее созданными библиотеками на основе gRPC-Java):

  • <ServiceName>ImplBase — базовый класс для серверной реализации метода, использует StreamObserver для отправки и получения ответа в длительном диалоге;

  • <ServiceName>Stub — заглушка для вызова сервиса с подпиской на поток;

  • <ServiceName>BlockingStub — заглушка для вызова сервиса с блокировкой выполнения до получения ответа;

  • <ServiceName>FutureStub — заглушка для вызова сервиса с получением объекта ListenableFuture для отслеживания получения ответа.

Создадим клиентскую часть приложения:

import io.grpc.ManagedChannelBuilder  suspend fun main() {     val port = 50051      val channel = ManagedChannelBuilder.forAddress("localhost", port).usePlaintext().build()     val stub = RegistrationServiceGrpcKt.RegistrationServiceCoroutineStub(channel)     val data = user {         lastname = "Ivanov"         firstname = "Petr"         middlename = "Sidorovich"         age = 23         gender = Register.User.Gender.MALE     }     val result = stub.register(data)     print("Success is ${result.succeeded}") }

Обратите внимание, что вызов функции register является корутиной (ответ возвращается асинхронно), поэтому функция main так же помечена как suspend. При использовании кода внутри обработчиков в веб-серверах (например, в ktor) это подразумевается по умолчанию.

Создадим для проверки в этом же проекте серверную часть приложения:

import io.grpc.ServerBuilder  private class RegistrationService : RegistrationServiceGrpcKt.RegistrationServiceCoroutineImplBase() {     override suspend fun register(request: Register.User): Register.RegistrationResult {         print("Registering user ${request.lastname} ${request.firstname} ${request.middlename}, age: ${request.age}, gender: ${request.gender.name}")         return registrationResult { succeeded=true }     } }  fun main() {     val port = 50051     //prepare and run the gRPC web server     val server = ServerBuilder         .forPort(port)         .addService(RegistrationService())         .build()     server.start()     //shutdown on application terminate     Runtime.getRuntime().addShutdownHook(Thread {         server.shutdown()     })     //wait for connection until shutdown     server.awaitTermination() }

Последовательно запустим серверную и клиентскую часть приложения и убедимся, что gRPC канал работает в обоих направлениях.

Server.kt Registering user Ivanov Petr Sidorovich, age: 23, gender: MALE  Client.kt Success is true

Создание сообщений в grpc-kotlin осуществляется с использованием DSL-синтаксиса (название генератора совпадает с названием класса со строчной буквы), при этом поля, которые помечены как repeated будут доступны как коллекции List, а поля с типом map будут реализованы как DslMap, поддерживающего основные методы чтения и модификации данных, аналогично типу Map. Простые типы данных транслируются в соответствующие типы Kotlin, для поля с типом enum создается вспомогательный статический класс с перечислением именованных констант. 

Таким образом, с использованием актуальных возможностей библиотеки grpc-kotlin количество кода с использованием корутин для реализации клиента и сервера стало значительно меньше, а содержание сообщений может быть сформировано с использованием DSL, что повышает кода и уменьшает количество избыточного кода при создании микросервисов, основанных на взаимодействии по протоколу gRPC.

Исходный текст проекта размещен на github: https://github.com/dzolotov/kotlin-grpc-sample

Также хочу пригласить всех на бесплатный демоурок курса Kotlin Backend Developer, который пройдет уже 9 февраля на платформе OTUS. Регистрация доступна по ссылке.


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/648747/


Комментарии

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

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