Реализация gRPC с помощью Java и Spring Boot

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Хорошо бы понимать различия между HTTP/1.1 и HTTP/2, поскольку gRPC использует HTTP/2 по умолчанию.

HTTP/1.1 vs HTTP/2

Характеристики HTTP/1.1:

  • Текстовый формат

  • Заголовки в текстовом формате

  • TCP-соединение требует «трехстороннего рукопожатия» (three-way handshake) — один запрос и ответ с одним единственным TCP-соединением.

Характеристики HTTP/2:

  • Бинарный формат

  • Сжатие заголовков

  • Управление потоком

  • Мультиплексирование (одно и то же TCP-соединение может быть повторно использовано для мультиплексирования. Потоковая передача с сервера — потоковая передача от клиента — возможна двунаправленная потоковая передача)

Посмотреть, как ведет себя загрузка каждой части для HTTP1 и HTTP2 (мультиплексирование) можно по этой ссылке.

В этом разделе постараемся разобраться в причинах, которые могут повлиять на решение о переходе с REST и JSON к gRPC с использованием Protocol Buffers.

JSON vs gRPC (использование Protocol Buffers)

JSON

  • Нет поддержки определения схемы документа.

  • Текстовый формат (поэтому сериализация/десериализация выполняется медленно и требует больших затрат ресурсов).

  • Используется в REST.

gPRC

  • Строгое определение схемы и безопасность типов (IDL: язык определения интерфейса для API).

  • Бинарный, что делает сериализацию/десериализацию быстрее.

  • Автоматическая генерация кода, оптимизированная для межсервисного взаимодействия.

  • HTTP/2 используется по умолчанию, поэтому поддерживается мультиплексирование.

gPRC клиент-сервисная коммуникация
gPRC клиент-сервисная коммуникация
Настройка проекта (состоит из интерфейсного, серверного и клиентского проектов)
Настройка проекта (состоит из интерфейсного, серверного и клиентского проектов)

Сравнение производительности представлено в этом руководстве. На видео показываю в действии:

Типы данных

int32 (для int) — значение по умолчанию: 0
int64 (для long) — значение по умолчанию: 0
float — значение по умолчанию: 0
double — значение по умолчанию: 0
bool — значение по умолчанию: false
string — значение по умолчанию: пустая строка
байт (для byte[])
repeated (для List/Collection)
map (для Map) — значение по умолчанию: empty map
enum — значение по умолчанию: первое значение в списке значений.

Есть также классы-обёртки (как Integer в Java), которые можно использовать, предварительно импортировав их в proto-файл.

import “google/protobuf/wrappers.proto”;

Использование:

google.protobuf.UInt64Value id_number = 1;

Если нужно добавить поле метки времени, можно добавить импорт, как описано здесь:

import "google/protobuf/timestamp.proto";

Использование:

google.protobuf.Timestamp timestamp = 2;

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

Рекомендуется создать отдельный модуль для proto-модели и определений сервисов для общего использования (как зависимость).

В соглашении об именовании proto-файлов рекомендуется использовать "lower_snake_case.proto". Руководство по стилю можно посмотреть здесь.

Имена переменных также должны быть написаны в нижнем регистре с использованием знака подчеркивания в качестве разделителя между словами.

Я создал proto-файл с именем "city_score.proto":

syntax = "proto3";

import "google/protobuf/timestamp.proto";

package cityscore;

option java_package = "com.nils.gprc.cityscore";
option java_multiple_files = true;

message CityScoreRequest {
  int32 city_code = 1;
}

message CityScoreResponse {
  int32 city_score = 1;
}

enum CityScoreErrorCode {
  INVALID_CITY_CODE_VALUE = 0;
  CITY_CODE_CANNOT_BE_NULL = 1;
}

message CityScoreExceptionResponse {
  google.protobuf.Timestamp timestamp = 1;
  CityScoreErrorCode error_code = 2;
}

