Привет, Хабр! Сегодня я поделюсь опытом работы с 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)
mikhail-vyrostkov Автор
13.10.2023 07:10По поводу golang, к сожалению, не знаком и с инструментами для работы с proto файлами тоже. Но на просторах интернета есть информацию и, возможно, она будет Вам полезна:
mikhail-vyrostkov Автор
13.10.2023 07:10Существуют инструменты, которые позволяют автоматизировать импорт и обновление proto файлов в типичных Golang проектах. Вот некоторые из них:
protoc-gen-go: Этот инструмент включен в стандартный пакет protobuf для Golang. Он автоматически обрабатывает proto файлы и генерирует соответствующий Go код.
protolint: Это сторонний инструмент, который помогает поддерживать корректность и актуальность proto файлов. Он также может быть использован для проверки синтаксиса и структуры протофайлов.
Protobuf plugin for GoLand/WebStorm/VSCode: Эти плагины для популярных IDE предлагают функции автоматического обновления и рефакторинга proto файлов прямо из интерфейса редактора кода.
Protofy: Это онлайн-сервис, который позволяет конвертировать, просматривать и обновлять proto файлы, а также генерировать соответствующий Go код на основе этих файлов.
ProtoQL: Это инструмент для работы с proto файлами, который предлагает функции проверки синтаксиса, анализа структуры и обновления proto файлов.
Эти инструменты могут облегчить процесс импорта и обновления proto файлов, и помочь поддерживать корректность и согласованность ваших proto файлов и сгенерированного Go кода.
ggo
Создавать proto-файлы в обычном java-проекте - это хорошо.
А если ли инструменты, позволяющие безболезненно импортировать и обновлять proto-файлы из типичных golang-проектов? Типичных golang - имеется ввиду тех, которые добавляются в качестве golang-зависимостей к golang проекту