Всем привет! Команда тестирования производительности Тинькофф продолжает цикл статей о нагрузочном тестировании различных протоколов с помощью Gatling.

В прошлой статье мы показали, как протестировать JDBC-протокол с помощью Gatling. В этой — разберем протокол gRPC.

Дисклеймер

На момент написания статьи gRPC плагин поддерживает версию Gatling не выше 3.6.1. Для получения работающего скрипта в будущем достаточно будет обновить версию плагина gRPC и версии плагинов в соответствии с их документацией.

Что такое gRPC

Remote Procedure Call(RPC) — класс технологий, позволяющих программам вызывать функции или процедуры в другом адресном пространстве (на удаленных узлах либо в независимой сторонней системе на том же узле). Обычно реализация RPC-технологии включает два компонента: сетевой протокол для обмена в режиме «клиент-сервер» и язык сериализации объектов.

gRPC — это система удаленного вызова процедур (RPC) с открытым исходным кодом, первоначально разработанная в 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) {}

Пример запроса:

{
  "latitude": 409146138,
  "longitude": -746188906
}

Пример ответа:

//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
Импорт файла 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 streaming 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)
Отправилось пять запросов (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 из примеров этой статьи

Комментарии (0)