Привет, Хабр! Сегодня я поделюсь опытом работы с gRPC и расскажу как создать и протестировать gRPC-сервис в приложении на Spring Boot. Основная проблема — это отсутствие структурированной информации по корректному тестированию gRPC сервиса. Эта статья будет полезна для тех, кто только начинает знакомиться с gRPC и ищет руководство по написанию и тестированию сервисов.

Настройка проекта

Для начала настроим проект. Создадим отдельный модуль для proto-моделей и назовем его interface-grpc. Добавим необходимые зависимости в pom.xml для работы с gRPC:

<dependencies>
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-stub</artifactId>
    </dependency>
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-protobuf</artifactId>
    </dependency>
    <dependency>
        <groupId>jakarta.annotation</groupId>
        <artifactId>jakarta.annotation-api</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>com.google.protobuf</groupId>
        <artifactId>protobuf-java</artifactId>
        <scope>compile</scope>
    </dependency>
</dependencies>

В блоке build добавим плагин для генерации классов из proto-файла:

<build>
    <extensions>
        <extension>
            <groupId>kr.motd.maven</groupId>
            <artifactId>os-maven-plugin</artifactId>
        </extension>
    </extensions>

    <plugins>
        <plugin>
            <groupId>org.xolstice.maven.plugins</groupId>
            <artifactId>protobuf-maven-plugin</artifactId>
            <configuration>
                <protocArtifact>com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}</protocArtifact>
                <pluginId>grpc-java</pluginId>
                <pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>compile</goal>
                        <goal>compile-custom</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

Написание gRPC Сервиса

Рассмотрим пример простого микросервиса для ветеринарной клиники. Создадим proto-файл для сущности "питомец":

syntax = "proto3";

package grpc.server.grpc_server.pet;

message CreatePetRequest {
  Pet pet = 1;

  message Pet {
    string pet_name = 2;
    string pet_type = 3;
    string pet_birth_date = 4;
  }
}

message CreatePetResponse{
  int32 pet_id = 1;
}

message FindByIdPetRequest {
  int32 pet_id = 1;
}

message FindByIdPetResponse {
  Pet pet = 1;

  message Pet {
    string pet_name = 2;
    string pet_type = 3;
  }
}

message ErrorResponse {
  string error_name = 1;
}

service PetService {
  rpc CreatePet (CreatePetRequest) returns (CreatePetResponse);
  rpc FindByIDPet (FindByIdPetRequest) returns (FindByIdPetResponse);
}

После создания proto-файла запустим сборку проекта, чтобы сгенерировать необходимые классы.

Реализация gRPC Сервиса в Spring Boot

Создадим ещё один модуль в нашем проекте – clinic-grpc-service. Pom-файл у меня выглядит следующим образом:

    <dependencies>
        <dependency>
            <groupId>net.devh</groupId>
            <artifactId>grpc-server-spring-boot-autoconfigure</artifactId>
        </dependency>
      <!--test -->
        <dependency>
            <groupId>net.devh</groupId>
            <artifactId>grpc-client-spring-boot-autoconfigure</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <scope>test</scope>
        </dependency>

Опишем сущности Pet и PetType, а также DTO классы. Затем реализуем интерфейс PetService с методами для сохранения и получения питомца.

Пример реализации PetRequestDTO и PetResponseDTO.

@Builder
public record PetRequestDTO(
        @JsonProperty("pet_name") String petName,
        @JsonProperty("pet_type") String petType,
        @JsonProperty("pet_birth_date") String petBirthDate
){}

@Builder
public record PetResponseDTO(
        @JsonProperty("pet_name") String petName,
        @JsonProperty("pet_type") String petType
){}

Теперь создадим интерфейс PetService c двумя методами: на сохранение и получение сущности:

public interface PetService {

    int createPet(PetRequestDTO pet);

    PetResponseDTO findByIDPet(int id);
}

Наконец, мы подошли к самому интересному – давайте реализуем gRPC service GrpcPetServiceImpl, который будет наследовать автоматически созданный класс PetServiceGrpc.PetServiceImplBase. Реализуем методы createPet и findByIDPet.
На первый взгляд выглядит странно, что метод не возвращает ответ явно. Вместо этого, результат выполнения сохраняется во втором аргументе метода. Основная особенность gRPC в том, что методы могут возвращать не только один объект, а стримить поток данных. Поэтому результат выполнения методов (response) упаковывается в StreamObserver. После того как мы соберем response с помощью newBuilder(), мы можем отсылать наш ответ клиенту с помощью команды onNext(). Завершая отправку данных, мы должны закрыть поток посредством вызова метода onCompleted().

@GrpcService
@RequiredArgsConstructor
public class GrpcPetServiceImpl extends PetServiceGrpc.PetServiceImplBase {
    private final PetService petService;

    @Override
    public void createPet(PetOuterClass.CreatePetRequest request,
                          StreamObserver<PetOuterClass.CreatePetResponse> responseObserver) {

        PetRequestDTO petRequestDTO = PetRequestDTO.builder()
                .petName(request.getPet().getPetName())
                .petType(request.getPet().getPetType())
                .petBirthDate(request.getPet().getPetBirthDate())
                .build();

        PetOuterClass.CreatePetResponse response = PetOuterClass.CreatePetResponse
                .newBuilder()
                .setPetId(petService.createPet(petRequestDTO))
                .build();

        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }

    @Override
    public void findByIDPet(PetOuterClass.FindByIdPetRequest request,
                            StreamObserver<PetOuterClass.FindByIdPetResponse> responseObserver) {

        PetResponseDTO pet = petService.findByIDPet(request.getPetId());

        if (pet == null) {
            Metadata.Key<PetOuterClass.ErrorResponse> errorResponseKey =
                    ProtoUtils.keyForProto(PetOuterClass.ErrorResponse.getDefaultInstance());
            PetOuterClass.ErrorResponse errorResponse = PetOuterClass.ErrorResponse.newBuilder()
                    .setErrorName("This pet with id = " + request.getPetId() + " is not in the database")
                    .build();
            Metadata metadata = new Metadata();
            metadata.put(errorResponseKey, errorResponse);
            responseObserver.onError(
                    NOT_FOUND.withDescription("This pet with id = " + request.getPetId() + " is not found")
                            .asRuntimeException(metadata)
            );
            return;
        }
        PetOuterClass.FindByIdPetResponse response = PetOuterClass.FindByIdPetResponse
                .newBuilder()
                .setPet(PetOuterClass.FindByIdPetResponse.Pet
                        .newBuilder()
                        .setPetName(pet.petName())
                        .setPetType(pet.petType())
                        .build())
                .build();

        responseObserver.onNext(response);
        responseObserver.onCompleted();
    }
}

Запустим наш сервис и протестируем его через консоль. Сохраним нашего питомца и потом получим его.

m.vyrostkov@macbook-KL920DXTK4 VetClinicApp % grpcurl -plaintext -d '{"pet":{"pet_name":"Joo","pet_type":"dog","pet_birth_date":"2023-09-01"}}' localhost:9090 ru.vyrostkov.grpc.server.grpc_server.pet.PetService/CreatePet  

{
  "pet_id": 1
}
m.vyrostkov@macbook-KL920DXTK4 VetClinicApp % grpcurl -plaintext -d '{"pet_id":1}' localhost:9090 ru.vyrostkov.grpc.server.grpc_server.pet.PetService/FindByIDPet

{
  "pet": {
    "pet_name": "Joo",
    "pet_type": "dog"
  }
}

Обработка ошибок в gRPC

Всё отлично, сервис работает. Что произойдёт, если мы запросим у сервиса объект, которого нет в базе? Сервис упадёт с ошибкой java.lang.NullPointerException: Cannot invoke "ru.vyrostkov.grpc.dto.PetResponseDTO.getPetName()" because "pet" is null.

