👋

[超モダン] Connectを使用したGo/DartのELIZAアプリ ~Dart編~

2025/02/19に公開

Dart でついに Connect が使えるようになったので、gRPC の進化版 Connect-Dart の導入方法について解説していきたいと思います!!

今後の開発で広く使われていくと思うので、ぜひ一読ください。
執筆日(2025/2/18)で、Connect-Dartが出たばかりなのでどんどんアップデートされていくとおもいます!

https://connectrpc.com/docs/dart/getting-started/
https://pub.dev/packages/connectrpc
https://github.com/connectrpc/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 を用いた双方向ストリーミングも可能です。

https://connectrpc.com/

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 認証情報が無効または不足。

https://github.com/connectrpc/connect-dart/blob/main/packages/connect/lib/src/code.dart

デモアプリ

公式の Eliza アプリを改良し、認証を追加したものです。
ローカルで動くので、実際のコードを見ることでConnectの理解が少しでも深まれば幸いです。

https://github.com/rensawamo/dart-go-grpc-connect

サーバー側の実装や、アーキテクチャなどを以下で説明しています。
https://zenn.dev/baleenstudio/articles/2c3c18809b79f1

フロント側は、以下のような感じです。

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 を定義します。
今回は、以下で定義しました。

https://github.com/rensawamo/dart-go-grpc-connect/tree/main/proto

Proto 3

(Protocol Buffers v3)を触ったことがない方は以下の公式ドキュメントを一読ください。
https://protobuf.dev/programming-guides/proto3/

通信方式が以下で色々出てくるので、ざっくりまとめておきます。

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のインターセプターを追加できます!
リクエストフローを切り替えることも可能です。

フローは以下で説明しています。
https://zenn.dev/baleenstudio/articles/2c3c18809b79f1#リクエストフロー

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);

今回は、ログなども細かく出しているため、プロジェクトをフォークしたりして動かしてみるといいと思います。

よくある質問

こちらで、まとまっていて
とても参考になったのでぜひ!
https://connectrpc.com/docs/faq

Web

今回は、Webにあまり触れていないため、気になる方は公式のサンプルが参考になるかと思います。
https://github.com/connectrpc/connect-dart/tree/main/example/web

最後に

Connect-Dartがやっと出たので、調査をこめてEliza拡張アプリを作ってみました。
現在、私のプロジェクトでは、gRPCを使用していて、自作したgrpcHandlerを使って、
サービスクライアントを一つずつ定義していく必要があり、少しだけ手間がかかる感じでしたが、
connectへ移行するとサービスクライアントが、コードジェネレーションされるため、楽になるなといった感じです。

しかし、まだまだ Connect-Dartは開発初期であり、移行するには少し早いかなと議論しています。
その辺は、Connect-Dartの開発状況を把握しながら慎重に行いたいと考えています。

Connect-Dartへ移行した時とかは、ぜひコメントいただけると嬉しいなと思います!

この記事が、お役に立てれば幸いです⭐️

株式会社BALEEN STUDIO

Discussion