Подключение приложений Android к серверу с помощью gRPC

gRPC  —  это современный высокопроизводительный фреймворк удаленного вызова процедур (RPC) с открытым исходным кодом, запускаемый в любой среде.

Применяется для эффективного подключения сервисов в ЦОДах и между ними со встраиваемой балансировкой нагрузки, проверкой работоспособности, поддержкой трассировки, аутентификации.

Для сервиса в gRPC автоматически генерируются идиоматические «заглушки» клиента и сервера на различных языках и платформах. Поддерживается двунаправленная потоковая передача между клиентом и сервером по протоколу HTTP/2.

RPC  —  это форма взаимодействия клиента и сервера с применением не обычного HTTP-вызова, а вызова функции.

Буферы протокола

В gRPC используются буферы протокола  —  это всеязычный, платформенно-независимый, расширяемый механизм Google для сериализации структурированных данных с прямой и обратной совместимостью.

Он аналогичен JSON, только меньше и быстрее, и им генерируются привязки к нативному языку. Позже мы создадим файл с расширением proto.

Буферы протокола  —  самый распространенный формат данных в gRPC.

message Person {
string name = 1;
int32 id = 2;
}

Сообщение message можно считать объектом.

Сервисы gRPC создаются так:

// Определение сервиса приветствия.
service Greeter {
// Отправка приветствия
rpc SayHello (HelloRequest) returns (HelloReply) {}
}

Чтобы из файла proto генерировался код, в gRPC используется protoc со специальным плагином gRPC: получается сгенерированный код для клиента gRPC и серверный код, а также обычный код буфера протокола для заполнения, сериализации и извлечения типов сообщений.

В gRPC имеется 4 вида сервисов.

  1. Одиночные RPC: клиентом отправляется запрос  —  с сервера возвращается ответ. Похоже на обычный вызов функции.
  2. RPC потоковой передачи данных с сервера: клиентом отправляется запрос  —  с сервера возвращается поток для чтения последовательности сообщений. Пока в возвращаемом потоке есть сообщения, они считываются клиентом. В gRPC гарантируется их упорядочение в отдельном RPC-вызове.
  3. RPC потоковой передачи данных от клиента: клиентом записывается последовательность сообщений, которые отправляются им на сервер, опять же по предоставляемому потоку. На сервере они считываются, ответ возвращается клиенту. В gRPC опять же гарантируется упорядочение сообщений в отдельном RPC-вызове.
  4. RPC двунаправленной потоковой передачи данных: обеими сторонами по потоку чтения-записи отправляется последовательность сообщений.

Подключаем сервер gRPC к приложению Android

Этап 1. Создаем новый проект Android:

Этап 2. Добавляем в сборке файла Gradle уровня проекта вот это:

buildscript {
...
dependencies {
classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.18"
}
}

Переходим к файлу gradle уровня приложения и добавляем в блок плагинов это:

id 'com.google.protobuf'

Выше блока зависимостей добавляем это:

protobuf {
protoc { artifact = 'com.google.protobuf:protoc:3.19.2' }
plugins {
grpc { artifact = 'io.grpc:protoc-gen-grpc-java:1.47.0'
}
}
generateProtoTasks {
all().each { task ->
task.builtins {
java { option 'lite' }
}
task.plugins {
grpc {
option 'lite' }
}
}
}
}

А в блоке зависимостей  —  это:

// Зависимость grpc
implementation 'io.grpc:grpc-okhttp:1.47.0'
implementation 'io.grpc:grpc-protobuf-lite:1.47.0'
implementation 'io.grpc:grpc-stub:1.47.0'
implementation 'org.apache.tomcat:annotations-api:6.0.53'

Нажимаем Sync Now («Синхронизировать»).

Этап 3. Создаем файл Proto.

Переходим в root -> app -> src -> main -> proto (создаем эту папку).

Добавляем файл greeter.proto:

syntax = "proto3";

option java_multiple_files = true;
option java_package = "com.example.grpc_app_demo";
option java_outer_classname = "GreeterProto";
option objc_class_prefix = "GRT";

package greeter;

