Использование Gatling. Тестирование gRPC
Всем привет! Команда тестирования производительности Тинькофф продолжает цикл статей о нагрузочном тестировании различных протоколов с помощью 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, — с примерами и шагами. Будем рады вопросам и идеям в комментариях.