[Перевод] Реализация 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-соединение может быть повторно использовано для мультиплексирования. Потоковая передача с сервера — потоковая передача от клиента — возможна двунаправленная потоковая передача)

b59c23fa49a6144899ef5c7ede4bd64d.png

Посмотреть, как ведет себя загрузка каждой части для 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 я получил ошибку:

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

UTF-8
16
16

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

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


  io.grpc
  grpc-netty-shaded
  1.41.0


  io.grpc
  grpc-protobuf
  1.41.0


  io.grpc
  grpc-stub
  1.41.0

 
  org.apache.tomcat
  annotations-api
  6.0.53
  provided

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


    
        io.grpc
        grpc-netty-shaded
        1.37.0
    
    
        io.grpc
        grpc-protobuf
        1.37.0
    
    
        io.grpc
        grpc-stub
        1.37.0
    
     
        org.apache.tomcat
        annotations-api
        6.0.53
        provided
    

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

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

  • City Score Service

  • Score Segment Service

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

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

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

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


   net.devh
   grpc-server-spring-boot-starter
   2.12.0.RELEASE



   com.nils.gprc
   proto-common
   0.0.1-SNAPSHOT

Это моя реализация 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 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.

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

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:


   net.devh
   grpc-client-spring-boot-starter
   2.12.0.RELEASE



   com.nils.gprc
   proto-common
   0.0.1-SNAPSHOT

Это будет сервис-агрегатор, который будет собирать ответы от вызовов проекта сервиса, объединять их и возвращать конечному пользователю через 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 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-файлы с помощью кнопки »+»:

2577ceca4679925f059077901490759b.png

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

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

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

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

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

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

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

© Habrahabr.ru