// Определение сервиса приветствия.
service Greeter {
// Одиночный вызов
rpc SayHello (HelloRequest) returns (HelloResponse) {}

// Потоковая передача с сервера
rpc LotsOfReplies(HelloRequest) returns (stream HelloResponse);

// Потоковая передача от клиента
rpc LotsOfRequests(stream HelloRequest) returns (HelloResponse);

// Двунаправленная потоковая передача
rpc BidirectionalHello(stream HelloRequest) returns (stream HelloResponse);
}

// Сообщение-запрос с именем пользователя.
message HelloRequest {
string name = 1;
}

// Сообщение-ответ с приветствиями
message HelloResponse {
string message = 1;
}

Делаем повторную сборку проекта, сгенерируются файлы  —  это «заглушки» клиента для подключения к серверу grpc.

Этап 4. Создаем канал для обмена данными между клиентом и сервером:

channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build()

Адрес хоста и номер порта укажем позже.

А сейчас отправим простой одиночный вызов:

fun sendMessage(
message: String,
channel: ManagedChannel?
):String? {
return try {
val stub = GreeterGrpc.newBlockingStub(channel)
val request = HelloRequest.newBuilder().setName(message).build()
val reply = stub.sayHello(request)
reply.message
} catch (e: Exception) {
e.message
}
}

Здесь используется сгенерированная «заглушка» GreeterGrpcStub, создаются входные данные  —  HelloRequest и получается ответ.

Прежде чем вызывать этот метод, настроим локальный сервер.

Этап 5. Загружаем отсюда Node.js и настраиваем.

Создаем папку grpc-server, запускаем npm init -y. Переопределяем файл package.json:

{
...
"dependencies": {
"@grpc/proto-loader": "⁰.5.0",
"async": "¹.5.2",
"google-protobuf": "³.0.0",
"@grpc/grpc-js": "¹.1.0",
"lodash": "⁴.6.1",
"minimist": "¹.2.0"
}
}

Запускаем npm install. Отправляем тот же greeter.proto в эту папку.

Создаем файл index.js и помещаем в него этот код:

var PROTO_PATH = __dirname+'/greeter.proto';
var grpc = require('@grpc/grpc-js');
var protoLoader = require('@grpc/proto-loader');
var packageDefinition = protoLoader.loadSync(
PROTO_PATH,
{keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
});

var greeter_proto =
grpc.loadPackageDefinition(packageDefinition).greeter;

function lotsOfReplies(call) {
console.log("Request:"+call.request);
var name = call.request.name
for(let i=0;i<10;++i){
call.write({message: "Yo "+name+" "+i});
}
call.end();
}

function sayHello(call, callback) {
callback(null, {message: 'Yo ' + call.request.name});
}

function lotsOfRequests(call, callback) {
var input = [];
var index = 0;
call.on('data',function(request){
console.log("Request:"+request.name);
input.push("Hello "+request.name+" "+index+"\n");
index++;
});
call.on('end',function(){
callback(null, {message: input.toString()});
});
}

function bidirectionalHello(call) {
var input = [];
var index = 0;
call.on('data',function(request){
console.log("Request: "+request.name);
if(index < 2){
call.write({message: "Hello "+request.name+" "+(index*2)});
}
else{
call.write({message: "Yo "+request.name+" "+(index*3)});
}

input.push(request.name+"\n");
index++;
});
call.on('end',function(){
call.write({message: "\n"+input.toString()+" "+(index*index)});
call.end();
});
}

function main() {
var server = new grpc.Server();
server.addService(greeter_proto.Greeter.service, {
sayHello: sayHello,
lotsOfReplies: lotsOfReplies,
lotsOfRequests: lotsOfRequests,
bidirectionalHello: bidirectionalHello
});

server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
server.start();
});
}

main();

Запускаем узел index.js: localhost готов к работе.

