
Всем привет! Команда тестирования производительности Тинькофф продолжает цикл статей о нагрузочном тестировании различных протоколов с помощью Gatling.
В прошлой статье мы показали, как протестировать JDBC-протокол с помощью Gatling. В этой — разберем протокол gRPC.
Дисклеймер
На момент написания статьи gRPC плагин поддерживает версию Gatling не выше 3.6.1. Для получения работающего скрипта в будущем достаточно будет обновить версию плагина gRPC и версии плагинов в соответствии с их документацией.
Что такое gRPC
Remote Procedure Call(RPC) — класс технологий, позволяющих программам вызывать функции или процедуры в другом адресном пространстве (на удаленных узлах либо в независимой сторонней системе на том же узле). Обычно реализация RPC-технологии включает два компонента: сетевой протокол для обмена в режиме «клиент-сервер» и язык сериализации объектов.
gRPC — система удаленного вызова процедур, с открытым исходным кодом, создана компанией Google в 2015 году. В качестве транспорта используется HTTP/2, в качестве языка описания интерфейса — Protocol Buffers. gRPC может эффективно соединять сервисы внутри дата-центров и между дата-центрами с балансировкой нагрузки, трейсингом, health checking и аутентификацией.
В Protocol Buffers (protobuf) указывается структура для передачи, кодирования и обмена данных. Protobuf — это протокол сериализации структурированных данных и эффективная бинарная альтернатива текстовому XML. Protocol Buffers проще, компактнее и быстрее, чем XML, потому что быстрее проходит передача бинарных данных, оптимизированных под минимальный размер сообщения.
Тестовый сервис gRPC
Для разработки скрипта развернем тестовый сервис gRPC — RouteGuide. Сервис принимает на вход координаты и возвращает информацию о маршруте. В нем реализованы четыре метода.
Унарный RPC (Unary RPC), когда клиент отправляет запрос на сервер и ждет ответа, как при обычном вызове функций:
// Отправляет координаты, получает объект в заданной позиции rpc GetFeature(Point) returns (Feature) {}
Пример запроса:
{ "latitude": 409146138, "longitude": -746188906 }
Пример ответа:
{ "name": "Berkshire Valley Management Area Trail, Jefferson, NJ, USA", "location": { "latitude": 409146138, "longitude": -746188906 } }
RPC с потоковой передачей на стороне сервера (Server streaming RPC), когда клиент отправляет запрос на сервер и получает поток для обратного чтения последовательности сообщений. Клиент читает из потока, пока не закончатся сообщения.
// Отправляет координаты прямоугольника, получает объекты // в заданном прямоугольнике, результат передается в потоке rpc ListFeatures(Rectangle) returns (stream Feature) {}
Пример запроса:
{ "lo": { "latitude": 408122808, "longitude": -743999179 }, "hi": { "latitude": 407838351, "longitude": -746143763 } }
Пример ответа:
//Message 1 { "name": "Patriots Path, Mendham, NJ 07945, USA", "location": { "latitude": 407838351, "longitude": -746143763 } } //Message 2 { "name": "101 New Jersey 10, Whippany, NJ 07981, USA", "location": { "latitude": 408122808, "longitude": -743999179 } }
RPC с потоковой передачей на стороне клиента (Client streaming RPC) похож на унарный RPC. Отличие в том, что клиент вместо одного сообщения отправляет поток. Сервер обычно отвечает одним сообщением, что получил весь поток. Но может ответить и несколькими.
// Передает поток с координатами по пройденному маршруту, // получает описание маршрута rpc RecordRoute(stream Point) returns (RouteSummary) {}
Пример запроса:
//Stream 1 { "latitude": 400273442, "longitude": -741220915 } //Stream 2 { "latitude": 400273442, "longitude": -741220915 }
Пример ответа:
{ "point_count": 2, "feature_count": 2, "distance": 93878, "elapsed_time": 10 }
Двунаправленный потоковый RPC (Bidirectional streaming RPC), в котором обе стороны отправляют последовательность сообщений, используя поток чтения-записи. Два потока работают независимо, поэтому клиенты и серверы могут читать и писать в любом порядке.
Например, сервер может дождаться получения всех клиентских сообщений, прежде чем писать свои ответы. Может поочередно читать сообщения, затем писать сообщения либо использовать другую комбинацию чтения и записи. Порядок сообщений в каждом потоке сохраняется.
// Передает поток заметок, получает поток всех отправленных заметок rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
Пример запроса:
//Client stream, message 1 { "location": { "latitude": 0, "longitude": 1 }, "message": "Hello 1" } //Client stream, message 2 { "location": { "latitude": 0, "longitude": 2 }, "message": "Hello 2" }
Пример ответа:
//Server stream, message 1 { "location": { "latitude": 0, "longitude": 1 }, "message": "Hello 1" } //Server stream, message 2 { "location": { "latitude": 0, "longitude": 2 }, "message": "Hello 2" }
Чтобы развернуть тестовый сервер, необходим установленный docker. Поднять сервис можно с помощью docker-compose. Создаем файл docker-compose.yml:
version: "3.9" services: grpcmock: image: dmitriysmol/grpc-mock:1.1 container_name: grpc-mock ports: - '9001:9001'
Запускаем файл для инициализации сервиса:
docker-compose up
В результате по адресу localhost:9001 будет доступен тестовый сервис gRPC.
Протокол Protobuf
Мы будем использовать Protobuf в качестве языка определения интерфейса (Interface definition language, IDL). Protobuf IDL — это протокол сериализации, который используется для передачи RPC вызовов по сети, определяется в файлах .proto. Для примера используем protobuf-файл — route_guide.proto, файл взят из репозитория grpc-go. В нем описаны сервис RouteGuide c методами и различные сообщения Message c соответствующими полями. Можно скопировать в репозиторий, чтобы запустить весь проект:
// Copyright 2015 gRPC authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. syntax = "proto3"; option go_package = "google.golang.org/grpc/examples/route_guide/routeguide"; option java_multiple_files = true; option java_package = "io.grpc.examples.routeguide"; option java_outer_classname = "RouteGuideProto"; package routeguide; // Interface exported by the server. service RouteGuide { // A simple RPC. // // Obtains the feature at a given position. // // A feature with an empty name is returned if there's no feature at the given // position. rpc GetFeature(Point) returns (Feature) {} // A server-to-client streaming RPC. // // Obtains the Features available within the given Rectangle. Results are // streamed rather than returned at once (e.g. in a response message with a // repeated field), as the rectangle may cover a large area and contain a // huge number of features. rpc ListFeatures(Rectangle) returns (stream Feature) {} // A client-to-server streaming RPC. // // Accepts a stream of Points on a route being traversed, returning a // RouteSummary when traversal is completed. rpc RecordRoute(stream Point) returns (RouteSummary) {} // A Bidirectional streaming RPC. // // Accepts a stream of RouteNotes sent while a route is being traversed, // while receiving other RouteNotes (e.g. from other users). rpc RouteChat(stream RouteNote) returns (stream RouteNote) {} } // Points are represented as latitude-longitude pairs in the E7 representation // (degrees multiplied by 10**7 and rounded to the nearest integer). // Latitudes should be in the range +/- 90 degrees and longitude should be in // the range +/- 180 degrees (inclusive). message Point { int32 latitude = 1; int32 longitude = 2; } // A latitude-longitude rectangle, represented as two diagonally opposite // points "lo" and "hi". message Rectangle { // One corner of the rectangle. Point lo = 1; // The other corner of the rectangle. Point hi = 2; } // A feature names something at a given point. // // If a feature could not be named, the name is empty. message Feature { // The name of the feature. string name = 1; // The point where the feature is detected. Point location = 2; } // A RouteNote is a message sent while at a given point. message RouteNote { // The location from which the message is sent. Point location = 1; // The message to be sent. string message = 2; } // A RouteSummary is received in response to a RecordRoute rpc. // // It contains the number of individual points received, the number of // detected features, and the total distance covered as the cumulative sum of // the distance between each point. message RouteSummary { // The number of points received. int32 point_count = 1; // The number of known features passed while traversing the route. int32 feature_count = 2; // The distance covered in metres. int32 distance = 3; // The duration of the traversal in seconds. int32 elapsed_time = 4; }
Отладка запросов gRPC
Один из вариантов отладки запросов gRPC — GUI-клиент BloomRPС. Этот клиент позволяет на основе protobuf-файла формировать запросы и отправлять их в сервис gRPC в GUI-режиме.
Клиент поддерживает все описанные выше типы RPC-запросов.
Импортируем наш protobuf-файл route_guide.proto в BloomRPС кнопкой Import protos. Выбираем GetFuture, указываем адрес тестового сервиса localhost:9001, подставляем параметры из первого примера.

