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

Kate

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

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

Наиболее очевидным решением стало использование универсального текстового представления с использованием кодировки 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


GitHub - dzolotov/kotlin-grpc-sample
github.com


 
Сверху