Этап 6. Переходим к MainActivity.kt:

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
val mainViewModel: MainViewModel by viewModels()
super.onCreate(savedInstanceState)
setContent {
GRPC_APP_DEMOTheme {
// Контейнер surface с цветом background из темы
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text("GRPC Demo")
}
)
}
) {
Column(
modifier = Modifier
.padding(24.dp)
){
Row(
modifier = Modifier.fillMaxWidth()
){
OutlinedTextField(
enabled = mainViewModel.hostEnabled.value,
value = mainViewModel.ip.value,
onValueChange = {
mainViewModel.onIpChange(it)
},
modifier = Modifier
.fillMaxWidth()
.weight(1f),
placeholder = {
Text("IP address")
},
label = {
Text("Server")
}
)
Spacer(modifier = Modifier.size(16.dp))
OutlinedTextField(
enabled = mainViewModel.portEnabled.value,
value = mainViewModel.port.value,
onValueChange = {
mainViewModel.onPortChange(it)
},
modifier = Modifier
.fillMaxWidth()
.weight(1f),
placeholder = {
Text("Port")
},
label = {
Text("Port")
}
)
}
Row(
modifier = Modifier.fillMaxWidth()
){
Button(
enabled = mainViewModel.startEnabled.value,
onClick = {
mainViewModel.start()
},
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Text("Start")
}
Spacer(modifier = Modifier.size(16.dp))
Button(
enabled = mainViewModel.endEnabled.value,
onClick = {
mainViewModel.exit()
},
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Text("End")
}
}
Button(
enabled = mainViewModel.buttonsEnabled.value,
onClick = {
mainViewModel.sayHello("Arun")
},
modifier = Modifier.fillMaxWidth()
) {
Text("Simple RPC: Say Hello")
}
Text("Result: ${mainViewModel.result.value}")
}
}
}
}
}
}
}

В MainViewModel.kt помещаем этот метод:

fun sayHello(name: String) {
viewModelScope.launch(context = Dispatchers.IO) {
try {
updateResult(sendMessage(name,channel) ?: "")
} catch (e: Exception) {
updateResult(e.message?:"")
}
}
}

А в grpc.kt—  этот:

fun sendMessage(
message: String,
channel: ManagedChannel?
):String? {
return try {
val stub = GreeterGrpc.newBlockingStub(channel)
val request = HelloRequest.newBuilder().setName(message).build()
val reply = stub.sayHello(request)
reply.message
} catch (e: Exception) {
e.message
}
}

Запускаем приложение.

Добавляем сервер 10.0.2.2 и порт 50051, нажимаем Start и Simple RPC и получаем ответ с локального сервера:

Ответ с сервера соответствует коду.

Переходим к потоковой передаче с сервера. В MainViewModel.kt помещаем этот код:

fun sendMessageWithReplies(message: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
updateResult(sendMessageWithReplies(message,channel).toString())
} catch (e: Exception) {
updateResult(e.message?:"")
}
}
}

А в grpc.kt—  этот:

fun sendMessageWithReplies(
message: String,
channel: ManagedChannel?
):Any? {
return try {
val stub = GreeterGrpc.newBlockingStub(channel)
val request = HelloRequest.newBuilder().setName(message).build()
val reply = stub.lotsOfReplies(request)
reply.asSequence().toList().map { it -> it.message+"\n" }
} catch (e: Exception) {
e
}
}

Получаем итератор:

Нажимаем Server Streaming и получаем поток сообщений.

Переходим к потоковой передаче от клиента, создаем асинхронную «заглушку»:

