Привет! Меня зовуте Андрей и я работаю разработчиком Flutter.
Написание материала вызвано желанием показать пример создания сервиса c использованием технологии gRPC в экосистеме Dart и, соответственно, Flutter. Желание периодически возникает, когда приходится испытывать "боль", при переключении на проекты, в которых до сих пор применяется REST + JSON.
Планирую сделать серию из 3-4 статей.
Кратко о gRPC
gRPC (Remote Procedure Calls от Гугл) технология для создания информационных систем (сервисов и клиентских приложений).
Для сериализации данных и их передачи по сети, как правило, в связке с gRPC используется Protocol Buffers (Protobuf).
Protobuf применяется и как IDL (Interface Definition Language) для описания типов данных и вызываемых процедур.
Технология gRPC является достойной альтернативой широко распространённым подходам, при которых сетевые вызовы используют HTTP методы, а обмен данными происходит в формате JSON или XML.
Основные преимущества gRPC это:
HTTP/2 в качестве транспорта
Отсутствие привязок к HTTP-методам при взаимодействии компонентов системы
Возможность использования Protocol Buffers (Protobuf) для сериализации / десериализации данных и их передачи по сети
Protobuf IDL удобен для описания системы
Нет необходимости вручную писать модели, сериализацию / десериализацию данных, интерфейсы вызовов процедур. Применяется кодогенерация для популярных языков программирования, в том числе и для Dart
![Упрощённый пример gRPC системы Упрощённый пример gRPC системы](https://habrastorage.org/getpro/habr/upload_files/c5e/2a1/208/c5e2a1208365312be6e94068fbd102f8.png)
Пример написания сервиса и клиента
В примере приведено несколько CLI команд, которые для вашей системы могут чуть разниться.
Подготовка среды разработки
Если на машине нет Dart SDK его нужно установить. Пример команды установки для Mac brew install dart
, для Ubuntu 20.4 sudo apt install dart
.
Проверить, что Dart успешно установлен dart --version
.
Установить protobuf (пример для Mac brew install protobuf
, пример для Ubuntu 20.4 sudo apt install -y protobuf-compiler
).
Проверить, что все прошло успешно protoc --version
.
![](https://habrastorage.org/getpro/habr/upload_files/1cd/831/eac/1cd831eacf1518fa14e77c6c3c400244.png)
Установить плагин для кодогенерации .proto
файлов, описывающих систему, в Dart:
dart pub global activate protoc_plugin
.
Pub устанавливает утилиты в $HOME/.pub-cache/bin
.
Чтобы плагин был доступен из любой директории в вашем терминале, добавьте в его конфигурационный файл (.bashrc, .bash_profile, .zshrc и т.п.) строчку export PATH="$PATH":"$HOME/.pub-cache/bin"
и перезагрузите терминал (или выполните команду source
на обновленный файл).
Подготовка проекта
В качестве примера давайте сделаем сервис, который будет задавать "Клиентам" вопросы и получать от них ответы. Название пусть будет "Umka".
В выбранной папке создаем проект:
dart create umka
Перейдя в папку проекта добавим директорию protos/
и в неё файл umka.proto
, в котором мы и опишем нашу систему:
mkdir protos && touch protos/umka.proto
Для исходного кода сделаем папку lib/
с файлами service.dart
и client.dart
:
mkdir lib && touch lib/service.dart lib/client.dart
Создадим также папку для сгенерированного кода:
mkdir lib/generated
В результате структура проекта выглядит следующим образом:
![](https://habrastorage.org/getpro/habr/upload_files/b69/3f8/ea2/b693f8ea26b36bf1b65f01f8dde14865.png)
Добавление зависимостей
В качестве сторонней библиотеки нам пока потребуется только grpc. Остальные зависимости она "подтянет" сама.
Добавим её в pubspec.yaml и удалим из файла все лишнее:
![](https://habrastorage.org/getpro/habr/upload_files/d4d/41c/545/d4d41c545e2a1790753f32f6b131941b.png)
Для загрузки из pub.dev репозитория библиотеки и её зависимостей в папке проекта выполним команду: dart pub get
Описание системы в с помощью IDL proto3
Опишем наш сервис Umka следующим образом:
![](https://habrastorage.org/getpro/habr/upload_files/f90/f42/cb2/f90f42cb27ec6b83728ddacba6b165db.png)
Кому лень печатать, прогуляйтесь по ссылке на код.
В первой строчке обязательно нужно указать версию IDL syntax="proto3";
.
Строки с 3 по 24 содержат описание типов передаваемых данных:
Ученик
Вопрос
Ответ
Оценка
Обратите внимание, что записи подобные string text = 2;
выглядят как присваивание значения, но на самом деле это номера полей, которые используются для их идентификации в бинарном потоке данных при сериализации / десериализации.
Типы выглядят как в привычных языках программирования:
встроенные (Scalar Value Types)
int32
иstring
созданные
Student
,Question
В конце файла описан сам сервис, который содержит пока только два вызова:
получить вопрос
отправить ответ
Структура записи вызова rpc sendAnswer(Answer) returns(Evaluation) {}
следующая:
sendAnswer
- название удаленного вызоваAnswer
- тип запросаEvaluation
- тип ответа
Генерируем код сервиса на основе его описания в umka.proto
Для этого из папки проекта запустим в терминале команду:
protoc -I protos/ protos/umka.proto --dart_out=grpc:lib/generated
Разберем команду:
protoc
утилита генерации (мы установили ее ранее)-I protos/
указание расположения файлов .protoprotos/umka.proto
файл описания сервиса--dart_out=grpc:lib/generated
grpc - указание плагина, lib/generated - директория для сгенерированного кода
В результате её выполнения в проекте появится 4 новых файла:
![](https://habrastorage.org/getpro/habr/upload_files/608/7ef/7ad/6087ef7ad966e3ee4b6f0a050f7e49a9.png)
Это основа нашего сервиса.
Эмуляция работы с данными
Добавим в корень проекта папку с файлом db/questions_db.json
со списком вопросов:
[
{
"id": 0,
"text": "7 x 5 = ?"
},
{
"id": 1,
"text": "12 x 13 = ?"
},
{
"id": 3,
"text": "2 ** 5 = ?"
},
{
"id": 4,
"text": "2 ** 10 = ?"
},
{
"id": 5,
"text": "2 ** 11 = ?"
}
]
В папку lib добавим файл lib/questions_db_driver.dart
с кодом для получения списка вопросов из нашей импровизированной базы данных:
import 'dart:io';
import 'dart:convert';
import 'generated/umka.pb.dart';
final List<Question> questionsDb = _readDb();
List<Question> _readDb() {
final jsonString = File('data/questions_db.json').readAsStringSync();
final List db = jsonDecode(jsonString);
return db
.map((entry) => Question()
..id = entry['id']
..text = entry['text'])
.toList();
}
Пишем код для сервера
В файле lib/service.dart создадим класс UmkaService, расширив UmkaServiceBase, находящийся в сгенерированном файле lib/generated/umka.pbgrpc.dart
:
class UmkaService extends UmkaServiceBase {}
Добавим реализацию одного из двух обязательных методов абстрактного родительского класса getQuestion
, а для второго sendAnswer
оставим пока заглушку TODO
:
@override
Future<Question> getQuestion(ServiceCall call, Student request) async {
print('Received question request from: $request');
return questionsDb[Random().nextInt(questionsDb.length)];
}
@override
Future<Evaluation> sendAnswer(ServiceCall call, Answer request) {
// TODO: implement sendAnswer
throw UnimplementedError();
}
Я намеренно оставил имя второго параметра обоих вызовов request
- каждый удаленный вызов должен содержать объект запроса, соответствующий типу, описанному в файле umka.proto
.
В этот же файл lib/service.dart добавим код запуска нашего сервиса на сервере:
class Server {
Future<void> run() async {
final server = grpc.Server([UmkaService()]);
await server.serve(port: 5555);
print('Serving on the port: ${server.port}');
}
}
Future<void> main() async {
await Server().run();
}
Теперь наш сервис готов "служить клиентам на 5555 порту", отвечая пока только на один вызов getQuestion
.
Пишем код клиентского приложения для терминала
Файл lib/client.dart
import 'package:grpc/grpc.dart';
import 'generated/umka.pbgrpc.dart';
class UmkaTerminalClient {
late final ClientChannel channel;
late final UmkaClient stub;
UmkaTerminalClient() {
channel = ClientChannel(
'127.0.0.1',
port: 5555,
options: ChannelOptions(credentials: ChannelCredentials.insecure()),
);
stub = UmkaClient(channel);
}
Future<Question> getQuestion(Student student) async {
final question = await stub.getQuestion(student);
print('Received question: $question');
return question;
}
Future<void> callService(Student student) async {
await getQuestion(student);
await channel.shutdown();
}
}
Future<void> main(List<String> args) async {
final clientApp = UmkaTerminalClient();
final student = Student()
..id = 42
..name = 'Alice Bobich';
await clientApp.callService(student);
}
ClientChannel channel;
является абстракцией сетевых вызовов по протоколу HTTP/2. Можно представить его как канал к виртуальному "gRPC endpoint".
stub
- экземпляр "клиента" любезно сгенерированного нам утилитой protoc
. Вызовы его методов фактически и являются RPC - удалёнными вызовами процедур.
В конструкторе мы инициализируем channel
передав ему адрес localhost
(для запуска локально), произвольный порт, и отключаем для простоты демонстрации "секьюрность".
Далее инициализируем stub
передав ему созданный channel
.
Метод запроса случайного вопроса getQuestion
очень прост - вызываем соответствующий метод у нашего экземпляра stub
, ждём пока вопрос не "прилетит", печатаем его и "возвращаем".
Метод callService
в классе UmkaTerminalClient
присутствует для демонстрации работы.
Также для запуска примера в файл client.dart добавлен метод main
в котором "создаётся студент" и от его имени запрашивается вопрос у нашего сервиса./
Запускаем сервис
Для запуска сервиса на localhost
из директории проекта выполним команду:
dart lib/service.dart
Стартуем клиентское приложение
Командой dart lib/client.dart
в другом окне терминала из папки проекта запустим нашего "клиента", который создаст канал, установит соединение с сервисом, запросит случайный вопрос, получит его и разорвёт соединение, заглушив канал.
![](https://habrastorage.org/getpro/habr/upload_files/e91/241/556/e9124155607413962af81a8fd9eef545.gif)
Заключение
На этом первая часть закончена, мы проделали отличную работу:
Подготовили среду разработки
Создали Dart проект
Добавили все необходимые зависимости
Описали нашу систему с помощью IDL proto3
Сгенерировали базовый Dart код системы утилитой
protoc
Добавили "базу вопросов" и код для чтения из неё
Написали код для запуска сервиса на сервере
Создали терминального "клиента"
Запустили сервис на локальной машине и обратились к нему получив запрошенные данные
Далее можно "получать удовольствие" развивая наш сервис и клиентское приложение.
В следующих частях мы посмотрим как отвечать сервису, получать от сервиса поток данных, отправлять поток данных на сервис, открывать двунаправленное соединение обмениваясь данными в режиме непрерывного потока между сервисом и клиентским приложением.
До встречи в следующей части!