
Очень часто при проектировании высоконагруженных систем, основанных на микросервисной архитектуре, обнаруживается что «узким» местом, ограничивающим производительность системы и возможности ее масштабирования, становится передача сообщений и временные затраты на сериализацию-десериализацию сообщений и дополнительные расходы на установку соединения и начальные согласования.
Также большое значение имеет тот факт, что микросервисы могут быть реализованы на разных технологиях разработки и при создании единого протокола обмена сообщениями необходимо было избежать проблем с неоднозначной интерпретацией пересылаемых значений (различные кодировки символов, порядок байтов в многобайтовых числах и т.п.), а также с согласованием идентификаторов полей и типов данных отправляемого информационного сообщения.
Наиболее очевидным решением стало использование универсального текстового представления с использованием кодировки 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 являлось использование корутин вместо асинхронных подписок на потоки. В этом месте эволюция разделилась на несколько параллельных ветвей:
-
часть библиотек начала создавать обертки вокруг gRPC-Java и добавлять полезные расширения, при сохранении общей концепции генерации сообщений (поскольку builder-классы создаются официальным инструментом Google для кодогенерации на основе proto-файла)
-
другие библиотеки стали реализовывать полностью независимую реализацию протокола 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/
Добавить комментарий