service CityScoreService {
  // unary
  rpc calculateCityScore(CityScoreRequest) returns (CityScoreResponse) {};
}

При выполнении компиляции в Maven я получил ошибку:

Поэтому добавил эти свойства:

<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>16</maven.compiler.source>
<maven.compiler.target>16</maven.compiler.target>

Затем получил ошибку компиляции:

Для этого я обновил зависимости Maven до последней версии, как указано здесь:

<dependency>
  <groupId>io.grpc</groupId>
  <artifactId>grpc-netty-shaded</artifactId>
  <version>1.41.0</version>
</dependency>
<dependency>
  <groupId>io.grpc</groupId>
  <artifactId>grpc-protobuf</artifactId>
  <version>1.41.0</version>
</dependency>
<dependency>
  <groupId>io.grpc</groupId>
  <artifactId>grpc-stub</artifactId>
  <version>1.41.0</version>
</dependency>
<dependency> <!-- necessary for Java 9+ -->
  <groupId>org.apache.tomcat</groupId>
  <artifactId>annotations-api</artifactId>
  <version>6.0.53</version>
  <scope>provided</scope>
</dependency>

Все шло хорошо, пока я не получил в проекте сервиса ошибку несоответствия версий proto-зависимостей (которые появились после того, как я добавил общий proto-модуль в качестве зависимости) и "grpc-server-spring-boot-starter", поэтому мне пришлось понизить версии proto-зависимостей до 1.37. Именно эту версию использует последняя версия grpc-server-spring-boot-starter — 2.12.0.RELEASE:

<dependencies>
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-netty-shaded</artifactId>
        <version>1.37.0</version>
    </dependency>
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-protobuf</artifactId>
        <version>1.37.0</version>
    </dependency>
    <dependency>
        <groupId>io.grpc</groupId>
        <artifactId>grpc-stub</artifactId>
        <version>1.37.0</version>
    </dependency>
    <dependency> <!-- necessary for Java 9+ -->
        <groupId>org.apache.tomcat</groupId>
        <artifactId>annotations-api</artifactId>
        <version>6.0.53</version>
        <scope>provided</scope>
    </dependency>
</dependencies>

После успешной компиляции в Maven я получил сгенерированные исходники для proto-файла:

Структура моего проекта

  • City Score Service

  • Score Segment Service

  • Score Calculator Service (сервис-агрегатор, которая вызывает как City Score, так и Score Segment).

Как вы увидите из примеров кода, я буду использовать одиночные вызовы в реализации.

Разработка проекта сервиса

Вам необходима "серверная" версия grpc spring-boot-starter в файле pom.xml:

<dependency>
   <groupId>net.devh</groupId>
   <artifactId>grpc-server-spring-boot-starter</artifactId>
   <version>2.12.0.RELEASE</version>
</dependency>

<dependency>
   <groupId>com.nils.gprc</groupId>
   <artifactId>proto-common</artifactId>
   <version>0.0.1-SNAPSHOT</version>
</dependency>

Это моя реализация City Score Service:

package com.nils.microservices.cityscore.service;

import com.nils.gprc.cityscore.CityScoreRequest;
import com.nils.gprc.cityscore.CityScoreResponse;
import com.nils.gprc.cityscore.CityScoreServiceGrpc;
import io.grpc.stub.StreamObserver;
import net.devh.boot.grpc.server.service.GrpcService;
import org.springframework.beans.factory.annotation.Autowired;

@GrpcService
public class CityScoreService extends CityScoreServiceGrpc.CityScoreServiceImplBase {

    @Autowired
    private ValidationService validationService;

