Создание и тестирование gRPC сервиса (Spring Boot приложение)
Привет, Хабр! Сегодня я поделюсь опытом работы с gRPC и расскажу, как создать и протестировать gRPC-сервис в приложении на Spring Boot. Основная проблема — это отсутствие структурированной информации по корректному тестированию gRPC сервиса. Эта статья будет полезна для тех, кто только начинает знакомиться с gRPC и ищет руководство по написанию и тестированию сервисов.
Настройка проекта
Для начала настроим проект. Создадим отдельный модуль для proto-моделей и назовем его interface-grpc. Добавим необходимые зависимости в pom.xml для работы с gRPC:
io.grpc
grpc-stub
io.grpc
grpc-protobuf
jakarta.annotation
jakarta.annotation-api
true
com.google.protobuf
protobuf-java
compile
В блоке build добавим плагин для генерации классов из proto-файла:
kr.motd.maven
os-maven-plugin
org.xolstice.maven.plugins
protobuf-maven-plugin
com.google.protobuf:protoc:${protobuf.version}:exe:${os.detected.classifier}
grpc-java
io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}
compile
compile-custom
Написание 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-файла запустим сборку проекта, чтобы сгенерировать необходимые классы.
После этого можно сделать сборку проекта и увидеть, что в папке target сформировались необходимые для дальнейшей работы файлы.

Реализация gRPC Сервиса в Spring Boot
Создадим ещё один модуль в нашем проекте — clinic-grpc-service. Pom-файл у меня выглядит следующим образом:
net.devh
grpc-server-spring-boot-autoconfigure
net.devh
grpc-client-spring-boot-autoconfigure
test
org.springframework.boot
spring-boot-test
test
org.junit.jupiter
junit-jupiter-api
test
org.testcontainers
junit-jupiter
test
org.junit.jupiter
junit-jupiter-engine
test
Опишем сущности 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 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 responseObserver) {
PetResponseDTO pet = petService.findByIDPet(request.getPetId());
if (pet == null) {
Metadata.Key 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
Metadata.Key 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
Тестирование
Теперь давайте покроем наш сервис тестами. Воспользуемся библиотеками mocito и 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("NOT_FOUND")
.isEqualTo(thrown.getStatus().getCode().toString());
assertThat("NOT_FOUND: This pet with id = 1 is not found")
.isEqualTo(thrown.getMessage());
Metadata metadata = Status.trailersFromThrowable(thrown);
PetOuterClass.ErrorResponse errorResponse =
metadata.get(ProtoUtils.keyForProto(
PetOuterClass.ErrorResponse.getDefaultInstance()
)
);
assertThat("This pet with id = 1 is not in the database")
.isEqualTo(errorResponse.getErrorName());
verify(petService).findByIDPet(petId);
}
}
Заключение
Мы покрыли наш gRPC сервис тестами, включая позитивные и негативные сценарии. Использование gRPC в Spring Boot позволяет создавать эффективные и масштабируемые микросервисы, а тестирование помогает обеспечить их надежную работу. В результате, разработчики получают инструмент для построения высокопроизводительных и надежных распределенных систем.
Надеюсь, эта статья поможет вам в изучении gRPC и улучшит ваши навыки разработки в Spring Boot.
