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

Импорт файла route_guide.proto в BloomRPC

Разработка скрипта для 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-сервера.

Отправилось пять запросов (debugSending message) в поток (Client Stream: Get route summary — Client Stream: OK) и пришел успешный ответ от сервера (gRPC response: status= OK)

Шаг 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, — с примерами и шагами. Будем рады вопросам и идеям в комментариях.

Полезные ссылки

  1. Gatling gRPC plugin

  2. Проект Gatling из примеров этой статьи