    @Override
    public void calculateCityScore(CityScoreRequest request, StreamObserver<CityScoreResponse> responseObserver) {
        // System.out.println("Request received from client:\n" + request);

        validationService.validateCityCode(request.getCityCode());

        Integer cityScore = request.getCityCode() * 10;

        CityScoreResponse response = CityScoreResponse.newBuilder()
                .setCityScore(cityScore)
                .build();

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

Валидация запроса

Примеры и различные методы обработки ошибок в gPRC можно посмотреть здесь.

Если вы хотите, чтобы вместо выбрасывания исключения возвращался ответ об ошибке, можно использовать "oneof" и отправлять ответ success для успешных запросов и ответ error для исключений:

oneof response {
    SuccessResponse success_response = 1;
    ErrorResponse error_response = 2;
  }
}

У меня будет выбрасываться исключение, поэтому я реализовал "GrpcAdvice" и "GrpcExceptionHandler", чтобы исключение было подробным с соответствующим кодом состояния gPRC. Узнать больше можно на официальном сайте документации Spring.

Есть два способа, с помощью которых можно передавать в исключении подробную информацию. Они описаны здесь.

  • Метаданные

  • Any.pack(yourCustomExceptionResponseObject)

CityScoreException — это кастомное исключение RuntimeException, которое я создал для ошибок валидации запроса. Чтобы проверить мое пользовательское сообщение "CityScoreExceptionResponse", вернитесь к моему proto-файлу. Это конечный класс "Grpc Exception Handler":

package com.nils.microservices.cityscore.exception;

import com.google.protobuf.Any;
import com.google.protobuf.Timestamp;
import com.google.rpc.Code;
import com.google.rpc.Status;
import com.nils.gprc.cityscore.CityScoreExceptionResponse;
import io.grpc.StatusRuntimeException;
import io.grpc.protobuf.StatusProto;
import net.devh.boot.grpc.server.advice.GrpcAdvice;
import net.devh.boot.grpc.server.advice.GrpcExceptionHandler;

import java.time.Instant;

@GrpcAdvice
public class CityScoreExceptionHandler {

    @GrpcExceptionHandler(CityScoreException.class)
    public StatusRuntimeException handleValidationError(CityScoreException cause) {

        Instant time = Instant.now();
        Timestamp timestamp = Timestamp.newBuilder().setSeconds(time.getEpochSecond())
                .setNanos(time.getNano()).build();

        CityScoreExceptionResponse exceptionResponse =
                CityScoreExceptionResponse.newBuilder()
                        .setErrorCode(cause.getErrorCode())
                        .setTimestamp(timestamp)
                        .build();


        Status status = Status.newBuilder()
                        .setCode(Code.INVALID_ARGUMENT.getNumber())
                        .setMessage("Invalid city code")
                        .addDetails(Any.pack(exceptionResponse))
                        .build();

        return StatusProto.toStatusRuntimeException(status);
    }
}

Порты для сервиса

Порт по умолчанию для сервера gRPC — 9090. Другое значение можно установить с помощью свойства "grpc.server.port":

grpc.server.port=8000

Разработка клиентской части

Вам нужна "клиентская" версия grpc spring-boot-starter в файле pom.xml:

<dependency>
   <groupId>net.devh</groupId>
   <artifactId>grpc-client-spring-boot-starter</artifactId>
   <version>2.12.0.RELEASE</version>
</dependency>

<dependency>
   <groupId>com.nils.gprc</groupId>
   <artifactId>proto-common</artifactId>
   <version>0.0.1-SNAPSHOT</version>
</dependency>

Это будет сервис-агрегатор, который будет собирать ответы от вызовов проекта сервиса, объединять их и возвращать конечному пользователю через RestController (поэтому он также будет использовать "spring-boot-starter-web").

Вот реализация:

package com.nils.microservices.scorecalculator.service;

import com.google.protobuf.Any;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.UInt64Value;
import com.google.rpc.Status;
import com.nils.gprc.cityscore.CityScoreExceptionResponse;
import com.nils.gprc.cityscore.CityScoreRequest;
import com.nils.gprc.cityscore.CityScoreResponse;
import com.nils.gprc.cityscore.CityScoreServiceGrpc;
import com.nils.gprc.scoresegment.ScoreSegmentExceptionResponse;
import com.nils.gprc.scoresegment.ScoreSegmentRequest;
import com.nils.gprc.scoresegment.ScoreSegmentResponse;
import com.nils.gprc.scoresegment.ScoreSegmentServiceGrpc;
import com.nils.microservices.scorecalculator.domain.IncomeBracketMultiplierInfo;
import com.nils.microservices.scorecalculator.exception.ScoreCalculatorException;
import com.nils.microservices.scorecalculator.model.ScoreCalculatorErrorCode;
import com.nils.microservices.scorecalculator.model.ScoreCalculatorRequest;
import io.grpc.StatusRuntimeException;
import io.grpc.protobuf.StatusProto;
import net.devh.boot.grpc.client.inject.GrpcClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.math.BigInteger;
import java.util.Optional;

@Service
public class ScoreCalculatorService {

