Привет, Хабр! Сегодня я поделюсь опытом работы с 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(); } }
Запустим наш сервис и протестируем его через консоль. Сохраним нашего питомца и потом получим его.
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 }
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(response.getPet().getPetName()).isEqualTo(pet.getName()); 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.
GitHub проекта : https://github.com/mikhail-vyrostkov/VetClinicApp
