В последнее время все очевиднее спрос на более надежные и распределенные инструменты, применяемые в командах. Особенно хороши в таких сценариях средства gRPC, которыми генерируется серверный код для Golang или Dart, поддерживаются серверные языки вроде Python, Ruby, Node.js, PHP и .NET, а также генерируется код для клиентских языков Dart, Swift, Objective-C, Java и Kotlin.

Благодаря gRPC намечаются контуры будущего API, генерируются сетевой код и код объектной модели для использования на сервере и любом клиенте.

В этом руководстве вы научитесь определять API при помощи gRPC и генерировать его инструментами код для серверных приложений Golang и клиентских Flutter, а также:

  • Читать и изменять файл .proto, которым описывается API.
  • Взаимодействовать с API-интерфейсом gRPC при помощи Evans.
  • Инструментом командной строки protoc генерировать код Dart для приложения Flutter.
  • Напрямую взаимодействовать с gRPC в приложении Flutter.

Примечание: применяемый здесь серверный код создан на Go, доступен в репозитории.

Начинаем

Установка Evans

Evans  —  это версия gRPC наподобие cURL и Insomnia, для которой, прежде чем выполнять вызовы к серверу с gRPC, не требуется писать целое клиентское приложение. А еще с Evans быстро проверяется на наличие ошибок файл .proto.

Evans поэтапно устанавливается на macOS диспетчером пакетов Homebrew:

brew tap ktr0731/evans && brew install evans

Проверяем корректность установки Evans:

evans --version

Установка Protoc и плагина Dart

Прежде чем установить плагин Dart, загружаем с официального сайта и устанавливаем SDK-пакет Dart.

Чтобы сгенерировать код Dart из файла спецификации gRPC, понадобятся:

  • Исполняемый файл protoc.
  • Плагины для Dart: один для генерирования кода для элементов Message, другой для генерирования элементов Service.

Установив Dart, запускаем:

dart pub global activate protoc_plugin

Подробнее о gRPC

gRPC  —  это технология, разработанная в Google для определения и сопровождения интерфейса приложения. Ее инструментами и плагинами генерируются нативный сетевой код и код объектной модели для использования с интерфейсом на сервере и клиенте.

В gRPC серверный и клиентский код генерируется из одной спецификации, кодированной в файле .proto, независимо от используемого языка программирования.

То есть разработчики определяют интерфейс службы в этом файле буферами протокола  —  всеязычным, платформенно независимым механизмом для сериализации структурированных данных. После чего в gRPC генерируется код для разных языков программирования, а разработчики уже на своем языке обеспечивают согласованность различных компонентов системы.

С gRPC упрощением сетевого взаимодействия минимизируется вероятность ошибок, вызванных ручной реализацией сетевых протоколов. Здесь выполняются сериализация, десериализация и сетевая передача, благодаря чему разработчики сосредотачиваются на создании основной функциональности приложений.

Чем gRPC отличается от REST?

Изображение: gRPC против Rest API

Разница между gRPC и JSON

В gRPC объекты данных кодируются и декодируются буферами протокола.

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

Сначала необходимо определить сообщения gRPC и модели данных в файле .proto.

Работа с файлом .proto

.proto  —  это текстовый файл, в котором содержатся определения для всех API. Акцентируем внимание на сообщениях и службах. Сообщения  —  это объекты данных, отправляемые между клиентом и сервером. Службами определяется способ передачи сообщений. В файле .proto содержатся также метаданные для различных генераторов кода.

Самое интересное для всех, у кого случались проблемы с комментариями в JSON: комментарии в файле .proto имеются. И в стиле //, и в стиле /* ... */.

В первой строке файла todo.proto корневого каталога проекта указывается используемая версия gRPC:

syntax = "proto2";

Если строка отсутствует, в gRPC рабочей считается версия 2, а не 3. Затем генератором кода при помощи параметров присваиваются названия.

Определение служб

В генерируемом коде Dart каждая запись rpc становится функцией.

syntax = "proto3";
package pb;
service TodoGrpc {
rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse){}
rpc GetTask(Empty) returns (GetAllTaskResponse){}
rpc DeleteTask(DeleteTaskRequest) returns (Empty){}
rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse){}
}