    @GrpcClient("city-score")
    private CityScoreServiceGrpc.CityScoreServiceBlockingStub cityScoreStub;

    @GrpcClient("score-segment")
    private ScoreSegmentServiceGrpc.ScoreSegmentServiceBlockingStub scoreSegmentStub;

    @Autowired
    private IncomeBracketMultiplierInfoService incomeBracketMultiplierInfoService;


    public BigInteger calculateScore(ScoreCalculatorRequest scoreCalculatorRequest) {
        IncomeBracketMultiplierInfo selectedIncomeBracketMultiplerInfo = getIncomeBracketMultiplerInfo(scoreCalculatorRequest.getIncomeBracketMultiplierId());

        BigInteger scoreSegment = getScoreSegment(scoreCalculatorRequest.getIdNumber());
        Integer cityScore = getCityScore(scoreCalculatorRequest.getCityCode());
        BigInteger score = BigInteger.valueOf(selectedIncomeBracketMultiplerInfo.getMultiplier().intValue())
                                    .multiply(scoreSegment)
                                    .add(BigInteger.valueOf(cityScore.intValue()));

        return score;
    }

    private BigInteger getScoreSegment(BigInteger idNumber) {
        ScoreSegmentRequest scoreSegmentRequest = ScoreSegmentRequest.newBuilder()
                                                                    .setIdNumber(UInt64Value.newBuilder().setValue(idNumber.longValue()).build())
                                                                    .build();
        try {
            ScoreSegmentResponse scoreSegmentResponse = scoreSegmentStub.calculateScoreSegment(scoreSegmentRequest);
            return new BigInteger(scoreSegmentResponse.getScoreSegment().toString());
        } catch (Exception e){
            Status status = StatusProto.fromThrowable(e);
            for (Any any : status.getDetailsList()) {
                if (!any.is(ScoreSegmentExceptionResponse.class)) {
                    continue;
                }
                try {
                    ScoreSegmentExceptionResponse exceptionResponse = any.unpack(ScoreSegmentExceptionResponse.class);
                    System.out.println("timestamp: " + exceptionResponse.getTimestamp() +
                            ", errorCode : " + exceptionResponse.getErrorCode());
                } catch (InvalidProtocolBufferException ex) {
                    ex.printStackTrace();
                }
            }
            // System.out.println(status.getCode() + " : " + status.getDescription());
        }

        // return a default value
        return BigInteger.ONE;
    }

    private Integer getCityScore(Integer cityCode) {
        CityScoreRequest cityScoreRequest = CityScoreRequest.newBuilder()
                                                            .setCityCode(cityCode)
                                                            .build();
        try {
            CityScoreResponse cityScoreResponse = cityScoreStub.calculateCityScore(cityScoreRequest);
            return cityScoreResponse.getCityScore();
        } catch (StatusRuntimeException e){
            Status status = StatusProto.fromThrowable(e);
            for (Any any : status.getDetailsList()) {
                if (!any.is(CityScoreExceptionResponse.class)) {
                    continue;
                }
                try {
                    CityScoreExceptionResponse exceptionResponse = any.unpack(CityScoreExceptionResponse.class);
                    System.out.println("timestamp: " + exceptionResponse.getTimestamp() +
                            ", errorCode : " + exceptionResponse.getErrorCode());
                } catch (InvalidProtocolBufferException ex) {
                    ex.printStackTrace();
                }
            }
            // System.out.println(status.getCode() + " : " + status.getDescription());
        }

        // return a default value
        return Integer.valueOf(1);
    }

