Высокопроизводительные микросервисы на 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. Регистрация доступна по ссылке.

Читайте так же:

  • Realme C20A оснащён 6,5-дюймовым экран и батареей ёмкостью 5000 мА•чRealme C20A оснащён 6,5-дюймовым экран и батареей ёмкостью 5000 мА•ч Realme выпустила несколько телефонов серии C, таких как Realme C20, Realme C21 и Realme C25, а теперь готовится выпустить еще один телефон под названием Realme C20A. Бангладешское крыло Realme намекнуло на скорое появление этого телефона в конце этого месяца. Realme Bangladesh […]
  • Google окончательно удалила код протокола FTP из ChromeGoogle окончательно удалила код протокола FTP из Chrome Команда разработчиков Chromium полностью отключила поддержку протокола FTP вместе с релизом браузера Chrome 95. Это решение назрело не в одночасье: еще с выходом 72 версии браузера разработчики предпринимали попытки отключить поддержку безнадежно устаревшего незащищенного […]
  • Как запустить рекламную кампанию в Instagram с привлечением микроблогеровКак запустить рекламную кампанию в Instagram с привлечением микроблогеров В 2022 году рынок influence-маркетинга продолжает расти, и в противовес публичным авторитетам все большую популярность набирают микроблогеры — нишевые эксперты. Несмотря на небольшое количество подписчиков (5-100 тысяч), они тесно коммуницируют с ЦА, знают все ее боли и легко […]
  • Космическая компания Success Rockets осуществила первый суборбитальный пускКосмическая компания Success Rockets осуществила первый суборбитальный пуск Суборбитальная ракета NEBO 25 была запущена 23 декабря частной российской космической компанией Success Rockets в Астраханской области. Пуск стал первым для суборбитальной ракеты Nebo 25 и вторым для компании в 2021 году (прототип суборбитальной ракеты был запущен в апреле 2021 года). […]