fun sendMessageWithRequests(
channel: ManagedChannel?
):Any {
return try {
val stub = GreeterGrpc.newStub(channel)
var failed: Throwable? = null
val finishLatch = CountDownLatch(1)
val responseList = mutableListOf<HelloResponse>()
val requestObserver = stub.lotsOfRequests(object : StreamObserver<HelloResponse> {
override fun onNext(response: HelloResponse) {
responseList.add(response)
}

override fun onError(t: Throwable) {
failed = t
finishLatch.countDown()

override fun onCompleted() {
finishLatch.countDown()
}
})

try {
val requests = arrayOf(
newHelloResponse("TOM"),
newHelloResponse("ANDY"),
newHelloResponse("MANDY"),
newHelloResponse("John")
)
for (request in requests) {
requestObserver.onNext(request)
}
} catch (e: java.lang.RuntimeException) {
requestObserver.onError(e)
return e.message?:""
}

requestObserver.onCompleted()

if (!finishLatch.await(1, TimeUnit.MINUTES)) {
return "Timeout error"
}

if (failed != null) {
return failed?.message?:""
}

return responseList.map { it.message }
} catch (e: Exception) {
e
}
}

Нужно создать 2 средства наблюдения за потоками  —  по одному для ответа и запроса.

Сделаем «защелку» с обратным отсчетом для ожидания текущего потока:

Нажимаем Client Streaming и получаем результат.

То же самое делаем с двунаправленной потоковой передачей:

fun sendMessageBiDirectional(channel: ManagedChannel?): Any{
return try {
val stub = GreeterGrpc.newStub(channel)
var failed: Throwable? = null
val finishLatch = CountDownLatch(1)
val responseList = mutableListOf<HelloResponse>()
val requestObserver = stub.bidirectionalHello(object : StreamObserver<HelloResponse> {
override fun onNext(response: HelloResponse) {
responseList.add(response)
}

override fun onError(t: Throwable) {
failed = t
finishLatch.countDown()
}

override fun onCompleted() {
finishLatch.countDown()
}
})

try {
val requests = arrayOf(
newHelloResponse("TOM"),
newHelloResponse("ANDY"),
newHelloResponse("MANDY"),
newHelloResponse("John")
)
for (request in requests) {
requestObserver.onNext(request)
}
} catch (e: java.lang.RuntimeException) {
requestObserver.onError(e)
return e.message?:""
}

requestObserver.onCompleted()

if (!finishLatch.await(1, TimeUnit.MINUTES)) {
return "Timeout error"
}

if (failed != null) {
return failed?.message?:""
}

return responseList.map { it.message }
} catch (e: Exception) {
e
}
}

Нажимаем Bi-directional. Вот и все.

Поясним использование методов на сервере Node.js:

var PROTO_PATH = __dirname+'/greeter.proto';
var grpc = require('@grpc/grpc-js');
var protoLoader = require('@grpc/proto-loader');
var packageDefinition = protoLoader.loadSync(
PROTO_PATH,
{keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
});


var greeter_proto =
grpc.loadPackageDefinition(packageDefinition).greeter;


function lotsOfReplies(call) {
console.log("Request:"+call.request);
var name = call.request.name
for(let i=0;i<10;++i){
call.write({message: "Yo "+name+" "+i});
}
call.end();
}

function sayHello(call, callback) {
callback(null, {message: 'Yo ' + call.request.name});
}

function lotsOfRequests(call, callback) {
var input = [];
var index = 0;
call.on('data',function(request){
console.log("Request:"+request.name);
input.push("Hello "+request.name+" "+index+"\n");
index++;
});
call.on('end',function(){
callback(null, {message: input.toString()});
});
}

function bidirectionalHello(call) {
var input = [];
var index = 0;
call.on('data',function(request){
console.log("Request: "+request.name);
if(index < 2){
call.write({message: "Hello "+request.name+" "+(index*2)});
}
else{
call.write({message: "Yo "+request.name+" "+(index*3)});
}

input.push(request.name+"\n");
index++;
});
call.on('end',function(){
call.write({message: "\n"+input.toString()+" "+(index*index)});
call.end();
});
}

function main() {
var server = new grpc.Server();
server.addService(greeter_proto.Greeter.service, {
sayHello: sayHello,
lotsOfReplies: lotsOfReplies,
lotsOfRequests: lotsOfRequests,
bidirectionalHello: bidirectionalHello
});

server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
server.start();
});
}

main();

В методе main() запускается сервер grpc с IP-адресом 0.0.0.0 и портом 50051 и добавляется сервис Greeter.

Для демонстрационных целей в различные RPC-вызовы добавлено логики.

Для потоковых запросов  —  потоковой передачи от клиента и двунаправленной потоковой передачи  —  нужны обратные вызовы, например call.on(‘data’,null) и call.on(‘end’,null).

Когда получаются запросы из потока, объектом call реализуется Readable. Когда в потоке отправляется ответ, объектом call реализуется Writable.

gRPC  —  весьма высокопроизводительный способ взаимодействия с серверами. Еще больше о нем  —  здесь.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Shaik Ahron: Connecting Android Apps with Server using gRPC

Предыдущая статьяТип Result в Rust
Следующая статьяNext.js и React.js: что выбрать для проекта