    private IncomeBracketMultiplierInfo getIncomeBracketMultiplerInfo(Long incomeBracketMultiplerInfoId) {
        Optional<IncomeBracketMultiplierInfo> multiplierInfo = incomeBracketMultiplierInfoService.findById(incomeBracketMultiplerInfoId);
        if (!multiplierInfo.isPresent()) {
            throw new ScoreCalculatorException(ScoreCalculatorErrorCode.INVALID_INCOME_BRACKET_MULTIPLIER_ID, incomeBracketMultiplerInfoId);
        }
        return multiplierInfo.get();
    }
}

Наконец, не забудьте добавить урлы сервиса grpc в файл application.properties. Имена свойств должны быть такими же, как аннотированные @GrpcClient

grpc.client.city-score.address=static://localhost:8000
grpc.client.city-score.negotiation-type=plaintext

grpc.client.score-segment.address=static://localhost:8100
grpc.client.score-segment.negotiation-type=plaintext

Время тестировать!

Для вызова сервисов можно использовать BloomRPC (как вы используете Postman для вызовов REST API).

Для установки на Mac используйте HomeBrew:

brew install --cask bloomrpc

После установки вы найдете его в приложениях.

Другой способ — установить gRPCurl для операций cURL с gPRC. Снова можно установить с помощью HomeBrew:

brew install grpcurl

Я буду использовать BloomRPC для тестирования эндпоинтов. Мы добавляем наши proto-файлы с помощью кнопки ”+”:

Нажимаем на метод, здесь это "calculateCityScore":

Он автоматически создает образец запроса. Мы обновляем информацию о порте (grpc.server.port) для сервиса и нажимаем кнопку play:

Чтобы проверить кейс с исключением, я установил отрицательное значение для city_code и нажал кнопку play:

Наконец, мы можем вызвать наш сервис-агрегатор — Score Calculator, используя Postman:

Если я отправлю "-35" для cityCode и проверю обработанную часть исключения, то в консоль будут выведены значения исключений, полученных в ответ:

Последняя версия моего проекта лежит здесь.


Скоро состоится открытое занятие «Свойства Spring-приложения». На встрече разберем, каким образом можно определять настройки приложения на чистом Spring, а также немного затронем тему конвертации типов.

  • Записаться на открытое занятие

Источник: https://habr.com/ru/companies/otus/articles/730740/


Интересные статьи

Интересные статьи

Привет, Хабр! Мы команда Marketing Management GlowByte, занимаемся автоматизацией маркетинговых процессов в крупных компаниях. Решили написать небольшую статью, которая будет интересна неравнодушным к...
На просторах интернета можно найти множество курсов по Java разработке для начинающих разного уровня сложности. Большая часть из них вполне хороши для того, чтобы помочь вам стать неплохим Junior разр...
Привет, эту статью мы пишем вместе — Дмитрий Генинг, руководитель направления разработки R&D, и Александр Желубенков, руководитель направления ранжирования и навигации в компании Lamoda. Одна...
На протяжении многих лет команда PVS-Studio развивает одноименный статический анализатор. На данный момент анализатор представляет собой сложное программное решение и предоставляет возможность ан...
В предыдущей статье на тему дизайна таблиц в Фигме мы выяснили, что базовый элемент создания любого data grid — это компонент ячейки, внутри которого спрятано все необходимое для того, чтобы ...