Разработка скрипта для gRPC
Мы не будем разрабатывать проект с нуля, а используем готовый шаблон. Создадим с его помощью проект mygrpc. Процесс создания мы описывали в первой статье цикла.
В результате сформируется готовый проект, но по умолчанию он для HTTP-протокола. Давайте перепишем его для gRPC-протокола.
Шаг 1. Обновление зависимостей. Чтобы генерировать Scala-код из файла protobuf, добавим в проект плагин ScalaPB: вместо <current version> подставим актуальные версии плагина. Для этого создаем scalapb.sbt в директории project.
addSbtPlugin("com.thesamet" % "sbt-protoc" % "<current version>") libraryDependencies += "com.thesamet.scalapb" %% "compilerplugin" % "<current version>"
Добавим в файл build.sbt:
Test / PB.targets := Seq( scalapb.gen() -> (Test / sourceManaged).value )
Для работы с протоколом gRPC подключим плагин gRPC, вместо <current version> подставляем актуальную версию. Для этого добавим в файл project/Dependencies.scala.
lazy val gatlingGrpc: Seq[ModuleID] = Seq( "com.github.phisgr" % "gatling-grpc" % "<current version>" % "test" ) lazy val grpcDeps: Seq[ModuleID] = Seq( "io.grpc" % "grpc-netty" % scalapb.compiler.Version.grpcJavaVersion, "com.thesamet.scalapb" %% "scalapb-runtime-grpc" % scalapb.compiler.Version.scalapbVersion, "com.thesamet.scalapb" %% "scalapb-runtime" % scalapb.compiler.Version.scalapbVersion % "protobuf" )
Добавим зависимости в build.sbt:
libraryDependencies ++= gatlingGrpc, libraryDependencies ++= grpcDeps,
Загрузим новые зависимости в проект, запустив команду в консоли:
sbt update
Шаг 2. Генерация Scala-кода на основе protobuf-файла. Добавим protobuf-файл route_guide.proto тестового сервиса gRPC в директорию src/test/protobuf. Предварительно создадим ее для генерации на основе protobuf-файла Scala-кода.
Запустим скрипт генерации классов Scala на основе protobuf-файла. Для этого в директории с проектом в терминале выполним команду:
sbt test
После запуска в папке \target\scala-<current version>\src_managed\test\io.grpc.examples.routeguide.route_guide будут созданы scala-классы.
Шаг 3. Переменные сервиса. В файле src/test/resources/simulation.conf хранятся дефолтные переменные для запуска. Давайте добавим в него переменные, которые определяют подключение к тестовому gRPC-сервису.
simulation.conf — это файл, который содержит переменные со значениями:
grpcHost: "localhost" grpcPort: 9001
Шаг 4. Unary RPC. Сначала рассмотрим унарные запросы. В директории сases создадим новый файл для объекта GrpcActions. Для примера создадим действие, которое описывает унарный RPC GetFeature: отправляет координаты, получает объект в заданной позиции.
GrpsActions.scala:
package ru.tinkoff.load.mygrpc.cases import com.github.phisgr.gatling.grpc.Predef._ import com.github.phisgr.gatling.grpc.action._ import com.github.phisgr.gatling.grpc.request._ import io.gatling.core.Predef._ import io.grpc.Status import io.grpc.examples.routeguide.route_guide._ object GrpcActions { val getFeature: GrpcCallActionBuilder[Point, Feature] = grpc( "Get feature", // имя запроса, отображаемое в отчете, // следует заполнять без какой-либо интерполяции или подстановки переменных ) .rpc(RouteGuideGrpc.METHOD_GET_FEATURE) // используемый метод тестового сервиса GetFeature .payload( Point( latitude = 409146138, longitude = -746188906, ), ) // отправляемый запрос в gRPC-сервис .extract(_.some)(_ notNull) // извлечение всего ответа и проверка, что он не пустой .extract(_.location.get.latitude.some)( _ saveAs "responseLatitude", ) // извлечение параметра ответа и сохранение в переменную responseLatitude .check(statusCode is Status.Code.OK) // проверка статуса ответа }
Методы и параметры gRPC-сервиса, которые можно использовать для запросов, описаны в route_guide.proto. Функция extract() опциональная и позволяет извлечь данные из ответа для проверки или для сохранения в переменную. Получить данные из переменной можно через вызов ${responseLatitude}.
Шаг 5. Сценарий теста. В CommonScenario опишем класс, в котором создаем сценарий — порядок выполнения определенных действий.
CommonScenario.scala:
package ru.tinkoff.load.mygrpc.scenarios import io.gatling.core.Predef._ import io.gatling.core.structure.ScenarioBuilder import ru.tinkoff.load.mygrpc.cases.GrpcActions class CommonScenario { val unaryRpcScenario: ScenarioBuilder = scenario("Unary RPC") .exec(GrpcActions.getFeature) }
Шаг 6. Описание gRPC-протокола. В файле mygrpc.scala опишем протокол, таким образом мы укажем хост и порт для подключения и необходимость использования gRPC-протокола:
package ru.tinkoff.load import com.github.phisgr.gatling.grpc.Predef._ import com.github.phisgr.gatling.grpc.protocol.StaticGrpcProtocol import ru.tinkoff.gatling.config.SimulationConfig._ package object mygrpc { val grpcHost: String = getStringParam("grpcHost") val grpcPort: Int = getIntParam("grpcPort") val grpcProtocol: StaticGrpcProtocol = grpc(managedChannelBuilder(grpcHost, grpcPort).usePlaintext()) }
Шаг 7. Нагрузочные тесты. В файле Debug.scala добавим вызов сценария CommonScenario().unaryRpcScenario с использованием протокола grpcProtocol, чтобы описать наш Debug-тест:
package ru.tinkoff.load.mygrpc import io.gatling.core.Predef._ import ru.tinkoff.gatling.config.SimulationConfig.testDuration import ru.tinkoff.load.mygrpc.scenarios.CommonScenario class Debug extends Simulation { setUp( new CommonScenario().unaryRpcScenario // запускаем наш сценарий .inject(atOnceUsers(1)), // запускать будет один пользователь — одну итерацию ).protocols(grpcProtocol) // работа будет проходить по протоколу, который описан в grpcProtocol .maxDuration(testDuration) }
По аналогии добавим вызов сценария CommonScenario().unaryRpcScenario с использованием протокола grpcProtocol в MaxPerformance и Stability для использования в тестах поиска максимальной производительности и стабильности соответственно.
Шаг 8. Запуск Debug-теста. Чтобы посмотреть все запросы и ответы, добавим в файл logback.xml логирование запросов gRPC. Файл расположен в директории src/test/resources:
<logger name="com.github.phisgr.gatling.grpc" level="TRACE" />
Запустим скрипт через ранее созданную конфигурацию. Нажимаем Run Debug и ждем выполнения скрипта. В Debug-консоли можно увидеть запрос и ответ от gRPC-сервера. Ниже видно, что запрос отправился — Request: Get feature: OK и пришел успешный ответ от сервера — gRPC response: status= OK.
Шаг 9. Использование Feeders. Подробно о Feeders писали в первой статье цикла. В нашем примере будем использовать фидеры из подключаемой библиотеки gatling-picatinny. Мы создаем собственный фидер, который принимает на вход имя переменной для использования в скриптах и функцию для генерации тестовых данных.
package ru.tinkoff.gatling.feeders import io.gatling.core.feeder.Feeder object CustomFeeder { def apply[T](paramName: String, f: => T): Feeder[T] = feeder[T](paramName)(f) }
В нашем проекте CustomFeeder будет принимать на вход имя переменной для использования в скриптах и функцию для генерации Point. Так значение не будет храниться в памяти, а будет генерироваться каждый раз при вызове. В директории mygrpc создадим новую директорию feeders, а в ней object Feeders:
package ru.tinkoff.load.mygrpc.feeders import io.gatling.core.feeder.Feeder import io.grpc.examples.routeguide.route_guide.Point import ru.tinkoff.gatling.feeders.CustomFeeder import scala.util.Random object Feeders { val pointFeeder: Feeder[Point] = CustomFeeder( "randPoint", new Point( latitude = Random.between(400273442, 419999544), longitude = Random.between(-749836354, -741058078), ), ) }
Шаг 10. Client streaminf RPC. На этом этапе добавим потоковую передачу на стороне клиента — Client streaming RPC. Она используется в методе тестового сервиса gRPC RecordRoute. Эта передача отправляет поток с координатами по пройденному маршруту, а обратно получает описание маршрута. В файл GrpcActions добавим необходимые методы:
val clientStream: ClientStream = grpc( "Get route summary", // имя запроса, отображаемое в отчете, следует заполнять без какой-либо интерполяции или подстановки переменных ) .clientStream("Client Stream") // создание клиентского потока val recordRouteConnect: ClientStreamStartActionBuilder[Point, RouteSummary] = clientStream .connect(RouteGuideGrpc.METHOD_RECORD_ROUTE) // используемый метод тестового сервиса RecordRoute .check(statusCode is Status.Code.OK) // открытие потока val recordRouteSend: StreamSendBuilder[Point] = clientStream .send("${randPoint}") // отправка запроса (реализуется в фидере) в поток val recordRouteСompleteAndWait: ClientStreamCompletionBuilder = clientStream.completeAndWait // закрытие потока и ожидание ответа от сервера
В CommonScenario добавим вызов методов для потоковой передачи запросов на стороне клиента — Client streaming RPC:
import ru.tinkoff.load.mygrpc.feeders.Feeders.pointFeeder import scala.concurrent.duration.DurationInt val clientStreamScenario: ScenarioBuilder = scenario("Client stream RPC") .exec(GrpcActions.recordRouteConnect) // открываем клиентский поток .repeat(5) { pause(1.seconds) .feed(pointFeeder) // вызываем фидер .exec(GrpcActions.recordRouteSend) // отправляем запрос в поток } .exec(GrpcActions.recordRouteСompleteAndWait) // закрываем поток и ждем ответа от сервера
В файле Debug.scala добавим вызов сценария CommonScenario().clientStreamScenario с использованием протокола grpcProtocol по аналогии с шагом 7. Запуск проводится как в шаге 8.
После выполнения скрипта в Debug консоли увидим запросы клиента и ответ от gRPC-сервера.

Шаг 11. Server streaming RPC. Добавим потоковую передачу на стороне сервера — Server streaming RPC. Эта передача используется в методе тестового сервиса gRPC ListFeatures: отправляет координаты прямоугольника, получает объекты в заданном прямоугольнике, результат передается в потоке. В файл GrpcActions добавим необходимые методы:
// Server stream RPC val serverStream: ServerStream = grpc("Get features in rectangle") .serverStream(streamName = "Server Steam") val listFeaturesStart: ServerStreamStartActionBuilder[Rectangle, Feature] = serverStream .start(RouteGuideGrpc.METHOD_LIST_FEATURES)( Rectangle( lo = Option( Point( latitude = 400000000, longitude = -750000000, ), ), hi = Option( Point( latitude = 420000000, longitude = -730000000, ), ), ), ) .timestampExtractor { (_, _, streamStartTime) => streamStartTime } .endCheck(statusCode is Status.Code.OK)
В CommonScenario добавим вызов методов для потоковой передачи запросов на стороне клиента — Client streaming RPC:
// Server stream RPC val serverStreamScenario: ScenarioBuilder = scenario("Server stream RPC") .exec(GrpcActions.listFeaturesStart) .during(testDuration) { pause(1.seconds) }
Шаг 12. Bidirectional streaming RPC. Добавим потоковую двунаправленную передачу, которая используется в методе тестового сервиса gRPC RouteChat и передает поток заметок, а обратно получает поток всех отправленных заметок. В файл GrpcActions добавим необходимые методы:
// Bidirectional RPC val bidiStream: BidiStream = grpc("Chat route notes") .bidiStream(streamName = "Bidi Steam") val RouteChatCon: BidiStreamStartActionBuilder[RouteNote, RouteNote] = bidiStream .connect(RouteGuideGrpc.METHOD_ROUTE_CHAT) .timestampExtractor { (_, _, streamStartTime) => streamStartTime } .endCheck(statusCode is Status.Code.OK) val RouteChatSend: StreamSendBuilder[RouteNote] = bidiStream .send("${randRouteNote}") val RouteChatComplete: StreamCompleteBuilder = bidiStream.complete
Создадим Feeder, который генерирует рандомную точку в заданном диапазоне и рандомную строку сообщения:
val randomString = RandomStringFeeder("randomMessage", 15) val routeNoteFeeder: Feeder[RouteNote] = CustomFeeder( "randRouteNote", new RouteNote( Option( Point( latitude = Random.between(400273442, 419999544), longitude = Random.between(-749836354, -741058078), ), ), message = randomString.next().apply("randomMessage"), ), )
В CommonScenario добавим вызов методов для потоковой передачи запросов на стороне клиента — Client streaming RPC:
// Bidirectional RPC val bidiScenario: ScenarioBuilder = scenario("Bidirectional RPC") .feed(routeNoteFeeder) .exec(GrpcActions.RouteChatCon) .repeat(5) { feed(routeNoteFeeder) .exec(GrpcActions.RouteChatSend) } .during(testDuration) { pause(1.seconds) } .exec(GrpcActions.RouteChatComplete)
Заключение
Обычно нагрузочное тестирование протоколов gRPC встречается намного реже, чем тестирование HTTP. Но может быть полезно знать и уметь его проводить на случай «а вдруг».
В следующей статье подробно расскажем, как проводить нагрузочное тестирование Kafka, — с примерами и шагами. Будем рады вопросам и идеям в комментариях.
