[Перевод] Реализация 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 клиент-сервисная коммуникация
Настройка проекта (состоит из интерфейсного, серверного и клиентского проектов)
Сравнение производительности представлено в этом руководстве. На видео показываю в действии:
Типы данных
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-файлы с помощью кнопки »+»:
Нажимаем на метод, здесь это «calculateCityScore»:
Он автоматически создает образец запроса. Мы обновляем информацию о порте (grpc.server.port) для сервиса и нажимаем кнопку play:
Чтобы проверить кейс с исключением, я установил отрицательное значение для city_code и нажал кнопку play:
Наконец, мы можем вызвать наш сервис-агрегатор — Score Calculator, используя Postman:
Если я отправлю »-35» для cityCode и проверю обработанную часть исключения, то в консоль будут выведены значения исключений, полученных в ответ:
Последняя версия моего проекта лежит здесь.
Скоро состоится открытое занятие «Свойства Spring-приложения». На встрече разберем, каким образом можно определять настройки приложения на чистом Spring, а также немного затронем тему конвертации типов.