Реалізація gRPC в Java: Поглиблений аналіз
Розгляньмо детальніше, як впровадити gRPC у Java-середовищі.
gRPC (віддалений виклик процедур Google): Ця технологія являє собою відкриту RPC-архітектуру, розроблену Google для забезпечення швидкої взаємодії між мікросервісами. gRPC дає можливість інтегрувати сервіси, створені на різних мовах програмування. Для обміну повідомленнями gRPC використовує Protobuf (протокольні буфери) – ефективний та компактний формат, призначений для серіалізації структурованих даних.
У певних випадках, застосування gRPC API може виявитися більш продуктивним, ніж використання REST API.
Спробуємо розробити сервер за допомогою gRPC. На початковому етапі потрібно створити декілька .proto файлів, в яких будуть описані сервіси та моделі даних (DTO). Для демонстраційного сервера використаємо ProfileService і ProfileDescriptor.
Структура ProfileService:
syntax = "proto3"; package com.deft.grpc; import "google/protobuf/empty.proto"; import "profile_descriptor.proto"; service ProfileService { rpc GetCurrentProfile (google.protobuf.Empty) returns (ProfileDescriptor) {} rpc clientStream (stream ProfileDescriptor) returns (google.protobuf.Empty) {} rpc serverStream (google.protobuf.Empty) returns (stream ProfileDescriptor) {} rpc biDirectionalStream (stream ProfileDescriptor) returns (stream ProfileDescriptor) {} }
gRPC підтримує різні типи взаємодії між клієнтом і сервером. Розглянемо кожен з них:
- Звичайний виклик сервера (запит-відповідь).
- Потокова передача від клієнта до сервера.
- Потокова передача від сервера до клієнта.
- Двонаправлений потік.
ProfileService використовує ProfileDescriptor, який визначається в розділі імпорту:
syntax = "proto3"; package com.deft.grpc; message ProfileDescriptor { int64 profile_id = 1; string name = 2; }
- int64 – відповідає типу Long в Java, призначений для ідентифікатора профілю.
- string – текстова змінна, аналогічна типу String в Java.
Для створення проєкту можна використовувати Gradle або Maven. У прикладі буде застосовано Maven, оскільки це є зручнішим. Зверніть увагу, що для Gradle генерація .proto файлів буде відрізнятися, і конфігурація збірки також потребуватиме іншого підходу. Для реалізації простого gRPC сервера необхідна лише одна залежність:
<dependency> <groupId>io.github.lognet</groupId> <artifactId>grpc-spring-boot-starter</artifactId> <version>4.5.4</version> </dependency>
Цей стартер значно спрощує процес розробки.
Структура нашого проєкту буде наступною:
Потрібен клас GrpcServerApplication для запуску Spring Boot застосунку, а також GrpcProfileService, що реалізує методи з .proto файлу. Для застосування protoc та генерації класів з .proto файлів, додайте protobuf-maven-plugin в pom.xml. Розділ збірки буде мати вигляд:
<build> <extensions> <extension> <groupId>kr.motd.maven</groupId> <artifactId>os-maven-plugin</artifactId> <version>1.6.2</version> </extension> </extensions> <plugins> <plugin> <groupId>org.xolstice.maven.plugins</groupId> <artifactId>protobuf-maven-plugin</artifactId> <version>0.6.1</version> <configuration> <protoSourceRoot>${project.basedir}/src/main/proto</protoSourceRoot> <outputDirectory>${basedir}/target/generated-sources/grpc-java</outputDirectory> <protocArtifact>com.google.protobuf:protoc:3.12.0:exe:${os.detected.classifier}</protocArtifact> <pluginId>grpc-java</pluginId> <pluginArtifact>io.grpc:protoc-gen-grpc-java:1.38.0:exe:${os.detected.classifier}</pluginArtifact> <clearOutputDirectory>false</clearOutputDirectory> </configuration> <executions> <execution> <goals> <goal>compile</goal> <goal>compile-custom</goal> </goals> </execution> </executions> </plugin> </plugins> </build>
- protoSourceRoot – визначає директорію, в якій зберігаються .proto файли.
- outputDirectory – вказує на місце, де будуть згенеровані файли.
- clearOutputDirectory – відмітка, що визначає, чи потрібно очищувати згенеровані файли.
Після цього можна зібрати проєкт. Потім потрібно перейти в директорію, зазначену як вихідну. Там знаходитимуться згенеровані файли. Тепер можна розпочати реалізацію GrpcProfileService.
Оголошення класу виглядає наступним чином:
@GRpcService public class GrpcProfileService extends ProfileServiceGrpc.ProfileServiceImplBase
Анотація GRpcService – ідентифікує клас як gRPC компонент.
Успадковуючи сервіс від ProfileServiceGrpc.ProfileServiceImplBase, ми можемо переозначити методи батьківського класу. Першим методом, який ми переозначимо, є getCurrentProfile:
@Override public void getCurrentProfile(Empty request, StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> responseObserver) { System.out.println("getCurrentProfile"); responseObserver.onNext(ProfileDescriptorOuterClass.ProfileDescriptor .newBuilder() .setProfileId(1) .setName("test") .build()); responseObserver.onCompleted(); }
Для відправлення відповіді клієнту необхідно скористатися методом onNext переданого StreamObserver. Після надсилання відповіді, клієнт отримує повідомлення про завершення операції через метод onCompleted. При відправці запиту на сервер getCurrentProfile, відповідь буде такою:
{ "profile_id": "1", "name": "test" }
Далі розглянемо серверний потік. При цьому типі обміну даними, клієнт відправляє запит на сервер, а сервер повертає потік повідомлень. Наприклад, сервер надсилає п’ять повідомлень у циклі. По завершенні відправлення, клієнт отримує повідомлення про успішне завершення потоку.
Переозначений метод серверного потоку виглядатиме так:
@Override public void serverStream(Empty request, StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> responseObserver) { for (int i = 0; i < 5; i++) { responseObserver.onNext(ProfileDescriptorOuterClass.ProfileDescriptor .newBuilder() .setProfileId(i) .build()); } responseObserver.onCompleted(); }
В результаті клієнт отримає п’ять повідомлень з ProfileId, що дорівнює номеру ітерації.
{ "profile_id": "0", "name": "" } { "profile_id": "1", "name": "" } … { "profile_id": "4", "name": "" }
Клієнтський потік є аналогічним серверному. Відмінність полягає в тому, що тепер клієнт надсилає потік повідомлень, а сервер їх обробляє. Сервер може обробляти повідомлення одразу, або дочекатися всіх запитів від клієнта, а потім їх опрацювати.
@Override public StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> clientStream(StreamObserver<Empty> responseObserver) { return new StreamObserver<>() { @Override public void onNext(ProfileDescriptorOuterClass.ProfileDescriptor profileDescriptor) { log.info("ProfileDescriptor from client. Profile id: {}", profileDescriptor.getProfileId()); } @Override public void onError(Throwable throwable) { } @Override public void onCompleted() { responseObserver.onCompleted(); } }; }
Для клієнтського потоку потрібно повернути StreamObserver, через який сервер отримуватиме повідомлення. Метод onError спрацьовує, якщо в потоці відбулась помилка.
Реалізація двонаправленого потоку передбачає об’єднання потоків від сервера та клієнта.
@Override public StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> biDirectionalStream( StreamObserver<ProfileDescriptorOuterClass.ProfileDescriptor> responseObserver) { return new StreamObserver<>() { int pointCount = 0; @Override public void onNext(ProfileDescriptorOuterClass.ProfileDescriptor profileDescriptor) { log.info("biDirectionalStream, pointCount {}", pointCount); responseObserver.onNext(ProfileDescriptorOuterClass.ProfileDescriptor .newBuilder() .setProfileId(pointCount++) .build()); } @Override public void onError(Throwable throwable) { } @Override public void onCompleted() { responseObserver.onCompleted(); } }; }
В цьому випадку, у відповідь на повідомлення від клієнта, сервер повертає профіль зі збільшеним pointCount.
Підсумок
Ми розглянули основні методи обміну повідомленнями між клієнтом та сервером за допомогою gRPC: серверний потік, клієнтський потік, двонаправлений потік.
Автор статті: Сергій Голіцин