1. Объявление синтаксиса

  • syntax = "proto3";: в этой строке указывается версия используемых буферов протокола, здесь это proto3  —  последняя на момент написания данной статьи версия, усовершенствованная по сравнению с предыдущими.

2. Объявление пакета

  • package pb;: ключевым словом package файлы буферов протокола организуются в пространства имен, здесь pb  —  пространство имен для этого определения службы.

3. Объявление службы

  • service TodoGrpc { ... }: в этом блоке определяется служба gRPC TodoGrpc. Служба gRPC  —  это набор удаленных методов, называемых также RPC, которые вызываются клиентами.

4. RPC, или удаленные вызовы процедур

  • rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse){}: в этой строке объявляется метод RPC CreateTask, которым принимается сообщение CreateTaskRequest и возвращается сообщение CreateTaskResponse.
  • rpc GetTask(EmptyGetRequest) returns (GetAllTaskResponse){}: методом RPC GetTask не принимается никаких входных данных, что обозначается как Empty, а возвращается GetAllTaskResponse.
  • rpc DeleteTask(DeleteTaskRequest) returns (Empty){}: методом RPC DeleteTask принимается DeleteTaskRequest, а возвращается EmptyTaskRequest.
  • rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse){}: методом RPC UpdateTask принимается UpdateTaskRequest, а возвращается UpdateTaskResponse.

Определение сообщений

Вот спецификация сообщения для объекта Task:

syntax = "proto3";
package pb;

CreateTaskRequest:

message CreateTaskRequest {
string task_name = 1;
string task_description = 2;
string task_deadline = 3;
optional string task_status = 4;
optional string task_priority = 5;
optional google.protobuf.Timestamp task_createdAt = 6;
}
  • Здесь определяется сообщение для создания новой задачи с полями для ее названия, описания, сроков, статуса, приоритета и метки времени создания.

CreateTaskResponse:

message CreateTaskResponse {
string task_name = 1;
string task_description = 2;
string task_deadline = 3;
optional string task_status = 4;
optional string task_priority = 5;
google.protobuf.Timestamp task_createdAt = 6;
optional google.protobuf.Timestamp task_updatedAt = 7;
}
  • А это ответ после создания задачи с аналогичными полями и полем для обновленной метки времени.

Task:

message Task {
string task_name = 1;
optional string task_description = 2;
optional string task_deadline = 3;
optional string task_status = 4;
optional string task_priority = 5;
optional google.protobuf.Timestamp task_createdAt = 6;
optional google.protobuf.Timestamp task_updatedAt = 7;
}

Это объект задачи с полями: название, описание, сроки, статус, приоритет и метки времени для создания и обновления.

  • Empty:
message Empty {}

А это пустое сообщение, часто используется как заполнитель или для указания на недостаток данных.

  • GetAllTaskResponse:
message GetAllTaskResponse {
repeated Task tasks = 1;
}

Это ответ с несколькими задачами, полем repeated типа Task.

  • DeleteTaskRequest:
message DeleteTaskRequest {
int64 id = 1;
}

А это запрос на удаление задачи по ее идентификатору.

  • UpdateTaskRequest:
message UpdateTaskRequest {
int64 id = 1;
string title = 2;
optional string description = 3;
optional string task_status = 4;
optional string task_priority = 5;
}

Это запрос на обновление задачи с полями: идентификатор, название, описание, статус и приоритет.

  • UpdateTaskResponse:
message UpdateTaskResponse {
Task task = 1;
}

А это ответ после обновления задачи с обновленным объектом Task.

Первое поле помечено как optional. Пока значение не сохранено в базе данных, его в этом поле не будет. Каждое поле в сообщении отслеживается по присвоенному ему генератором кода номеру.

Кроме string, у поля могут быть различные скалярные типы, и в каждом языке генератором выполняются соответствующие преобразования. Полями бывают перечисления или даже другие сообщения.

Ключевым словом repeated в коде генерируется массив задач Tasks. Подробнее о различных вариантах  —  здесь.

В Google имеется немало известных типов: Empty, Timestamp, BoolValue и другие. Однако homebrew-версия protoc не представлена, поэтому здесь применяется пользовательская. Тем не менее обе стратегии рабочие.

Пробуем файл .proto с Evans

Открываем терминал и в корневом каталоге проекта, где содержится файл to-do.proto, запускаем Evans в режиме REPL:

evans repl --host localhost --port 1234 --proto ./todo.proto

Появляется заставка Evans:

______
| ____|
| |__ __ __ __ _ _ __ ___
| __| \ \ / / / _. | | '_ \ / __|
| |____ \ V / | (_| | | | | | \__ \
|______| \_/ \__,_| |_| |_| |___/

more expressive universal gRPC client
pb.TodoGrpc@localhost:9090>

Сервер gRPC еще не запущен и не сгенерирован код gRPC на Golang, а в Evans с помощью файла todo.proto уже предоставляется информация о службах.

Вспоминаем из файла todo.proto, что имеется четыре сообщения.

В командной строке Evans вводим:

show message

Появятся четыре определенные ранее сообщения:

+--------------------+
| MESSAGE |
+--------------------+
| CreateTaskRequest |
| CreateTaskResponse |
| DeleteTaskRequest |
| Empty |
| GetAllTaskResponse |
| UpdateTaskRequest |
| UpdateTaskResponse |
+--------------------+

Теперь командой show service выводим в Evans список служб:

+----------+------------+-------------------+--------------------+
| SERVICE | RPC | REQUEST TYPE | RESPONSE TYPE |
+----------+------------+-------------------+--------------------+
| TodoGrpc | CreateTask | CreateTaskRequest | CreateTaskResponse |
| TodoGrpc | GetTask | Empty | GetAllTaskResponse |
| TodoGrpc | DeleteTask | DeleteTaskRequest | Empty |
| TodoGrpc | UpdateTask | UpdateTaskRequest | UpdateTaskResponse |
+----------+------------+-------------------+--------------------+

Закрываем Evans и возвращаемся в терминал командой exit.

Мы на верном пути, дальше сгенерируем код Dart.

Генерирование кода Dart из файла .proto

Открываем в терминале каталог с файлом todo.proto и вводим:

protoc --dart_out=grpc:lib/core/proto/generate \\
-Ilib/core/proto lib/core/proto/*.proto

Работа с proto в приложении Flutter

Сначала создаем новый проект Flutter:

Flutter create todogrpc

Затем добавляем пакеты и импортируем все необходимое для работы с gRPC во Flutter:

dependencies:
protobuf: ^latest
get_it: ^latest
grpc: ^latest
dartz: ^latest
flutter_riverpod: ^latest
fixnum: ^latest

dev_dependencies:
freezed: ^latest
  1. protobuf: этим пакетом обеспечивается поддержка буферов протокола, то есть метода сериализации структурированных данных, применяемого обычно для эффективного взаимодействия служб в распределенных системах.
  2. get_it  —  это простой, но мощный фреймворк внедрения зависимостей для Dart и Flutter. Им обеспечивается слабая связанность компонентов в приложении, благодаря чему экземпляры зависимостей получаются без прямого к ним обращения.
  3. grpc  —  пакет для взаимодействия приложений Dart с серверами удаленного вызова процедур gRPC. Им предоставляются инструменты для генерирования клиентского и серверного кода из определений служб protobuf, обеспечивается бесперебойное взаимодействие различных служб.
  4. dartz  —  это библиотека функционального программирования для Dart. Ею предоставляются служебные программы для работы с типичными концепциями функционального программирования: Option, Either, Try и другими. Так пишется более лаконичный, выразительный код, особенно в асинхронных и подверженных ошибкам сценариях.
  5. flutter_riverpod  —  библиотека управления состоянием для Flutter, в основе которой  —  пакет Provider. Это простой и гибкий способ управления состоянием приложения, зависимостями и обновлениями пользовательского интерфейса. С ним проще создавать масштабируемые и сопровождаемые приложения Flutter.
  6. freezed  —  библиотека генерирования кода для Dart и Flutter, с которой легко создавать неизменяемые классы. В основе freezed аннотированные классы, поэтому ею автоматически генерируется шаблонный код для эквивалентности значений, методов copyWith и многого другого. Так уменьшается перегруженность кода, обеспечивается типобезопасность.

Создадим в серверной папке файл grpc_handler.dart и определим класс GrpcHandlerService:

import '../proto/generate/todo.pbgrpc.dart';
import 'package:grpc/grpc.dart';
class GrpcHandlerService {
late TodoGrpcClient client;

static final GrpcHandlerService _grpcHandlerService =
GrpcHandlerService._internal();

factory GrpcHandlerService() {
return _grpcHandlerService;
}

GrpcHandlerService._internal();

static Future<GrpcHandlerService> init() async {
final channel = ClientChannel('localhost',
port: 9090,
options: const ChannelOptions(
credentials: ChannelCredentials.insecure(),
idleTimeout: Duration(minutes: 30),
));

try {
await channel.getConnection();

_grpcHandlerService.client = TodoGrpcClient(channel,
options: CallOptions(
timeout: const Duration(minutes: 30),
));
} catch (e) {
print('unable to conntect');
rethrow;
}

return GrpcHandlerService();
}
}
  1. Класс GrpcHandlerService
  • В этом классе обрабатывается gRPC-взаимодействие, и содержится поздно инициализируемая переменная экземпляра client типа TodoGrpcClient, которая инициализируется после создания канала gRPC.
  • А еще здесь определяются конструкторы: закрытый _internal() и фабричный GrpcHandlerService(). В последнем при помощи шаблона «Одиночка» возвращается единственный экземпляр GrpcHandlerService.
  • GrpcHandlerService инициализируется статическим методом init(), которым возвращается завершаемая экземпляром GrpcHandlerService футура.

2. Метод init()

  • Внутри метода init() создается gRPC ClientChannel с адресом 'localhost' и портом 9090, сконфигурированный небезопасными учетными данными и тайм-аутом ожидания 30 минут.
  • Соединение с каналом устанавливается с помощью channel.getConnection().
  • Если оно установлено, то инициализированным каналом и параметрами вызова с 30-минутным тайм-аутом создается TodoGrpcClient.
  • Выдаваемая во время инициализации ошибка перехватывается, выводится на печать и выдается повторно.
  • В конце возвращается экземпляр GrpcHandlerService.

Создадим папку failures и добавим файл failure.dart, который ранее применялся для обработки различных случаев сбоев:

import 'package:freezed_annotation/freezed_annotation.dart';
part 'failure.freezed.dart';

@freezed
class Failure with _$Failure {
const factory Failure.failure(final String message) = _Failure;
const factory Failure.notFoundFailure(final String message) = _NotFoundFailure;
const factory Failure.errorFailure(final String message) = _ErrorFailure;
}

Следующей командой терминала сгенерируем класс сбоя freezed, создадим в серверной папке файл gapi.dart, определим абстрактный класс TodoFacade и реализуем функции в ITodoFacade:

import 'package:dartz/dartz.dart';

import '../../error/failure.dart';
import '../../proto/generate/todo.pb.dart' as $pb;
import '../grpc_handler.dart';

abstract class TodoFacade {
Future<Either<Failure, Unit>> createTodo(
$pb.CreateTaskRequest createTaskRequest);
Future<Either<Failure, Unit>> updateTodo(
$pb.UpdateTaskRequest updateTaskRequest);
Future<Either<Failure, Unit>> deleteTodo(
$pb.DeleteTaskRequest deleteTaskRequest);

Future<Either<Failure, $pb.GetAllTaskResponse>> listTodo();
}

class ITodoFacade implements TodoFacade {
final GrpcHandlerService _grpcHandlerService;

ITodoFacade(this._grpcHandlerService);

@override
Future<Either<Failure, Unit>> createTodo(
$pb.CreateTaskRequest createTaskRequest) async {
final response =
await _grpcHandlerService.client.createTask(createTaskRequest);

if (response.isInitialized()) {
return right(unit);
} else {
return left(const Failure.errorFailure('An Error Occurred'));
}
}

@override
Future<Either<Failure, Unit>> deleteTodo(
$pb.DeleteTaskRequest deleteTaskRequest) async {
final response =
await _grpcHandlerService.client.deleteTask(deleteTaskRequest);

if (response.isInitialized()) {
return right(unit);
} else {
return left(const Failure.errorFailure('An Error Occurred'));
}
}

@override
Future<Either<Failure, $pb.GetAllTaskResponse>> listTodo() async {
print(_grpcHandlerService.client.getTask($pb.EmptyGetRequest()));
final response =
await _grpcHandlerService.client.getTask($pb.EmptyGetRequest());

if (response.isInitialized()) {
return right(response);
} else {
return left(const Failure.errorFailure('An Error Occurred'));
}
}

@override
Future<Either<Failure, Unit>> updateTodo(
$pb.UpdateTaskRequest updateTaskRequest) async {
final response =
await _grpcHandlerService.client.updateTask(updateTaskRequest);

if (response.isInitialized()) {
return right(unit);
} else {
return left(const Failure.errorFailure('An Error Occurred'));
}
}
}
  1. Импорты
  • dartz: импорт библиотеки Dartz с ее конструкциями функционального программирования Either для обработки ошибок.
  • failure.dart: импорт пользовательского класса Failure для различных сценариев сбоя в приложении.
  • todo.pb.dart: импорт сгенерированных классов protobuf, которыми определяются типы сообщений запросов и ответов для Todo-задач.
  • grpc_handler.dart: импорт пользовательского класса GrpcHandlerService, которым обрабатывается gRPC-взаимодействие.

2. Абстрактный класс TodoFacade

  • Им объявляется четыре абстрактных метода для CRUD-операций Todo-задач: createTodo, updateTodo, deleteTodo, listTodo.

3. Класс ITodoFacade

  • Реализуется абстрактный класс TodoFacade.
  • В конструкторе для обработки gRPC-взаимодействия принимается экземпляр GrpcHandlerService.
  • Каждый абстрактный метод реализуется клиентскими gRPC-методами GrpcHandlerService.

4. Метод createTodo

  • Метод createTask клиента gRPC вызывается для создания Todo-задачи.
  • Если получен ответ об успехе или ответа нет, возвращается Right(unit), в противном случае  —  Failure с сообщением об ошибке.

5. Метод deleteTodo

  • Метод deleteTask клиента gRPC вызывается для удаления Todo-задачи.
  • Аналогично createTodo проверяется, получен ли успешный ответ, и возвращается соответствующий результат Either.

6. Метод listTodo

  • Метод getTask клиента gRPC вызывается для получения всех Todo-задач.
  • При успехе возвращается ответ Right(response), в противном случае  —  Failure.

7. Метод updateTodo

  • Метод updateTask клиента gRPC вызывается для обновления Todo-задачи.
  • Аналогично createTodo и deleteTodo проверяется, получен ли успешный ответ, и возвращается соответствующий результат Either.

Посредством доступа к статическому элементу I для GetIt создается глобальный экземпляр sl, которым во всем приложении регистрируются и получаются зависимости.

Реализуем управление состояниями, для их обработки понадобится Riverpod:

import 'package:freezed_annotation/freezed_annotation.dart';
import 'core/proto/generate/todo.pb.dart';

part 'todo_states.freezed.dart';

@freezed
class TodoState with _$TodoState {
const factory TodoState.initial() = _Initial;
const factory TodoState.loading() = _Loading;
const factory TodoState.getTodo({required GetAllTaskResponse data}) =
_Success;
const factory TodoState.error({required String message}) = _Error;
}

Следующей командой терминала сгенерируем класс Todo-состояний:

dart run build_runner build

Теперь реализуем управление состоянием с Riverpod:

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/grpc/api/todo.dart';
import 'core/injector/injector.dart';
import 'core/proto/generate/todo.pb.dart';
import 'todo_states.dart';

class TodoNotifier extends StateNotifier<TodoState> {
TodoNotifier() : super(const TodoState.initial()){
getTodo();
}

final todoList = sl<TodoFacade>();

getTodo() async {
final todo = await todoList.listTodo();

todo.fold((l) {
state = TodoState.error(message: l.message);
}, (r) {
state = TodoState.getTodo(data: r);
});
}

createTodo({required CreateTaskRequest createTaskRequest}) async {
state = const TodoState.loading();

final todoList = sl<TodoFacade>();

final todo = await todoList.createTodo(createTaskRequest);

todo.fold((l) {
state = TodoState.error(message: l.message);
}, (r) {
getTodo();
});
}

updateTodo({required UpdateTaskRequest updateTaskRequest}) async {
state = const TodoState.loading();

final todoList = sl<TodoFacade>();

final todo = await todoList.updateTodo(updateTaskRequest);

todo.fold((l) {
state = TodoState.error(message: l.message);
}, (r) {
getTodo();
});
}

deleteTodo({required DeleteTaskRequest deleteTaskRequest}) async {
state = const TodoState.loading();

final todoList = sl<TodoFacade>();

final todo = await todoList.deleteTodo(deleteTaskRequest);


todo.fold((l) {
state = TodoState.error(message: l.message);
}, (r) {
getTodo();
});
}
}

Создадим Provider для StateNotifier:

final todoNotifierProvider =
StateNotifierProvider<TodoNotifier, TodoState>((ref) => TodoNotifier());

Теперь в main.dart создаем простой пользовательский интерфейс:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/injector/injector.dart';
import 'core/localization/generated/strings.dart';
import 'core/proto/generate/google/protobuf/timestamp.pb.dart';
import 'core/proto/generate/todo.pbgrpc.dart';
import 'todo_notifier.dart';

void main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);

await injector();
runApp(const ProviderScope(child: MyHomePage()));
}

class MyHomePage extends ConsumerStatefulWidget {
const MyHomePage({super.key});

@override
ConsumerState<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends ConsumerState<MyHomePage> {
@override
void initState() {
super.initState();
ref.read(todoNotifierProvider.notifier).getTodo();
}

@override
Widget build(BuildContext context) {
return
MaterialApp(
title: 'gRPC Demo',
debugShowCheckedModeBanner: false,
home: const Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text("gRPC Demo"),
centerTitle: true,
),
body: Column(
mainAxisSize: MainAxisSize.min,
children: [
ref.watch(todoNotifierProvider).maybeWhen(
orElse: () {
return const CircularProgressIndicator();
},
loading: () {
return const Center(child: CircularProgressIndicator());
},
getTodo: (data) {
return Expanded(
child: ListView.builder(
itemCount: data.tasks.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(data.tasks[index].taskName),
onLongPress: () {
ref.read(todoNotifierProvider.notifier).deleteTodo(
deleteTaskRequest: DeleteTaskRequest(
id: data.tasks[index].taskId));
},
subtitle: Text(data.tasks[index].taskDescription),
);
}),
);
},
error: (error) {
return Text(error.toString());
},
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Добавляем в список фиктивную задачу

DateTime currentDate = DateTime.now(); //DateTime

ref.read(todoNotifierProvider.notifier).createTodo(
createTaskRequest: CreateTaskRequest(
taskName: 'Hello',
taskCreatedAt: Timestamp.fromDateTime(currentDate),
taskDeadline: 'Tomorrow',
taskDescription: 'A short Tasks',
taskPriority: 'High',
taskStatus: 'Not Started'));
},
tooltip: "Add Todo",
child: const Icon(Icons.add),
),
),
],
),
);
}
}

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

Демоверсия пользовательского интерфейса gRPC

Что дальше?

Мы изучили основы работы с файлом .proto, которым описывается API-интерфейс gRPC, научились генерировать код Dart с protoc и модифицировать приложение Flutter для применения gRPC вместо HTTP.

Дополнительные документация и руководства имеются в проектах на GitHub для protobuf, grpc-dart и protoc.

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

Читайте нас в Telegram, VK и Дзен


Перевод статьи Ademola Kolawole: Exploring gRPC and Flutter for Modern App Development

Предыдущая статьяMVI на Eventbrite