Создание и тестирование gRPC сервиса (Spring Boot приложение)

от автора

Привет, Хабр! Сегодня я поделюсь опытом работы с 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


ссылка на оригинал статьи https://habr.com/ru/articles/767130/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *