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

image-loader.svg

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

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

Наиболее очевидным решением стало использование универсального текстового представления с использованием кодировки 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 by DefaultStreamObserver() {
  override fun onNext(data: RegistrationResponse) {
    stub.doRegisterConfirmation(data.token, object: StreamObserver

Очевидным решением для 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-файлов для каждого сервиса создается объект с названием GrpcKt, предоставляющего для использования в Kotlin несколько заглушек:

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

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

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

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

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

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

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

Backend-разработка на Kotlin: фреймворк для создания высоконагруженных серверных API

otus.ru

© Habrahabr.ru