Что бы такого не произошло, я добавил обработку ошибок. В proto-файле описал дополнительный message ErrorResponse. Это пример сообщения об ошибке, которое мы отправим клиенту в виде метаданных.

message ErrorResponse {
  string error_name = 1;
}

Метаданные – это побочный канал, который позволяет клиентам и серверам предоставлять друг другу информацию, связанную с RPC.

Метаданные gRPC – это пара ключ-значение, которая отправляется с начальными или конечными запросами или ответами gRPC.

Для каждой пары ключ-значение метаданных ошибки создаём ключ Metadata.Key<PetOuterClass.ErrorResponse>, а значением будет являться ErrorResponse. После чего сохраняем пары ключ-значение в метаданных, вызывая metadata.put(Key,Value). И в конце вызываем responseObserver, чтобы установить условие ошибки, передавая в него StatusRuntimeException, в который мы прокинем метаданные в Status.

Metadata.Key<PetOuterClass.ErrorResponse> errorResponseKey =
                    ProtoUtils.keyForProto(PetOuterClass.ErrorResponse.getDefaultInstance());
            PetOuterClass.ErrorResponse errorResponse = PetOuterClass.ErrorResponse.newBuilder()
                    .setErrorName("Информация, которую мы вернем клиенту в виде метаданных")
                    .build();

            Metadata metadata = new Metadata();
            metadata.put(errorResponseKey, errorResponse);

            responseObserver.onError(
                    NOT_FOUND.withDescription("Описание ошибки")
                            .asRuntimeException(metadata)
            );

Мы используем io.grpc.Status для указания статуса ошибки. Функция responseObserver::onError принимает Throwable-параметр, поэтому мы используем исключение asRuntimeException (метаданные) для преобразования Status в Throwable.

Если клиент отправляет неверный запрос – сервис вернёт исключение, а не упадёт с ошибкой.

m.vyrostkov@macbook-KL920DXTK4 VetClinicApp % grpcurl -plaintext -d '{"pet_id":2}' localhost:9090 ru.vyrostkov.grpc.server.grpc_server.pet.PetService/FindByIDPet

ERROR:
  Code: NotFound
  Message: This pet with id = 2 is not found

Тестирование

Теперь давайте покроем наш сервис тестами. Воспользуемся библиотеками mockito и spring-boot-test. Мы создали клиент с помощью аннотации @GrpcClient, через который будет осуществляться вызов непосредственно нашего gRPC сервиса. Также необходимо создать заглушку для petService.

Пример возможной реализации тестов может выглядеть следующим образом:

@SpringBootTest(properties = {
        "grpc.server.inProcessName=test",
        "grpc.server.port=9091",
        "grpc.client.petService.address=in-process:test"
})
@SpringJUnitConfig(classes = {GrpcApplication.class})
@Log4j2
public class GrpcPetServiceImplTest {

    @MockBean
    PetService petService;
    @GrpcClient("petService")
    private PetServiceGrpc.PetServiceBlockingStub petServiceBlockingStub;
  
    final int petId = 1;
  
    Pet pet;
    PetRequestDTO petRequestDTO;
    PetResponseDTO petResponseDTO;

    @BeforeEach
    void setUp() {
        pet = Pet
                .builder()
                .name("Bob")
                .petType(PetType.builder().name("dog").build())
                .birthDate(LocalDate.parse("2020-12-11"))
                .build();

        petRequestDTO = PetRequestDTO
                .builder()
                .petName("Bob")
                .petType("dog")
                .petBirthDate("2020-12-11")
                .build();

        petResponseDTO = PetResponseDTO
                .builder()
                .petName("Bob")
                .petType("dog")
                .build();
    }

