Здравствуйте, рад снова видеть вас на gRPC курсе. На первой половине лекции мы узнали как реализовать унарный gRPC вызов в Go. На второй половине мы изучим как это сделать на Java. План такой же. Мы рассмотрим следующие темы: как определить сервис с помощью protocol buffer, реализовать сервер, клиент, написать unit тесты, обработать ошибки, коды состояний и задать максимальное время на выполнение gRPC.
Приступим. Я открою проект в IntelliJ IDEA, затем в окне терминала
скопирую файл laptop_service.proto
, который мы создали на первой половине
лекции, в наш Java проект в папку src/main/proto
. Я сделаю небольшой обзор
его структуры для тех, кто не читал лекцию, посвященную Go. Сначала мы
определяем сообщение CreateLaptopRequest
с единственным полем Laptop
.
message CreateLaptopRequest {
Laptop laptop = 1;
}
Затем мы создали сообщение CreateLaptopResponse
со строковым полем:
идентификатором ноутбука.
message CreateLaptopResponse {
string id = 1;
}
Затем мы определяем LaptopService
с помощью ключевого слова service
. Внутри
этого сервиса мы задаём наш унарный gRPC вызов, используя ключевое слово rpc
.
Назовём RPC CreateLaptop
. Он принимает на вход CreateLaptopRequest
и
возвращает CreateLaptopResponse
. Ничего сложного. Теперь давайте соберём
проект, чтобы сгенерировать Java из него. При сборке получаем ошибку:
javax.annotation.Generated
. Чтобы исправить её, откройте браузер и найдите
"maven javax annotation". Откройте эту страницу https://mvnrepository.com/artifact/javax.annotation/javax.annotation-api
с Javax Annotation API, выберите последнюю версию (1.3.2 на момент написания),
скопируйте настройку для Gradle и вставьте её в блок зависимостей нашего файла
build.gradle
. Подождите, пока Gradle настроит её.
dependencies {
// ...
// https://mvnrepository.com/artifact/javax.annotation/javax.annotation-api
implementation group: 'javax.annotation', name: 'javax.annotation-api', version: '1.3.2'
}
Затем снова нажмите на кнопку Build
. На этот раз сборка прошла успешно.
Давайте откроем папку build/generated/source/proto/main/grpc
. В ней был
создан класс LaptopServiceGrpc
. Посмотрим что находится у него внутри.
Прежде всего мы увидим LaptopServiceStub
.
/**
* Creates a new async stub that supports all call types for the service
*/
public static LaptopServiceStub newStub(io.grpc.Channel channel) {
// ...
}
Это асинхронная заглушка, поддерживающая все типы вызовов для сервиса. За ней расположена другая блокирующая заглушка, которая поддерживает унарные и потоковые исходящие вызовы.
/**
* Creates a new blocking-style stub that supports unary and streaming output calls on the service
*/
public static LaptopServiceBlockingStub newBlockingStub(
io.grpc.Channel channel){
// ...
}
На этой лекции мы будем использовать эту заглушку. Здесь также находится заглушка в стиле ListenableFuture. Вы можете использовать её, если хотите.
/**
* Creates a new ListenableFuture-style stub that supports unary calls on the service
*/
public static LaptopServiceFutureStub newFutureStub(
io.grpc.Channel channel) {
// ...
}
Далее следует абстрактный класс LaptopServiceImplBase
. Нам нужно будет
реализовать метод createLaptop
этого класса на стороне сервера.
/**
*/
public static abstract class LaptopServiceImplBase implements io.grpc.BindableService {
// ...
}
Классы в оставшейся часть файла, нас пока не интересуют, поэтому закройте файл и давайте начнём писать код.
Я создам новый пакет "service": com.gitlab.techschool.pcbook.service
. На
стороне сервера мы создадим новый класс LaptopService
. Мы будем писать
различные сообщения в логи, поэтому нам нужно объявить в нём логгер. Класс
LaptopService
должен наследовать класс LaptopServiceImplBase
и мы должны
переопределить функцию createLaptop
этого класса.
public class LaptopService extends LaptopServiceGrpc.LaptopServiceImplBase {
private static final Logger logger = Logger.getLogger(LaptopService.class.getName());
@Override
public void createLaptop(CreateLaptopRequest request, StreamObserver<CreateLaptopResponse> responseObserver) {
}
}
Сначала мы получаем объект-ноутбук из запроса. Мы используем идентификатор
ноутбука и пишем его в лог, где сообщаем, что был принят запрос на создание
ноутбука с этим идентификатором. Теперь проверяем является ли идентификатор
ноутбука пустой строкой. Если да, то генерируем случайный UUID для него. В
противном случае мы проверяем идентификатор на корректность. Если метод
UUID.fromString()
выдаёт исключение IllegalArgumentException
, то это
недопустимый UUID, поэтому необходимо использовать здесь блок try-catch
.
Если возникла ошибка, то мы возвращаем её клиенту. Достаточно использовать
здесь метод responseObserver.onError
. Вернем статус InvalidArgument
вместе
с сообщением, описывающим ошибку, и преобразуем её в исключение времени
выполнения. Затем сразу выйдем из метода. В противном случае если
всё пошло по плану, на этом этапе у нас будет корректный идентификатор
ноутбука. Мы можем задать его объекту-ноутбуку. Для этого необходимо
преобразовать ноутбук в построитель и использовать метод-сеттер для
присвоения значения идентификатору. Отлично, обычно после этого мы должны
сохранить ноутбук в базе данных.
public class LaptopService extends LaptopServiceGrpc.LaptopServiceImplBase {
private static final Logger logger = Logger.getLogger(LaptopService.class.getName());
@Override
public void createLaptop(CreateLaptopRequest request, StreamObserver<CreateLaptopResponse> responseObserver) {
Laptop laptop = request.getLaptop();
String id = laptop.getId();
logger.info("got a create-laptop request with ID: " + id);
UUID uuid;
if (id.isEmpty()) {
uuid = UUID.randomUUID();
} else {
try {
uuid = UUID.fromString(id);
} catch (IllegalArgumentException e) {
responseObserver.onError(
Status.INVALID_ARGUMENT
.withDescription(e.getMessage())
.asRuntimeException()
);
return;
}
}
Laptop other = laptop.toBuilder().setId(uuid.toString()).build();
// Save other laptop to the store
}
}
Тем не менее, я не хочу отвлекать вас, поскольку этот курс о gRPC, а не о
базах данных. Поэтому я буду использовать хранилище в оперативной памяти.
Давайте создадим новый файл: LaptopStore
. Он должен быть Interface
, чтобы
мы могли реализовывать различные виды хранилищ, когда захотим. В этом
интерфейсе будет функция Save
для сохранения ноутбука в хранилище. Обратите
внимание, что для упрощения в этой лекции я буду использовать объект probobuf
Laptop
непосредственно как модель данных. Но вам следует рассмотреть
возможность использования отдельной модели для разделения уровня передачи и
хранения данных. Нам также понадобится функция Find
для поиска ноутбука по
его идентификатору.
package com.gitlab.techschool.pcbook.service;
import com.github.techschool.pcbook.pb.Laptop;
public interface LaptopStore {
void Save(Laptop laptop) throws Exception; // consider using a separate db model
Laptop Find(String id);
}
Теперь давайте создадим новый файл, реализующий InMemoryLaptopStore
. Мы
будем использовать ConcurrentMap
для хранения ноутбуков, где ключом будет
строка идентификатора ноутбука, а значением — сам ноутбук. В конструкторе этого
класса создадим новую ConcurrentHashmap
с начальной ёмкостью равной нулю. В
функции Save
мы проверяем существует ли идентификатор ноутбука в хранилище
или нет.
package com.gitlab.techschool.pcbook.service;
import com.github.techschool.pcbook.pb.Laptop;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public class InMemoryLaptopStore implements LaptopStore {
private ConcurrentMap<String, Laptop> data;
public InMemoryLaptopStore() {
data = new ConcurrentHashMap<>(0);
}
@Override
public void Save(Laptop laptop) throws Exception {
if (data.containsKey(laptop.getId())) {
throw new AlreadyExistsException("laptop ID already exists");
}
}
@Override
public Laptop Find(String id) {
return null;
}
}
Если да, генерируем новое исключение AlreadyExistsException
. Давайте
определим это исключение в отдельном файле в папке service
. Оно наследует
класс RuntimeException
и вызывает родительский конструктор с сообщением,
переданным в него.
package com.gitlab.techschool.pcbook.service;
public class AlreadyExistsException extends RuntimeException {
public AlreadyExistsException(String message) {
super(message);
}
}
Теперь в функции Save
, мы создадим глубокую копию ноутбука и сохраним его в
хеш-таблице.
public class InMemoryLaptopStore implements LaptopStore {
// ...
@Override
public void Save(Laptop laptop) throws Exception {
if (data.containsKey(laptop.getId())) {
throw new AlreadyExistsException("laptop ID already exists");
}
// deep copy
Laptop other = laptop.toBuilder().build();
data.put(other.getId(), other);
}
// ...
}
В функции Find
, если карта не содержит идентификатор ноутбука мы просто
возвращаем null
. В противном случае мы осуществляем глубокое копирование
объекта из хеш-таблицы и возвращаем его.
public class InMemoryLaptopStore implements LaptopStore {
// ...
@Override
public Laptop Find(String id) {
if (!data.containsKey(id)) {
return null;
}
// deep copy
Laptop other = data.get(id).toBuilder().build();
return other;
}
}
Давайте вернемся к классу LaptopService
. Мы объявили объект LaptopStore
и
инициализировали его в конструкторе.
public class LaptopService extends LaptopServiceGrpc.LaptopServiceImplBase {
private static final Logger logger = Logger.getLogger(LaptopService.class.getName());
private LaptopStore store;
public LaptopService(LaptopStore store) {
this.store = store;
}
// ...
}
Теперь в функции createLaptop
мы можем вызвать store.Save()
, чтобы
сохранить ноутбук в хранилище. Если мы перехватим исключение
AlreadyExistsException
, то вернём статус AlreadyExists
клиенту. Иначе, если
перехватим неожидаемое исключение, то возвращаем статус Internal
, означающий,
что произошла внутренняя ошибка сервера. Если исключений нет, то мы создадим
новый объект-ответ с идентификатором ноутбука и вернём его клиенту, используя
функцию responseObserver.onNext()
. Наконец, мы вызываем функцию
onCompelted()
, чтобы сообщить клиенту, что RPC завершена и ничего больше
отправляться не будет. Мы пишем в лог сообщение, в котором говорится, что
ноутбук с таким-то идентификатором был успешно сохранен в хранилище. На этом
реализация класса LaptopService
завершена.
public class LaptopService extends LaptopServiceGrpc.LaptopServiceImplBase {
// ...
@Override
public void createLaptop(CreateLaptopRequest request, StreamObserver<CreateLaptopResponse> responseObserver) {
// ...
// Save other laptop to the store
try {
store.Save(other);
} catch (AlreadyExistsException e) {
responseObserver.onError(
Status.ALREADY_EXISTS
.withDescription(e.getMessage())
.asRuntimeException()
);
return;
} catch (Exception e) {
responseObserver.onError(
Status.INTERNAL
.withDescription(e.getMessage())
.asRuntimeException()
);
return;
}
CreateLaptopResponse response = CreateLaptopResponse.newBuilder().setId(other.getId()).build();
responseObserver.onNext(response);
responseObserver.onCompleted();
logger.info("saved laptop with ID: " + other.getId());
}
}
Теперь давайте создадим класс LaptopServer
для прослушивания gRPC запросов и
вызова LaptopService
для их обслуживания. Как и раньше, мы сначала создадим
новый логгер для записи определенных логов. Затем мы напишем два конструктора.
Первый на вход принимает порт и хранилище ноутбуков, а второй принимает ещё
один дополнительный входной параметр, которым является grpc ServerBuilder
.
Этот конструктор нам пригодится позднее при написании unit теста.
public class LaptopServer {
private static final Logger logger = Logger.getLogger(LaptopServer.class.getName());
public LaptopServer(int port, LaptopStore store) {
}
public LaptopServer(ServerBuilder serverBuilder, int port, LaptopStore store) {
}
}
В первом конструкторе мы создаём новый ServerBuilder, используя порт,
подаваемый в качестве входного параметра и затем вызываем второй конструктор,
передавая его. Мы определим два приватных поля в классе LaptopServer
, одно
для порта, а второе — для gRPC сервера. Во втором конструкторе мы сохраним
значение входного порта в приватное свойство класса и создадим сервис для
ноутбуков, используя хранилище, переданное во входном параметре. Добавим сервис
для ноутбуков в serverBuilder
и вызовем функцию build()
для создания gRPC
сервера.
public class LaptopServer {
private static final Logger logger = Logger.getLogger(LaptopServer.class.getName());
private final int port;
private final Server server;
public LaptopServer(int port, LaptopStore store) {
this(ServerBuilder.forPort(port), port, store);
}
public LaptopServer(ServerBuilder serverBuilder, int port, LaptopStore store) {
this.port = port;
LaptopService laptopService = new LaptopService(store);
server = serverBuilder.addService(laptopService).build();
}
}
Затем мы реализуем функцию start()
для запуска сервера. Мы пишем сообщение в
лог, сообщающее о том, что сервер запущен на заданном порту port
. Затем
нам понадобится метод stop()
для остановки сервера. Здесь мы будем
использовать функцию awaitTermination()
, чтобы дождаться полного завершения
работы сервера. Поскольку иногда работа JVM может быть завершена извне из-за
некоторых прерываний или неожиданных ошибок, мы должны добавить хук при
выключении к функции start()
, чтобы gRPC сервер мог корректно завершиться. В
этом хуке мы просто записываем системную ошибку и вызываем метод stop()
объекта LaptopServer
.
public class LaptopServer {
// ...
public void start() throws IOException {
server.start();
logger.info("server started on port " + port);
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
System.out.println("shut down gRPC server because JVM shuts down");
try {
LaptopServer.this.stop();
} catch (InterruptedException e) {
e.printStackTrace(System.err);
}
System.err.println("server shut down");
}
});
}
public void stop() throws InterruptedException {
if (server != null) {
server.shutdown().awaitTermination(30, TimeUnit.SECONDS);
}
}
}
Затем мы создаём ещё одну функцию для блокировки основного потока до остановки сервера, поскольку gRPC сервер использует потоки, работающие в фоновом режиме.
public class LaptopServer {
// ...
private void blockUntilShutdown() throws InterruptedException {
if (server != null) {
server.awaitTermination();
}
}
}
Итак, теперь мы можем реализовать функцию main
. Сначала мы создадим новый
InMemoryLaptopStore
, а затем LaptopServer
на порту 8080, используя это
хранилище. Мы вызываем server.start()
для запуска сервера и наконец
server.blockUntilShutdown()
. Сервер готов.
public class LaptopServer {
// ...
public static void main(String[] args) throws InterruptedException, IOException {
InMemoryLaptopStore store = new InMemoryLaptopStore();
LaptopServer server = new LaptopServer(8080, store);
server.start();
server.blockUntilShutdown();
}
}
Давайте нажмём на кнопку Run
рядом с методом public static void main
, чтобы
запустить его. Как видно сервер запускается на порту 8080. Теперь я
воспользуюсь клиентом на Golang, который был реализован нами на предыдущей
лекции, чтобы подключиться к этому серверу. Я выполню команду make client
в
терминале для запуска клиента. Отлично! Был создан новый ноутбук с
идентификатором fa63a094-834d-46c4-be1d-e4f334f4be84
.
2021/04/01 19:28:18 created laptop with id: fa63a094-834d-46c4-be1d-e4f334f4be84
На стороне сервера мы также можем увидеть сообщение о том, что был получен запрос на создание ноутбука с пустым идентификатором и ещё одно сообщение о сохранении ноутбука в хранилище.
INFO: got a create-laptop request with ID:
INFO: saved laptop with ID: fa63a094-834d-46c4-be1d-e4f334f4be84
Тем не менее, у вас может возникнуть сообщение о "сбое при передаче". Это не ошибка, поскольку сообщение относится к типу INFO. Когда я поискал информацию об этом сообщении в Интернете, оказалось, что это новый баг библиотеки gRPC-java, возникающий, когда клиент закрывает соединение слишком быстро и RPC вызов оказывается не до конца завершенным, хотя ответ от сервера был успешно доставлен. Сервер по-прежнему относит это сообщение к типу INFO, хотя оно должно принадлежать к типу FINE. Код, исправляющий эту ошибку был недавно объединен с основной версией библиотеки и я надеюсь, что в следующей версии её не будет. Пока что можно исправить эту ошибку следующим образом. После получения ответа от сервера мы просто можем подождать немного, скажем, 1 секунду.
cmd/client/main.go
func main() {
log.Printf("created laptop with id: %s", res.Id)
time.Sleep(time.Second)
}
Затем, когда мы снова запустим make client
, сообщений об ошибке не будет.
Давайте рассмотрим случай, когда идентификатор уже сгенерирован на стороне
клиента.
cmd/client/main.go
func main() {
// ...
laptop := sample.NewLaptop()
//laptop.Id = ""
req := &pb.CreateLaptopRequest{
Laptop: laptop,
}
// ...
}
Сервер успешно обработал его! Как видно из логов сервера, ноутбук был успешно создан с идентификатором, который клиент послал в запросе. Теперь давайте рассмотрим случай, когда в хранилище существует ноутбук с таким же идентификатором.
cmd/client/main.go
func main() {
// ...
laptop := sample.NewLaptop()
laptop.Id = "fa63a094-834d-46c4-be1d-e4f334f4be84"
req := &pb.CreateLaptopRequest{
Laptop: laptop,
}
// ...
}
Отлично! Сервер выдал сообщение о том, что такой ноутбук уже существует, как мы и ожидали.
2021/04/01 19:44:14 laptop already exists
А если передать некорректный идентификатор?
cmd/client/main.go
func main() {
// ...
laptop := sample.NewLaptop()
laptop.Id = "invalid"
req := &pb.CreateLaptopRequest{
Laptop: laptop,
}
// ...
}
Сервер вернет ошибку с кодом InvalidArgument
.
2021/04/01 19:46:43 cannot create laptop: rpc error: code = InvalidArgument desc = Invalid UUID string: invalid
exit status 1
Отлично! Теперь давайте напишем клиент на Java. Я создам новый класс
LaptopClient
в пакете service
. Как и раньше, мы создадим новый логгер.
Затем конструктор с двумя аргументами: адресной строкой сервера host
и
портом port
. Чтобы подключиться к серверу, нам понадобится объект
ManagedChannel
. Он нужен для взаимодействия клиента и сервера. Нам также
понадобится блокирующая заглушка, чтобы вызывать унарный RPC.
import com.github.techschool.pcbook.pb.LaptopServiceGrpc;
import io.grpc.ManagedChannel;
import java.util.logging.Logger;
public class LaptopClient {
private static final Logger logger = Logger.getLogger(LaptopClient.class.getName());
private final ManagedChannel channel;
private final LaptopServiceGrpc.LaptopServiceBlockingStub blockingStub;
public LaptopClient(String host, int port) {
}
}
В конструкторе LaptopClient
мы используем ManagedChannelBuilder
для
создания канала с указанной адресной строкой сервера host
и портом port
.
Чтобы не усложнять, мы пока не используем защищенное соединение. В этом случае
данные передаются в виде обычного текста, без шифрования (usePlaintext
).
После создания канала, мы можем использовать его для создания новой блокирующей
заглушки.
public class LaptopClient {
// ...
public LaptopClient(String host, int port) {
channel = ManagedChannelBuilder.forAddress(host, port)
.usePlaintext()
.build();
blockingStub = LaptopServiceGrpc.newBlockingStub(channel);
}
}
Теперь мы напишем функцию shutdown
, чтобы корректно завершить работу канала.
Мы будем ждать завершения работы канала не более 5 секунд.
public class LaptopClient {
// ...
public void shutdown() throws InterruptedException {
channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);
}
}
Затем мы напишем функцию createLaptop
для вызова RPC на сервере, чтобы
создать новый ноутбук. Сначала мы создаём новый запрос create-laptop
с объектом-ноутбуком в качестве входного параметра. Мы создаём экземпляр ответа
create-laptop, используемый по умолчанию, затем вызываем
blockStub.createlaptop()
и передаём запрос. Если мы перехватили исключение,
мы записываем сообщение с типом SEVERE и завершаем работу метода. В случае
успешного вызова, мы получим response
в качестве ответа. После этого мы
запишем информационное сообщение в лог, сообщающее о создании ноутбука с
требуемым идентификатором.
public class LaptopClient {
// ...
public void createLaptop(Laptop laptop) {
CreateLaptopRequest request = CreateLaptopRequest.newBuilder().setLaptop(laptop).build();
CreateLaptopResponse response = CreateLaptopResponse.getDefaultInstance();
try {
response = blockingStub.createLaptop(request);
} catch (Exception e) {
logger.log(Level.SEVERE, "request failed: " + e.getMessage());
return;
}
logger.info("laptop created with ID: " + response.getId());
}
}
Хорошо, теперь введите psvm
, чтобы создать функцию main
. Сначала создайте
новый клиент, который подключается по адресу localhost
к порту 8080
. Затем
инициализируйте новый генератор и сгенерируйте случайный ноутбук.
public class LaptopClient {
// ...
public static void main(String[] args) {
LaptopClient client = new LaptopClient("0.0.0.0", 8080);
Generator generator = new Generator();
Laptop laptop = generator.NewLaptop();
}
}
Кстати, в лекции 9, я забыл задать случайный идентификатор для
объекта-ноутбука. Итак, давайте вызовем здесь функцию setID()
в
NewLaptop()
, чтобы присвоить ему случайную UUID строку.
public class Generator {
// ...
public Laptop NewLaptop() {
// ...
return Laptop.newBuilder()
.setId(UUID.randomUUID().toString())
.setBrand(brand)
.setName(name)
.setCpu(NewCPU())
.setRam(NewRAM())
.addGpus(NewGPU())
.addStorages(NewSSD())
.addStorages(NewHDD())
.setScreen(NewScreen())
.setKeyboard(NewKeyboard())
.setWeightKg(weightKg)
.setPriceUsd(priceUsd)
.setReleaseYear(releaseYear)
.setUpdatedAt(timestampNow())
.build();
}
// ...
}
Вернемся к нашей функции main, теперь мы можем вызвать client.createLaptop
,
используя случайный объект-ноутбук. Оберните его в блок try
, а в блоке
finally
вызовите client.shutdown()
, чтобы явно его завершить.
public class LaptopClient {
// ...
public static void main(String[] args) throws InterruptedException {
// ...
try {
client.createLaptop(laptop);
} finally {
client.shutdown();
}
}
}
Давайте запустим сервер, реализованный на Go, и подключим к нему этот Java клиент. Как видно из логов, ноутбук успешно создан.
INFO: laptop created with ID: 7fea0409-cffb-4b61-bac1-5b81111730e7
Давайте рассмотрим случай, когда клиент отправляет ноутбук с пустым идентификатором.
public class LaptopClient {
// ...
public static void main(String[] args) throws InterruptedException {
// ...
Generator generator = new Generator();
Laptop laptop = generator.NewLaptop().toBuilder().setId("").build();
// ...
}
}
В этом случае ноутбук всё равно создаётся.
INFO: laptop created with ID: 4a24b183-64da-42b4-8a5b-557912351a30
На стороне сервера мы видим два сообщения: первое сигнализирует о том, что был получен ноутбук с пустым идентификатором, а второе, что он был сохранен со случайным UUID.
2021/04/01 20:35:32 receive a create-laptop request with id:
2021/04/01 20:35:38 saved laptop with id: 4a24b183-64da-42b4-8a5b-557912351a30
Это именно то, что нам нужно. Теперь если мы попытаемся отправить ноутбук с
идентификатором, который уже был сохранен в хранилище, то сервер вернёт ошибку
со статусом ALREADY_EXISTS
.
public class LaptopClient {
// ...
public static void main(String[] args) throws InterruptedException {
// ...
Generator generator = new Generator();
Laptop laptop = generator.NewLaptop().toBuilder().setId("4a24b183-64da-42b4-8a5b-557912351a30").build();
// ...
}
}
SEVERE: request failed: ALREADY_EXISTS: cannot save laptop to the store: record already exists
Однако, ошибка отображается как сообщение с типом SEVERE, а мы хотим, чтобы
это было обычное информационное сообщение. Поэтому давайте немного обновим код.
Здесь, если возникло исключение StatusRuntimeException
, мы можем вызвать
e.getStatus().getCode()
, чтобы получить код состояния ответа. Если он равен
AlreadyExists
, то ничего страшного не произошло. Мы просто пишем
информационное сообщение в лог и выходим из метода. В противном случае мы
добавляем сообщение с типом SEVERE для любых других исключений.
public class LaptopClient {
// ...
public void createLaptop(Laptop laptop) {
// ...
try {
response = blockingStub.createLaptop(request);
} catch (StatusRuntimeException e) {
if (e.getStatus().getCode() == Status.Code.ALREADY_EXISTS) {
// not a big deal
logger.info("laptop ID already exists");
return;
}
logger.log(Level.SEVERE, "request failed: " + e.getMessage());
return;
} catch (Exception e) {
logger.log(Level.SEVERE, "request failed: " + e.getMessage());
return;
}
// ...
}
}
Теперь сообщение является информационным и выглядит как "laptop ID already exists".
INFO: laptop ID already exists
Если мы изменим значение идентификатора на "invalid" и перезапустим клиент, то
в лог запишется сообщение с типом SEVERE и кодом состояния Invalid Argument
.
SEVERE: request failed: INVALID_ARGUMENT: laptop ID is not a valid UUID: invalid UUID length: 7
Итак, Java клиент очень хорошо работает с Go сервером. Теперь давайте попробуем проделать те же действия с Java сервером. Сервер запущен на порту 8080. Теперь запустим клиент с пустым идентификатором. Ноутбук был успешно создан.
INFO: laptop created with ID: 17877dd3-0f15-4087-9905-56e3eae8aa60
Теперь рассмотрим случай с идентификатором, который уже существует в хранилище.
Получаем сообщение laptop ID already exists
.
INFO: laptop ID already exists
Затем случай с некорректным идентификатором. Запрос не выполнился, возникла
ошибка INVALID_ARGUMENT
.
SEVERE: request failed: INVALID_ARGUMENT: Invalid UUID string: invalid
И наконец случай с корректным идентификатором. Ноутбук был успешно создан.
INFO: laptop created with ID: 823095af-7bc0-4bfd-8d71-15651bf5e178
Итак, Java клиент также отлично работает с Java сервером. Теперь я покажу вам как обрабатывать запросы, превысившие максимальное время выполнения. Допустим на сервере мы осуществляем какие-то ресурсоёмкие вычисления перед сохранением ноутбука в хранилище. Здесь в качестве примера я буду использовать простую задержку в 6 секунд.
public class LaptopService extends LaptopServiceGrpc.LaptopServiceImplBase {
// ...
@Override
public void createLaptop(CreateLaptopRequest request, StreamObserver<CreateLaptopResponse> responseObserver) {
// ...
// heavy processing
try {
TimeUnit.SECONDS.sleep(6);
} catch (InterruptedException e) {
e.printStackTrace();
}
// ...
}
}
Затем на стороне клиента, установим максимальное время выполнения запроса в 5 секунд.
public class LaptopClient {
// ...
public void createLaptop(Laptop laptop) {
// ...
try {
response = blockingStub.withDeadlineAfter(5, TimeUnit.SECONDS).createLaptop(request);
} catch (StatusRuntimeException e) {
if (e.getStatus().getCode() == Status.Code.ALREADY_EXISTS) {
// not a big deal
logger.info("laptop ID already exists");
return;
}
logger.log(Level.SEVERE, "request failed: " + e.getMessage());
return;
} catch (Exception e) {
logger.log(Level.SEVERE, "request failed: " + e.getMessage());
return;
}
// ...
}
}
Теперь запустите сервер и клиент. После 5 секунд, мы получим ошибку
DEADLINE_EXCEEDED
.
SEVERE: request failed: DEADLINE_EXCEEDED: deadline exceeded after 4.984459972s. [buffered_nanos=141896411, remote_addr=0.0.0.0/[0:0:0:0:0:0:0:1]:8080]
Но, на стороне сервера, ноутбук всё равно создался, как видно из сообщения.
INFO: saved laptop with ID: 947bd987-5a1d-4fcc-8178-ecc10ec0aa9e
Давайте откроем код севера и исправим это. После ресурсоёмких вычислений,
мы проверим текущее состояние контекста. Если он был отменен, мы записываем
в лог информационное сообщение, вызываем responseObserver.onError()
со
статусом Status.CANCELLED
и сразу же завершаем работу метода.
public class LaptopService extends LaptopServiceGrpc.LaptopServiceImplBase {
// ...
@Override
public void createLaptop(CreateLaptopRequest request, StreamObserver<CreateLaptopResponse> responseObserver) {
// ...
// heavy processing
try {
TimeUnit.SECONDS.sleep(6);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (Context.current().isCancelled()) {
logger.info("request is cancelled");
responseObserver.onError(
Status.CANCELLED
.withDescription("request is cancelled")
.asRuntimeException()
);
return;
}
// ...
}
}
Теперь если мы перезапустим сервер и клиент, то в этот раз, на стороне сервера увидим сообщение "request is cancelled", а ноутбук больше не будет сохраняться в хранилище.
INFO: request is cancelled
Превосходно! Прежде чем я покажу вам как написать unit тесты для этого унарного RPC, я закомментирую этот кусок кода, имитирующий ресурсоёмкие вычисления.
Наведите мышку на класс LaptopServer, нажмите option + Enter (на macOS) или Alt + Enter (на Win или Linux) и выберите в списке "Create Test".
Рисунок 1 - Создаём тест.
Поставьте галочки напротив setUp
и tearDown
и нажмите OK. Тестовый файл
будет создан в папке com/gitlab/techschool/pcbook/service
. В функции setUp
мы сгенерируем имя сервера и создадим новый внутрипроцессный построитель
серверов с этим именем в качестве непосредственного исполнителя.
public class LaptopServerTest {
@Before
public void setUp() throws Exception {
String serverName = InProcessServerBuilder.generateName();
InProcessServerBuilder serverBuilder = InProcessServerBuilder.forName(serverName).directExecutor();
}
@After
public void tearDown() throws Exception {
}
}
Затем мы объявляем 3 приватных объекта: хранилище ноутбуков, сервер ноутбуков и управляемый канал.
public class LaptopServerTest {
private LaptopStore store;
private LaptopServer server;
private ManagedChannel channel;
// ...
}
В функции setUp
мы инициализируем хранилище, используя хранилище в памяти.
Мы создаём сервер, передавая в качестве входных параметров построитель серверов,
нулевой порт и хранилище. Вызываем server.start()
и наконец создаём канал
как внутрипроцессный канал для сгенерированного имени сервера также в качестве
непосредственного исполнителя. Мы также хотим закрыть соединение после теста.
Поэтому мы добавим здесь gRPC правило для закрытия и зарегистрируем новый канал
с ним в методе теста setUp
.
public class LaptopServerTest {
@Rule
public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule(); // automatic shutdown channel at the end of test
// ...
@Before
public void setUp() throws Exception {
// ...
store = new InMemoryLaptopStore();
server = new LaptopServer(serverBuilder, 0, store);
server.start();
channel = grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).directExecutor().build()
);
}
// ...
}
В функции tearDown
всё что нам нужно сделать — это вызвать server.stop()
.
public class LaptopServerTest {
// ...
@After
public void tearDown() throws Exception {
server.stop();
}
}
Теперь давайте напишем первый тест. Он протестирует RPC createLaptop с
корректным идентификатором ноутбука. Сначала мы создадим новый генератор
ноутбуков и сгенерируем случайный объект-ноутбук. Затем мы вызываем построитель
запроса create-laptop, используя сгенерированный ноутбук. Мы создаём новую
блокирующую заглушку с управляемым каналом, который был проинициализирован в
методе setUp
. Затем мы используем заглушку для вызова createLaptop
с
объектом-запросом. Сервер возвращает ответ. Мы проверяем, что ответ не равен
null
и что идентификатор ноутбука в ответе должен быть равен идентификатору
в запросе. Наконец, если мы находим идентификатор в хранилище, то он не должен
быть равен null
. Отлично, давайте запустим тест.
public class LaptopServerTest {
// ...
@Test
public void createLaptopWithAValidID() {
Generator generator = new Generator();
Laptop laptop = generator.NewLaptop();
CreateLaptopRequest request = CreateLaptopRequest.newBuilder().setLaptop(laptop).build();
LaptopServiceGrpc.LaptopServiceBlockingStub stub = LaptopServiceGrpc.newBlockingStub(channel);
CreateLaptopResponse response = stub.createLaptop(request);
assertNotNull(response);
assertEquals(laptop.getId(), response.getId());
Laptop found = store.Find(response.getId());
assertNotNull(found);
}
}
Он успешно пройден. Теперь я скопирую этот тест и изменю его для второго случая: создания ноутбука с пустым идентификатором. Итак, я преобразую ноутбук в построитель. Присвоим идентификатору значение пустой строки и заново создадим ноутбук. Теперь если мы запустим этот тест, то он завершиться с ошибкой, поскольку мы всё ещё ожидаем, что созданный идентификатор ноутбука будет равен идентификатору в запросе.
public class LaptopServerTest {
// ...
@Test
public void createLaptopWithAnEmptyID() {
Generator generator = new Generator();
Laptop laptop = generator.NewLaptop().toBuilder().setId("").build();
CreateLaptopRequest request = CreateLaptopRequest.newBuilder().setLaptop(laptop).build();
LaptopServiceGrpc.LaptopServiceBlockingStub stub = LaptopServiceGrpc.newBlockingStub(channel);
CreateLaptopResponse response = stub.createLaptop(request);
assertNotNull(response);
assertEquals(laptop.getId(), response.getId()); // response laptop ID not equal to the input laptop ID.
Laptop found = store.Find(response.getId());
assertNotNull(found);
}
}
Итак, давайте изменим эту команду assert, чтобы просто проверить, что идентификатор ответа не пустой. Теперь тест пройден.
public class LaptopServerTest {
// ...
@Test
public void createLaptopWithAnEmptyID() {
// ...
assertFalse(response.getId().isEmpty());
Laptop found = store.Find(response.getId());
assertNotNull(found);
}
}
Третий случай — когда клиент отправляет некорректный идентификатор. Здесь я
просто изменю этот идентификатор ноутбука на "invalid" и проверю, что
идентификатор ответа равен null
. Самое важное здесь: мы ожидаем перехватить
исключение StatusRuntimeException
в этом случае. Давайте запустим тест.
public class LaptopServerTest {
// ...
@Test(expected = StatusRuntimeException.class)
public void createLaptopWithAnInvalidID() {
Generator generator = new Generator();
Laptop laptop = generator.NewLaptop().toBuilder().setId("invalid").build();
CreateLaptopRequest request = CreateLaptopRequest.newBuilder().setLaptop(laptop).build();
LaptopServiceGrpc.LaptopServiceBlockingStub stub = LaptopServiceGrpc.newBlockingStub(channel);
CreateLaptopResponse response = stub.createLaptop(request);
assertNotNull(response);
assertNull(response.getId());
}
}
На самом деле я думаю, что ответ тоже может быть равен null
.
public class LaptopServerTest {
// ...
@Test(expected = StatusRuntimeException.class)
public void createLaptopWithAnInvalidID() {
Generator generator = new Generator();
Laptop laptop = generator.NewLaptop().toBuilder().setId("invalid").build();
CreateLaptopRequest request = CreateLaptopRequest.newBuilder().setLaptop(laptop).build();
LaptopServiceGrpc.LaptopServiceBlockingStub stub = LaptopServiceGrpc.newBlockingStub(channel);
CreateLaptopResponse response = stub.createLaptop(request);
assertNull(response);
assertNull(response.getId());
}
}
Тест всё равно успешно пройден. Таким образом, похоже, что команда assert не выполняется после того, как исключение было поймано, и мы можем удалить её.
public class LaptopServerTest {
// ...
@Test(expected = StatusRuntimeException.class)
public void createLaptopWithAnInvalidID() {
Generator generator = new Generator();
Laptop laptop = generator.NewLaptop().toBuilder().setId("invalid").build();
CreateLaptopRequest request = CreateLaptopRequest.newBuilder().setLaptop(laptop).build();
LaptopServiceGrpc.LaptopServiceBlockingStub stub = LaptopServiceGrpc.newBlockingStub(channel);
CreateLaptopResponse response = stub.createLaptop(request);
}
}
Теперь последний случай: клиент отправляет идентификатор, который уже
существует в хранилище. Здесь я сгенерирую обычный случайный ноутбук и сохраню
его в магазине перед тем как сделать запрос. В этом случае мы также ожидаем
перехватить исключение StatusRuntimeException
. Так что менять больше нечего.
Давайте запустим тест.
public class LaptopServerTest {
// ...
@Test(expected = StatusRuntimeException.class)
public void createLaptopWithAnAlreadyExistsID() throws Exception{
Generator generator = new Generator();
Laptop laptop = generator.NewLaptop();
store.Save(laptop);
CreateLaptopRequest request = CreateLaptopRequest.newBuilder().setLaptop(laptop).build();
LaptopServiceGrpc.LaptopServiceBlockingStub stub = LaptopServiceGrpc.newBlockingStub(channel);
CreateLaptopResponse response = stub.createLaptop(request);
}
}
Он успешно пройден. Теперь давайте запустим все unit тесты вместе. Если вы увидите четыре зеленых галочки, то все тесты успешно пройдены. Превосходно!
Рисунок 2 - Все тесты успешно пройдены.
На этом закончим лекцию. Теперь вы знаете как реализовать и протестировать унарный RPC как в Go, так и в Java.
На следующей лекции мы узнаем как реализовать второй тип gRPC, а именно серверную потоковую передачу. Надеюсь, что информацию, которую вы узнали из курса, пригодится вам. Желаю вам получать удовольствие от написания программ и до новых встреч!