[超モダン] Connectを使用したGo/DartのELIZAアプリ ~Dart編~
Dart でついに Connect が使えるようになったので、gRPC の進化版 Connect-Dart の導入方法について解説していきたいと思います!!
今後の開発で広く使われていくと思うので、ぜひ一読ください。
執筆日(2025/2/18)で、Connect-Dartが出たばかりなのでどんどんアップデートされていくとおもいます!
こんな人におすすめ
- 最新のスキーマ駆動開発に興味がある人
- RESTとgRPCの両方に対応できる柔軟なAPIを作りたい人
- 型安全なバックエンド・フロントエンドの開発を行いたい人
- Bufに興味がある人
Connect とは
Connectは、gRPCの機能を簡素化し、ブラウザと互換性のあるHTTP APIを構築するためのライブラリです。Connectを使うことで、以下のメリットがあります。
-
スキーマ駆動の開発
Protobufを利用してAPI定義を作成し、バックエンドとフロントエンドで共通のコードを自動生成 -
シンプルな構築
gRPCに比べて、設定やプロキシの導入が不要 -
HTTP/1.1 でも動作
curlコマンドで簡単にリクエストをテスト可能 -
様々なストリーミング対応
Connect プロトコル、gRPC、gRPC WEB の 3 つのプロトコルに対応しています。
従来の gRPC との互換性があるため HTTP/2 を用いた双方向ストリーミングも可能です。
3つのプロトコルをサポートしている
Connect-Dartは、Connectプロトコル、gRPC、gRPC-Webの3つのプロトコルに対応しています。特に、Connectプロトコルは、HTTP/1.1やHTTP/2上で動作し、gRPCのストリーミング機能を活かしながらも、モバイルやWebといった幅広いプラットフォームで利用できるのが特徴です。
プロトコル | 説明 |
---|---|
Connect(デフォルト) | シンプルなHTTPベースのプロトコルで、JSONとProtobufの両方に対応 |
gRPC | 従来のgRPCサービスと通信可能 |
gRPC-Web | HTTPトレーラーを使用しないgRPCのWeb対応版 |
import 'package:connectrpc/protocol/connect.dart' as protocol;
// 簡単に切り替えが可能
// import 'package:connectrpc/protocol/grpc.dart' as protocol;
// import 'package:connectrpc/protocol/grpc_web.dart' as protocol;
final transport = protocol.Transport(
baseUrl: "https://demo.connectrpc.com",
codec: const ProtoCodec(), // JSONを使う場合はJsonCodec()
httpClient: createHttpClient(),
);
HTTPクライアントの種類
Connect-Dartでは、用途に応じて3種類のHTTPクライアントが利用できます。
クライアント | 対応プロトコル | 特徴 |
---|---|---|
dart:io |
Connect, gRPC-Web | HTTP/1 のみ対応、双方向ストリーミング不可 |
dart:js_interop + fetch |
Connect, gRPC-Web | Web向け、双方向ストリーミング不可 |
http2 |
Connect, gRPC, gRPC-Web | HTTP/2 対応、全てのRPCタイプに対応(Web不可) |
Connect Error Codes
Connect はエラーのカテゴリをコードとして表現し、それぞれが特定の HTTP ステータスコードに対応しています。
コード | HTTP ステータス | 説明 |
---|---|---|
canceled |
499 | 呼び出し元が RPC をキャンセル。 |
unknown |
500 | 原因不明のエラーや分類不能なエラー。 |
invalid_argument |
400 | リクエストが無効。 |
deadline_exceeded |
504 | 処理が期限内に完了しなかった。 |
not_found |
404 | リソースが見つからない。 |
already_exists |
409 | 既に存在するリソースを作成しようとした。 |
permission_denied |
403 | 操作の権限がない。 |
resource_exhausted |
429 | リソース不足のため処理できない。 |
failed_precondition |
400 | システムの状態が要件を満たしていません。 |
aborted |
409 | 操作が中断された。(例: 競合発生)。 |
out_of_range |
400 | 許容範囲外の操作が実行された。 |
unimplemented |
501 | 操作が未実装または無効化されている。 |
internal |
500 | システム内部の重大なエラーが発生。 |
unavailable |
503 | サービスが一時的に利用できない。 |
data_loss |
500 | データの損失や破損が発生。 |
unauthenticated |
401 | 認証情報が無効または不足。 |
デモアプリ
公式の Eliza アプリを改良し、認証を追加したものです。
ローカルで動くので、実際のコードを見ることでConnectの理解が少しでも深まれば幸いです。
サーバー側の実装や、アーキテクチャなどを以下で説明しています。
フロント側は、以下のような感じです。
frontend/
├── core/
│ ├── gen/ # コード生成(Connectサービス)
│ ├── provider/ # Riverpod Generatorを利用した依存関係の注入
│ ├── router/ # 画面遷移の管理
│ ├── state/ # SSOT状態管理
│ ├── util/ # loggerなど
├── feature/
│ ├── eliza/ # Eliza機能の実装
│ ├── login/ # 認証機能の実装
├── app.dart
├── main.dart
簡単なアプリの説明
①loginでアクセストークンを得る
②アクセストークンをHeaderに含めて、Elizaにリクエスト
レスポンスとして、マイグレーションした文をランダムで受け取るといったシンプルなアプリです。
Protocol Buffersを定義する
gRPCと同様に、Connect でも Protocol Buffers を使って API を定義します。
今回は、以下で定義しました。
Proto 3
(Protocol Buffers v3)を触ったことがない方は以下の公式ドキュメントを一読ください。
通信方式が以下で色々出てくるので、ざっくりまとめておきます。
RPC タイプ | 説明 |
---|---|
Unary | 1 つのリクエストを送り、1 つのレスポンスを受け取る (1:1) |
Server Streaming | 1 つのリクエストを送り、複数のレスポンスをストリーミングで受け取る (1:N) |
Client Streaming | 複数のリクエストを送り、1 つのレスポンスを受け取る (N:1) |
Bidirectional streaming RPC | クライアントとサーバーが同時にストリーミングデータを送受信 (N:N) |
コード生成する
Protocol Buffersが定義できたら、自動コード生成を行います。
project-root/
│── backend/ # バックエンド(Go)のソースコード
│ ├── gen
│── frontend/ # フロントエンド(Dart)のソースコード
│ ├── lib/gen
│── proto/ # Protocol Buffersの定義ファイル
このプロジェクトでは、protoディレクトリに、Protocol Buffers の定義ファイルを配置し
フロントエンドとバックエンドの、両方で共通のプロトコルを使用できるようになっています。
具体的には、proto/ に .proto ファイルを定義し、それを Bufなどのコードジェネレーターを使って、フロントエンド(TypeScript, Dart など)と、バックエンド(Go, Python, Node.js など)で、それぞれ利用できるクライアントコードやサーバースタブを自動生成します。
これにより、フロントエンドとバックエンド間で一貫した API 仕様を維持することができます。
この構造により、フロントエンドとバックエンドの開発チームが、同じプロトコルを基に開発を進められ
API の整合性を保ちつつ型安全な通信を実現できます。
例えば、プロジェクトルートのMakeコマンドから、一斉にFrontendとBackendでのコードジェネレーションが可能です!
$ make gen
cd backend && make gen && cd ../frontend && make gen
....
すると、以下のようにDart用のコードが吐き出されるはずです。
core
gen
├── auth/v1
│ ├── auth.connect.client.dart
│ ├── auth.connect.spec.dart
│ ├── auth.pb.dart
│ ├── auth.pbenum.dart
│ ├── auth.pbjson.dart
│ ├── auth.pbserver.dart
│
├── eliza/v1
│ ├── eliza.connect.client.dart
│ ├── eliza.connect.spec.dart
│ ├── eliza.pb.dart
│ ├── eliza.pbenum.dart
│ ├── eliza.pbjson.dart
│ ├── eliza.pbserver.dart
APIを叩いてみよう
Transportを定義する
上記で、通信プロトコルを切り替えられることを説明しました。
今回はモバイルアプリを想定し、connectプロトコルを使用します。
import 'package:connectrpc/protocol/connect.dart' as protocol;
final transport = protocol.Transport(
baseUrl: "https://demo.connectrpc.com",
codec: const ProtoCodec(), // Or JsonCodec()
httpClient: createHttpClient(),
);
生成されたServiceClientを使用してAPIを叩く
この say メソッドは SayRequest を送信し、SayResponse を受け取る Unary RPC です。
final response = await ElizaServiceClient(widget.transport).say(
SayRequest(sentence: sentence),
);
上記で、APIが叩けます。
また、コード生成によって型が決まっているので、バックエンドとの型が違ったといった心配がなくなります。
要素 | 型 | 説明 |
---|---|---|
リクエスト (SayRequest ) |
string sentence |
送信するメッセージ |
レスポンス (SayResponse ) |
string sentence |
受信するメッセージ |
(proto定義は以下)
message SayRequest {
string sentence = 1;
}
message SayResponse {
string sentence = 1;
}
service ElizaService {
// unary RPC
rpc Say(SayRequest) returns (SayResponse) {}
}
様々な通信
以下のように、ストリーミング通信も可能のようです。
Protoの定義を変えてみて、試してみるのもありですね!
サーバーストリーミングの受信
final stream = elizaClient.introduce(IntroduceRequest());
await for (final next in stream) {
print(next);
}
クライアントストリーミング & 双方向ストリーミング
final stream = elizaClient.converse(Stream.fromIterable([ConverseRequest()]));
await for (final next in stream) {
print(next);
}
Riverpodを使った書き方に変える
上記は、基礎を説明したので、ここからriverpodを使ったインターセプターの追加などを行なっていきます。
メタデータのInterceptorを定義する
MetadataInterceptor は、Token を受け取り、それをリクエストのヘッダーに追加します。
call メソッド内で next(req) を実行する前に、
req.headers.add('Authorization', 'Bearer ${token.accessToken}')
を追加することで、リクエストに認証情報を付与します。
また、タイムアウトなどのカスタマイズも可能です。
@riverpod
MetadataInterceptor metaDataInterceptor(
Ref ref,
) {
final token = ref.read(tokenNotifierProvider);
return MetadataInterceptor(token);
}
class MetadataInterceptor {
const MetadataInterceptor(this.token);
final Token token;
AnyFn<I, O> call<I extends Object, O extends Object>(AnyFn<I, O> next) {
return (req) async {
// タイムアウトを設定
final signal = TimeoutSignal(const Duration(seconds: 10));
// Headerに認証情報を追加
req.headers.add('Authorization', 'Bearer ${token.accessToken}');
switch (req) {
case UnaryRequest<I, O>(message: final message):
return next(
UnaryRequest<I, O>(
req.spec,
req.url,
req.headers,
message,
signal,
),
);
case StreamRequest<I, O>(message: final message):
return next(
StreamRequest<I, O>(
req.spec,
req.url,
req.headers,
message,
signal,
),
);
}
};
}
}
Transportにinterceporを追加する
Transportのinterceptorsにログやmetadataのインターセプターを追加できます!
リクエストフローを切り替えることも可能です。
フローは以下で説明しています。
part 'transport.g.dart';
@riverpod
Transport transport(
Ref ref, {
bool isRequireMetaData = true, //リクエストフローを切り替えフラグ
}) {
final metadataInterceptor = ref.read(metaDataInterceptorProvider);
final transport = protocol.Transport(
baseUrl:
Platform.isAndroid ? 'http://10.0.2.2:8080' : 'http://localhost:8080',
codec: const ProtoCodec(),
httpClient: createHttpClient(),
interceptors: [
const LoggingInterceptor().call,
if (isRequireMetaData) metadataInterceptor.call,
],
);
return transport;
}
API実行
final request = SayRequest(sentence: sentence);
final response = await ElizaServiceClient(
ref.read(transportProvider())).say(request);
今回は、ログなども細かく出しているため、プロジェクトをフォークしたりして動かしてみるといいと思います。
よくある質問
こちらで、まとまっていて
とても参考になったのでぜひ!
Web
今回は、Webにあまり触れていないため、気になる方は公式のサンプルが参考になるかと思います。
最後に
Connect-Dartがやっと出たので、調査をこめてEliza拡張アプリを作ってみました。
現在、私のプロジェクトでは、gRPCを使用していて、自作したgrpcHandlerを使って、
サービスクライアントを一つずつ定義していく必要があり、少しだけ手間がかかる感じでしたが、
connectへ移行するとサービスクライアントが、コードジェネレーションされるため、楽になるなといった感じです。
しかし、まだまだ Connect-Dartは開発初期であり、移行するには少し早いかなと議論しています。
その辺は、Connect-Dartの開発状況を把握しながら慎重に行いたいと考えています。
Connect-Dartへ移行した時とかは、ぜひコメントいただけると嬉しいなと思います!
この記事が、お役に立てれば幸いです⭐️
Discussion