    @Test
    @DisplayName("JUnit grpc test for find pet by id")
    public void findByIDPetTest() {

        doReturn(petResponseDTO)
                .when(petService)
                .findByIDPet(anyInt());

        PetOuterClass.FindByIdPetRequest request =
                PetOuterClass.FindByIdPetRequest
                        .newBuilder()
                        .setPetId(petId)
                        .build();

        PetOuterClass.FindByIdPetResponse response =
                petServiceBlockingStub.findByIDPet(request);

        assertThat(response).isNotNull();
        assertThat(pet.getName()).isEqualTo(response.getPet().getPetName());
        verify(petService).findByIDPet(petId);
    }

    @Test
    @DisplayName("JUnit grpc test for find pet by id when pet not found")
    public void PetNotFoundWhenFindByIDTest() throws Exception {

        doReturn(null)
                .when(petService)
                .findByIDPet(anyInt());

        PetOuterClass.FindByIdPetRequest request =
                PetOuterClass.FindByIdPetRequest
                        .newBuilder()
                        .setPetId(petId)
                        .build();

        StatusRuntimeException thrown =
                Assertions.assertThrows(StatusRuntimeException.class, () ->
                        petServiceBlockingStub.findByIDPet(request));

        assertThat(thrown.getStatus().getCode().toString())
                .isEqualTo("NOT_FOUND");
        assertThat(thrown.getMessage())
                .isEqualTo("NOT_FOUND: This pet with id = 1 is not found");
        Metadata metadata = Status.trailersFromThrowable(thrown);
        PetOuterClass.ErrorResponse errorResponse =
                metadata.get(ProtoUtils.keyForProto(
                                PetOuterClass.ErrorResponse.getDefaultInstance()
                        )
                );
        assertThat(errorResponse.getErrorName())
                .isEqualTo("This pet with id = 1 is not in the database");

        verify(petService).findByIDPet(petId);
    }
}

Заключение

Мы покрыли наш gRPC сервис тестами, включая позитивные и негативные сценарии. Использование gRPC в Spring Boot позволяет создавать эффективные и масштабируемые микросервисы, а тестирование помогает обеспечить их надежную работу. В результате, разработчики получают инструмент для построения высокопроизводительных и надежных распределенных систем.

Надеюсь, эта статья поможет вам в изучении gRPC и улучшит ваши навыки разработки в Spring Boot.

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


  1. ggo
    13.10.2023 07:10
    +1

    Создавать proto-файлы в обычном java-проекте - это хорошо.

    А если ли инструменты, позволяющие безболезненно импортировать и обновлять proto-файлы из типичных golang-проектов? Типичных golang - имеется ввиду тех, которые добавляются в качестве golang-зависимостей к golang проекту


  1. mikhail-vyrostkov Автор
    13.10.2023 07:10

    По поводу golang, к сожалению, не знаком и с инструментами для работы с proto файлами тоже. Но на просторах интернета есть информацию и, возможно, она будет Вам полезна:


  1. mikhail-vyrostkov Автор
    13.10.2023 07:10

    Существуют инструменты, которые позволяют автоматизировать импорт и обновление proto файлов в типичных Golang проектах. Вот некоторые из них:

    1. protoc-gen-go: Этот инструмент включен в стандартный пакет protobuf для Golang. Он автоматически обрабатывает proto файлы и генерирует соответствующий Go код.

    2. protolint: Это сторонний инструмент, который помогает поддерживать корректность и актуальность proto файлов. Он также может быть использован для проверки синтаксиса и структуры протофайлов.

    3. Protobuf plugin for GoLand/WebStorm/VSCode: Эти плагины для популярных IDE предлагают функции автоматического обновления и рефакторинга proto файлов прямо из интерфейса редактора кода.

    4. Protofy: Это онлайн-сервис, который позволяет конвертировать, просматривать и обновлять proto файлы, а также генерировать соответствующий Go код на основе этих файлов.

    5. ProtoQL: Это инструмент для работы с proto файлами, который предлагает функции проверки синтаксиса, анализа структуры и обновления proto файлов.

    Эти инструменты могут облегчить процесс импорта и обновления proto файлов, и помочь поддерживать корректность и согласованность ваших proto файлов и сгенерированного Go кода.