• トップ
  • ブログ一覧
  • 【Flutter】gRPCを使ってAPI通信を実装する【後編】
  • 【Flutter】gRPCを使ってAPI通信を実装する【後編】

    広告メディア事業部広告メディア事業部
    2022.11.11

    IT技術

    はじめに

    近々gRPCを使ってAPI通信するFlutterアプリを開発する機会があるので、そこに向けた事前準備&備忘録として対応内容を記録していきます。

    本記事では前編記事に引き続き、gPRCの下記4つの通信方式すべてのパターンで実装した内容をまとめます。

    • Unary RPC (単一リクエスト, 単一レスポンス)
    • Server streaming RPC (単一リクエスト, 複数レスポンス)
    • Client streaming RPC (複数リクエスト, 単一レスポンス)
    • Bidirectional streaming RPC (複数リクエスト, 複数レスポンス)

    環境構築については前編記事にまとめているのでそちらを御覧ください。
    またgRPCとは?という部分の説明は省きますので、詳細を知りたい方は公式ドキュメントを御覧ください。

    前編はこちら

    featureImg2022.11.08【Flutter】gRPCを使ってAPI通信を実装する【前編】はじめに近々gRPCを使ってAPI通信するFlutterアプリを開発する機会があるので、そこに向けた事前準備&...

    開発環境

    開発環境は以下の通りです。

    Dart2.18.2
    Flutter3.3.4
    protoc3.21.7

    Unary RPC (単一リクエスト, 単一レスポンス)

    まずは、Unary RPCでの通信を実装してみます。

    Unary RPCは単一リクエスト、単一レスポンスなので、下記のようなユースケースで利用できそうです。

    • リクエストとレスポンスが時間経過や状態変化で変わることがない、または変更を通知する必要がない

    前編記事で実装したHello, worldアプリもUnary RPCでしたが、おさらいということで今回は少し内容を変えて「10進数の数字を2進数へ変換する計算が正しいかどうかを判定する」機能を作成してみます。

    サーバー側

    サーバー側の準備を行います。

    リクエストとして10進数の数字と2進数の数字文字列を受け取り、レスポンスとして正誤結果と正しい2進数の数字文字列を返却するようにします。

    まずは、server/protos に今回作るサービス用のprotoファイルを作成します。

    1syntax = "proto3";
    2
    3package decimal_to_binary;
    4
    5service Converter {
    6  rpc Convert (ConvertRequest) returns (ConvertResponse) {}
    7}
    8
    9message ConvertRequest {
    10  int32 decimal = 1;
    11  string binary = 2;
    12}
    13
    14message ConvertResponse {
    15  bool isCorrect = 1;
    16  string answer = 2;
    17}

    次にgRPCのDart用コードを出力します。
    前編同様に共通パッケージのgrpc_gen のlib/src 内に出力します。
    前編ではsrc直下に出力しましたが、今回は機能ごとにディレクトリを切って出力していきます。

    1$ cd server
    2$ protoc --dart_out=grpc:../grpc_gen/lib/src/decimal_to_binary -Iprotos protos/decimal_to_binary.proto

    利用する側が自動生成されたファイル郡を意識せずに扱えるようにするために、各ファイルのexportも設定します。

    grpc_gen/lib/src/decimal_to_binary/decimal_to_binary.dart

    1library decimal_to_binary;
    2
    3export 'decimal_to_binary.pb.dart';
    4export 'decimal_to_binary.pbenum.dart';
    5export 'decimal_to_binary.pbgrpc.dart';
    6export 'decimal_to_binary.pbjson.dart';

    grpc_gen/lib/grpc_gen.dart

    1library grpc_gen;
    2
    3...
    4export 'src/decimal_to_binary/decimal_to_binary.dart';   <--追加

    最後にConverterServiceBase を継承したConverterService クラスをlib/converter_serevice.dart として作成し、bin/server.dart内でサーバーアプリを起動するmain処理のサービス一覧に追加します。

    server/lib/converter_service.dart

    1import 'package:grpc/grpc.dart';
    2import 'package:grpc_gen/grpc_gen.dart';
    3
    4class ConverterService extends ConverterServiceBase {
    5  @override
    6  Future<ConvertResponse> convert(
    7      ServiceCall call, ConvertRequest request) async {
    8    // 2進数に変換
    9    final result = request.decimal.toRadixString(2);
    10    return ConvertResponse(
    11      isCorrect: result == request.binary,
    12      answer: result,
    13    );
    14  }
    15}

    server/bin/server.dart

    1import 'package:grpc/grpc.dart';
    2import 'package:server/converter_service.dart';
    3import 'package:server/greeter_service.dart';
    4
    5Future<void> main(List<String> args) async {
    6  final server = Server(
    7    [
    8      GreeterService(),
    9      ConverterService(),  <-- 追加
    10    ],
    11    const <Interceptor>[],
    12    CodecRegistry(codecs: const [GzipCodec(), IdentityCodec()]),
    13  );
    14  await server.serve(port: 50051);
    15  print('Server listening on port ${server.port}...');
    16}

    これでサーバー側の実装および準備は完了です。

    アプリ側

    サーバー側の準備が完了したので、次はアプリ側の実装を行っていきます。

    前編記事にてHello, world機能のあるアプリを作成したので、依存パッケージやサーバーにアクセスするためのチャンネルを生成するProvider などはそのまま流用し、同じアプリ内に機能追加する形で新しい画面を追加していきます。

    まずは、入力する10進数と2進数を保持するStateProvider を実装します。

    ついでに数字が有効な場合のみ「答え合わせ」ボタンを活性にするために、変換可能かどうかを判定するProvider も作成しておきます。

    1/// 10進数を保持するProvider
    2final decimalProvider = StateProvider.autoDispose<String>((ref) {
    3  return '';
    4});
    5
    6/// 2進数を保持するProvider
    7final binaryProvider = StateProvider.autoDispose<String>((ref) {
    8  return '';
    9});
    10
    11/// 変換可能かどうかを判定するProvider
    12final convertibleProvider = Provider.autoDispose<bool>((ref) {
    13  final decimal = ref.watch(decimalProvider);
    14  final binary = ref.watch(binaryProvider);
    15  if (decimal.isEmpty || binary.isEmpty) {
    16    // 未入力の場合は不可
    17    return false;
    18  }
    19
    20  final decimalNumber = int.tryParse(decimal);
    21  if (decimalNumber == null) {
    22    // 入力された10進数が数字でない場合は不可
    23    return false;
    24  }
    25
    26  // それ以外の場合は可
    27  return true;
    28});

    次に、入力された10進数と2進数をもとに答え合わせの結果を保持・更新するStateNotiferProvider を実装します。

    ボタンが押されたときにcheck() 、数字が変更されたときにはreset() が呼ばれる想定です。

    1final convertProvider =
    2    StateNotifierProvider.autoDispose<ConvertNotifier, ConvertResponse?>((ref) {
    3  return ConvertNotifier(channel: ref.watch(channelProvider));
    4});
    5
    6class ConvertNotifier extends StateNotifier<ConvertResponse?> {
    7  ConvertNotifier({
    8    required ClientChannel channel,
    9  })  : _channel = channel,
    10        super(null);
    11
    12  final ClientChannel _channel;
    13
    14  Future<void> check({required int decimal, required String binary}) async {
    15    // クライアント作成
    16    final client = ConverterClient(_channel);
    17
    18    // サービス実行
    19    final response = await client.convert(
    20      ConvertRequest(decimal: decimal, binary: binary),
    21    );
    22
    23    // レスポンスの状態を更新
    24    state = response;
    25  }
    26
    27  void reset() {
    28    state = null;
    29  }
    30}

    最後に、表示するWidget を作成します。

    1class DecimalToBinaryPage extends StatelessWidget {
    2  const DecimalToBinaryPage({super.key});
    3
    4  @override
    5  Widget build(BuildContext context) {
    6    return Scaffold(
    7      appBar: AppBar(title: const Text('Unary RPC')),
    8      body: SafeArea(
    9        child: Padding(
    10          padding: const EdgeInsets.symmetric(horizontal: 32),
    11          child: Column(
    12            mainAxisAlignment: MainAxisAlignment.center,
    13            children: [
    14              Row(
    15                children: [
    16                  Expanded(
    17                    child: Column(
    18                      crossAxisAlignment: CrossAxisAlignment.start,
    19                      children: [
    20                        const Text('10進数'),
    21                        const SizedBox(height: 8),
    22                        Consumer(
    23                          builder: (context, ref, child) {
    24                            return TextField(
    25                              onChanged: (value) {
    26                                ref.read(convertProvider.notifier).reset();
    27                                ref
    28                                    .read(decimalProvider.notifier)
    29                                    .update((state) => value);
    30                              },
    31                            );
    32                          },
    33                        ),
    34                      ],
    35                    ),
    36                  ),
    37                  const Icon(Icons.arrow_right),
    38                  Expanded(
    39                    child: Column(
    40                      crossAxisAlignment: CrossAxisAlignment.start,
    41                      children: [
    42                        const Text('2進数'),
    43                        const SizedBox(height: 8),
    44                        Consumer(
    45                          builder: (context, ref, child) {
    46                            return TextField(
    47                              onChanged: (value) {
    48                                ref.read(convertProvider.notifier).reset();
    49                                ref
    50                                    .read(binaryProvider.notifier)
    51                                    .update((state) => value);
    52                              },
    53                            );
    54                          },
    55                        ),
    56                      ],
    57                    ),
    58                  ),
    59                ],
    60              ),
    61              const SizedBox(height: 16),
    62              Consumer(
    63                builder: (context, ref, child) {
    64                  return ElevatedButton(
    65                    onPressed: ref.watch(convertibleProvider)
    66                        ? () {
    67                            ref.read(convertProvider.notifier).check(
    68                                  decimal:
    69                                      int.parse(ref.watch(decimalProvider)),
    70                                  binary: ref.watch(binaryProvider),
    71                                );
    72                          }
    73                        : null,
    74                    child: const Text('答え合わせ'),
    75                  );
    76                },
    77              ),
    78              const SizedBox(height: 32),
    79              Consumer(
    80                builder: (context, ref, child) {
    81                  final response = ref.watch(convertProvider);
    82                  if (response == null) {
    83                    return const SizedBox.shrink();
    84                  }
    85                  return Column(
    86                    children: [
    87                      Text(response.isCorrect ? '正解!' : '不正解!'),
    88                      Text('答え:${response.answer}'),
    89                    ],
    90                  );
    91                },
    92              )
    93            ],
    94          ),
    95        ),
    96      ),
    97    );
    98  }
    99}

    以上でアプリ側の実装は完了です。

    動作確認

    それでは動作確認してみます。

    10進数と対応する2進数を入力して「答え合わせ」ボタンをタップすると…

    無事答え合わせができました!

    Server streaming RPC (単一リクエスト, 複数レスポンス)

    次は、Server streaming RPCでの通信を実装していきます。

    Server streaming RPCは単一リクエスト、複数レスポンスなので、下記のようなユースケースで利用できそうです。

    • リクエストに対するレスポンスが時間経過や状態変化で変化する、かつ変化を監視する必要がある

    上記を満たす機能として、「秒間隔をリクエストとして送信し、送信された間隔ごとに時刻を返す」機能を作成してみます。

    サーバー側

    サーバー側の実装をしていきます。

    リクエストとして秒間隔を受け取り、レスポンスとして秒間隔ごとにUNIX時間を返却するものを定義します。

    まずは、server/protos に今回作るサービス用のprotoファイルを作成します。

    レスポンスの前にstreamを付与するのがポイントです。
    また、UNIX時間はint32 に収まらないのでint64 で定義します。

    1syntax = "proto3";
    2
    3package time_notification;
    4
    5service TimeNotifier {
    6  rpc notifier (TimeNotificationRequest) returns (TimeNotificationResponse) {}
    7}
    8
    9message TimeNotificationRequest {
    10  int32 interval = 1;
    11}
    12
    13message TimeNotificationResponse {
    14  int32 unixTime = 1;
    15}

    次にgRPCのDart用コードを出力します。

    1$ cd server
    2$ protoc --dart_out=grpc:../grpc_gen/lib/src/time_notification -Iprotos protos/time_notification.proto

    利用する側が自動生成されたファイル郡を意識せずに扱えるようにするために、各ファイルのexportも設定します。

    grpc_gen/lib/src/time_notification/time_notification.dart

    1library time_notification;
    2
    3export 'time_notification.pb.dart';
    4export 'time_notification.pbenum.dart';
    5export 'time_notification.pbgrpc.dart';
    6export 'time_notification.pbjson.dart';

    grpc_gen/lib/grpc_gen.dart

    1library grpc_gen;
    2 
    3...
    4export 'src/time_notification/time_notification.dart';   <--追加

    最後にTimeNotifierServiceBase を継承したTimeNotifierService クラスをlib/time_notifier_service.dart に作成し、bin/server.dart 内でサーバーアプリを起動するmain処理のサービス一覧に追加します。

    server/lib/converter_service.dart

    1import 'package:grpc/grpc.dart';
    2import 'package:grpc_gen/grpc_gen.dart';
    3
    4class TimeNotifierService extends TimeNotifierServiceBase {
    5  @override
    6  Stream<TimeNotificationResponse> notifier(
    7      ServiceCall call, TimeNotificationRequest request) {
    8    print('間隔: ${request.interval}秒');
    9    var current = DateTime.now().toUtc();
    10    final timer = Stream<TimeNotificationResponse>.periodic(
    11      Duration(seconds: request.interval),
    12      ((computationCount) {
    13        current = current.add(
    14          Duration(seconds: request.interval),
    15        );
    16        print('時刻: $current');
    17        return TimeNotificationResponse(
    18          unixTime: current.millisecondsSinceEpoch,
    19        );
    20      }),
    21    );
    22    return timer;
    23  }
    24}

    server/bin/server.dart

    1import 'package:grpc/grpc.dart';
    2import 'package:server/converter_service.dart';
    3import 'package:server/greeter_service.dart';
    4 
    5Future<void> main(List<String> args) async {
    6  final server = Server(
    7    [
    8      GreeterService(),
    9      ConverterService(),
    10      TimeNotifierService(),  <-- 追加
    11    ],
    12    const <Interceptor>[],
    13    CodecRegistry(codecs: const [GzipCodec(), IdentityCodec()]),
    14  );
    15  await server.serve(port: 50051);
    16  print('Server listening on port ${server.port}...');
    17}

    これでサーバー側の実装および準備は完了です。

    アプリ側

    サーバー側の準備が完了したので、次はアプリ側の実装を行っていきます。

    まずは、入力した間隔を保持するProvider を実装します。

    1// 入力した間隔を保持するProvider
    2final intervalProvider = StateProvider.autoDispose<String>((ref) {
    3  return '';
    4});

    次にサーバーから取得した時間をStreamで受け取るProvider を実装します。

    1/// サーバーから取得した時間をStreamで受け取るProvider
    2final timeProvider = StreamProvider.autoDispose<DateTime>((ref) async* {
    3  // サーバーへアクセスするためのチャンネルを取得
    4  final channel = ref.watch(channelProvider);
    5
    6  final interval = ref.watch(intervalProvider);
    7  if (interval.isEmpty || int.tryParse(interval) == null) {
    8    return;
    9  }
    10
    11  // クライアント作成
    12  final client = TimeNotifierClient(channel);
    13
    14  // ResponseStreamを取得
    15  final responseStream = client.notifier(
    16    TimeNotificationRequest(
    17      interval: int.parse(interval),
    18    ),
    19  );
    20
    21  await for (final response in responseStream) {
    22    // Streamを受け取るごとに、ローカルの現在時刻を流す
    23    yield DateTime.fromMillisecondsSinceEpoch(response.unixTime.toInt())
    24        .toLocal();
    25  }
    26});

    最後に表示するWidget を作成します。

    1class TimeNotificationPage extends HookConsumerWidget {
    2  const TimeNotificationPage({super.key});
    3
    4  @override
    5  Widget build(BuildContext context, WidgetRef ref) {
    6    final textController = useTextEditingController(
    7      text: ref.watch(intervalProvider),
    8    );
    9    return Scaffold(
    10      appBar: AppBar(title: const Text('Server streaming RPC')),
    11      body: SafeArea(
    12        child: Padding(
    13          padding: const EdgeInsets.symmetric(horizontal: 32),
    14          child: Column(
    15            mainAxisAlignment: MainAxisAlignment.center,
    16            children: [
    17              Consumer(
    18                builder: (context, ref, child) {
    19                  final time = ref.watch(timeProvider).valueOrNull;
    20                  if (time == null) {
    21                    return const SizedBox.shrink();
    22                  }
    23                  return Text('時刻:$time');
    24                },
    25              ),
    26              const SizedBox(height: 32),
    27              Row(
    28                children: [
    29                  Expanded(
    30                    child: TextFormField(
    31                      controller: textController,
    32                    ),
    33                  ),
    34                  const SizedBox(width: 16),
    35                  const Text('秒'),
    36                  const SizedBox(width: 32),
    37                  Consumer(
    38                    builder: (context, ref, child) {
    39                      return ElevatedButton(
    40                        onPressed: () {
    41                          print(textController.text);
    42                          ref
    43                              .read(intervalProvider.notifier)
    44                              .update((state) => textController.text);
    45                        },
    46                        child: const Text(
    47                          '時間取得開始',
    48                        ),
    49                      );
    50                    },
    51                  ),
    52                ],
    53              )
    54            ],
    55          ),
    56        ),
    57      ),
    58    );
    59  }
    60}

    以上でアプリ側の実装は完了です。

    動作確認

    それでは動作確認をしてみます。

    取得したい時間の間隔を入力して「時間取得開始」ボタンをタップすると…

    無事指定した間隔ごとに時間が取得できていることが確認できました!

    Client streaming RPC (複数リクエスト, 単一レスポンス)

    続いては、Client streaming RPCでの通信を実装していきます。

    Client streaming RPCは複数リクエスト、単一レスポンスなので、下記のようなユースケースで利用できそうです。

    • レスポンスに対するリクエストが時間経過や状態変化で変化する、かつ変化を監視する必要がある

    上記を満たす機能として、「aから始まる英単語を5秒以内に何個挙げられるかを計測する」機能を作成してみます。

    サーバー側

    サーバー側の実装をしていきます。

    リクエストとして単語を受け取り、レスポンスとして送られてきた単語の数を返却するものを定義します。

    まずは、server/protos に今回作るサービス用のprotoファイルを作成します。

    今回はリクエストの前にstreamを付与します。

    1syntax = "proto3";
    2
    3package word_count;
    4
    5service WordCounter {
    6  rpc count (stream WordCountRequest) returns (WordCountResponse) {}
    7}
    8
    9message WordCountRequest {
    10  string word = 1;
    11}
    12
    13message WordCountResponse {
    14  int32 count = 1;
    15}

    次にgRPCのDart用コードを出力します。

    1$ cd server
    2$ protoc --dart_out=grpc:../grpc_gen/lib/src/word_count -Iprotos protos/word_count.proto

    利用する側が自動生成されたファイル郡を意識せずに扱えるようにするために、各ファイルのexportも設定します。

    grpc_gen/lib/src/word_count/word_count.dart

    1library word_count;
    2
    3export 'word_count.pb.dart';
    4export 'word_count.pbenum.dart';
    5export 'word_count.pbgrpc.dart';
    6export 'word_count.pbjson.dart';

    grpc_gen/lib/grpc_gen.dart

    1library grpc_gen;
    2
    3...
    4export 'src/word_count/word_count.dart';  <-- 追加

    最後に WordCounterServiceBase  を継承したWordCounterService クラスをlib/word_counter_service.dart に作成し、bin/server.dart 内でサーバーアプリを起動するmain処理のサービス一覧に追加します。

    server/lib/word_counter_service.dart

    1import 'package:grpc/grpc.dart';
    2import 'package:grpc_gen/grpc_gen.dart';
    3
    4class WordCounterService extends WordCounterServiceBase {
    5  final words = <String>[];
    6
    7  @override
    8  Future<WordCountResponse> count(
    9      ServiceCall call, Stream<WordCountRequest> request) async {
    10    await for (final req in request) {
    11      // リクエストごとにリストに追加
    12      print('単語: ${req.word}');
    13      words.add(req.word);
    14    }
    15    // aから始まる単語をカウント
    16    final count = [...words.where((element) => element.startsWith('a'))].length;
    17    // リセット
    18    words.clear();
    19    // カウント数を返却
    20    return WordCountResponse(count: count);
    21  }
    22}

    server/bin/server.dart

    1import 'package:grpc/grpc.dart';
    2import 'package:server/converter_service.dart';
    3import 'package:server/greeter_service.dart';
    4import 'package:server/time_notifier_service.dart';
    5import 'package:server/word_counter_service.dart';
    6
    7Future<void> main(List<String> args) async {
    8  final server = Server(
    9    [
    10      GreeterService(),
    11      ConverterService(),
    12      TimeNotifierService(),
    13      WordCounterService()

    これでサーバー側の実装および準備は完了です。

    アプリ側

    サーバー側の準備が完了したので、次はアプリ側の実装を行っていきます。

    まずは、入力した単語をStreamに追加してくためのControllerを保持するProvider を実装します。

    1/// 入力した単語をStreamで保持するためのController
    2final wordStreamControllerProvider =
    3    StateProvider.autoDispose<StreamController<String>?>((ref) {
    4  return;
    5});

    次に、入力した単語の数を取得するProvider を実装します。

    5秒後にstreamに対してclose処理を呼び出し、レスポンスが受け取れるようにしています。

    1/// 単語の数を取得するProvider
    2final wordCountProvider = FutureProvider.autoDispose<int?>((ref) async {
    3  // サーバーへアクセスするためのチャンネルを取得
    4  final channel = ref.watch(channelProvider);
    5
    6  final streamController = ref.watch(wordStreamControllerProvider);
    7  if (streamController == null) {
    8    return null;
    9  }
    10
    11  // 5秒後にStreamの終了を通知
    12  Future.delayed(const Duration(seconds: 5)).then(
    13    (_) => streamController.close(),
    14  );
    15
    16  // クライアント作成
    17  final client = WordCounterClient(channel);
    18
    19  final response = await client.count(
    20    streamController.stream.map(
    21      (word) => WordCountRequest(word: word),
    22    ),
    23  );
    24
    25  return response.count;
    26});

    最後に表示するWidget を作成します。

    1class WordCountPage extends HookWidget {
    2  const WordCountPage({super.key});
    3
    4  @override
    5  Widget build(BuildContext context) {
    6    final textController = useTextEditingController();
    7    return Scaffold(
    8      appBar: AppBar(title: const Text('Client streaming RPC')),
    9      body: SafeArea(
    10        child: Padding(
    11          padding: const EdgeInsets.symmetric(horizontal: 32),
    12          child: Center(
    13            child: Column(
    14              mainAxisAlignment: MainAxisAlignment.center,
    15              children: [
    16                Consumer(
    17                  builder: (context, ref, child) {
    18                    final count = ref.watch(wordCountProvider).valueOrNull;
    19                    if (count == null) {
    20                      return const SizedBox.shrink();
    21                    }
    22                    return Text('結果: $count個');
    23                  },
    24                ),
    25                const SizedBox(height: 32),
    26                Consumer(
    27                  builder: (context, ref, child) {
    28                    final streamController =
    29                        ref.watch(wordStreamControllerProvider);
    30                    if (streamController == null) {
    31                      return ElevatedButton(
    32                        onPressed: () {
    33                          ref
    34                              .read(wordStreamControllerProvider.notifier)
    35                              .update((state) => StreamController());
    36                        },
    37                        child: const Text('開始!'),
    38                      );
    39                    }
    40                    final count = ref.watch(wordCountProvider).valueOrNull;
    41                    if (count != null) {
    42                      return ElevatedButton(
    43                        onPressed: () {
    44                          textController.clear();
    45                          ref
    46                              .read(wordStreamControllerProvider.notifier)
    47                              .update((state) => null);
    48                        },
    49                        child: const Text('もう一度!'),
    50                      );
    51                    }
    52                    return TextField(
    53                      controller: textController,
    54                      onEditingComplete: () {
    55                        ref
    56                            .read(wordStreamControllerProvider)
    57                            ?.add(textController.text);
    58                        textController.clear();
    59                      },
    60                    );
    61                  },
    62                ),
    63              ],
    64            ),
    65          ),
    66        ),
    67      ),
    68    );
    69  }
    70}

    以上でアプリ側の実装は完了です。

    動作確認

    それでは動作確認をしてみます。

    「開始!」ボタンをタップして単語を入力していくと…

    無事5秒以内に入力した単語数がレスポンスとして返却されるような機能ができました!

    Bidirectional streaming RPC (複数リクエスト, 複数レスポンス)

    最後は、Bidirectional streaming RPCでの通信を実装していきます。

    Client streaming RPCは複数リクエスト、複数レスポンスなので、下記のようなユースケースで利用できそうです。

    • リクエストおよびレスポンスが時間経過や状態変化で変化する、かつ変化を監視する必要がある

    上記を満たす機能として、複数端末を使って匿名チャットができる機能を作成してみます。

    サーバー側

    サーバー側の実装をしていきます。

    リクエストとしてメッセージを受け取り、レスポンスとしてこれまでのメッセージ一覧(時刻付き)を返却するものを定義します。

    まずは、server/protos に今回作るサービス用のprotoファイルを作成します。

    今回はリクエストとレスポンスの前にそれぞれstreamを付与します。

    1syntax = "proto3";
    2
    3package chat;
    4
    5service ChatConnecter {
    6  rpc connect (stream ChatConnectRequest) returns (stream ChatConnectResponse) {}
    7}
    8
    9message ChatConnectRequest {
    10  string message = 1;
    11}
    12
    13message ChatConnectResponse {
    14  repeated Message messages = 1;
    15}
    16
    17message Message {
    18  string message = 1;
    19  int64 unixTime = 2;
    20}

    次にgRPCのDart用コードを出力します。

    1cd server
    2$ protoc --dart_out=grpc:../grpc_gen/lib/src/chat -Iprotos protos/chat.proto

    利用する側が自動生成されたファイル郡を意識せずに扱えるようにするために、各ファイルのexportも設定します。

    grpc_gen/lib/src/time_notification/time_notification.dart

    1library chat;
    2
    3export 'chat.pb.dart';
    4export 'chat.pbenum.dart';
    5export 'chat.pbgrpc.dart';
    6export 'chat.pbjson.dart';

    grpc_gen/lib/grpc_gen.dart

    1library grpc_gen;
    2
    3...
    4export 'src/chat/chat.dart';  <--追加

    最後に ChatConnecterServiceBase  を継承したChatConnecterService クラスをlib/chat_connecter_service.dart に作成し、bin/server.dart 内でサーバーアプリを起動するmain処理のサービス一覧に追加します。

    server/lib/chat_connecter_service.dart

    1import 'dart:async';
    2
    3import 'package:fixnum/fixnum.dart';
    4import 'package:grpc/grpc.dart';
    5import 'package:grpc_gen/grpc_gen.dart';
    6
    7class ChatConnecterService extends ChatConnecterServiceBase {
    8  final streamController = StreamController<List<Message>>.broadcast();
    9  final messages = <Message>[];
    10
    11  @override
    12  Stream<ChatConnectResponse> connect(
    13      ServiceCall call, Stream<ChatConnectRequest> request) async* {
    14    // 現在のメッセージ一覧を設定
    15    streamController.add(messages);
    16    // 接続直後に現在のメッセージ一覧を送信
    17    yield ChatConnectResponse(messages: messages);
    18
    19    request.listen(
    20      (event) {
    21        print('メッセージ: ${event.message}');
    22        messages.add(
    23          Message(
    24            message: event.message,
    25            unixTime: Int64(DateTime.now().millisecondsSinceEpoch),
    26          ),
    27        );
    28        streamController.add(messages);
    29      },
    30    );
    31
    32    // Streamを返却
    33    yield* streamController.stream
    34        .map((messages) => ChatConnectResponse(messages: messages));
    35  }
    36}

    server/bin/server.dart

    1import 'package:grpc/grpc.dart';
    2import 'package:server/chat_connecter_service.dart';
    3import 'package:server/converter_service.dart';
    4import 'package:server/greeter_service.dart';
    5import 'package:server/time_notifier_service.dart';
    6import 'package:server/word_counter_service.dart';
    7
    8Future<void> main(List<String> args) async {
    9  final server = Server(
    10    [
    11      GreeterService(),
    12      ConverterService(),
    13      TimeNotifierService(),
    14      WordCounterService(),
    15      ChatConnecterService(),  <-- 追加
    16    ],
    17    const <Interceptor>[],
    18    CodecRegistry(codecs: const [GzipCodec(), IdentityCodec()]),
    19  );
    20  await server.serve(port: 50051);
    21  print('Server listening on port ${server.port}...');
    22}

    これでサーバー側の実装および準備は完了です。

    アプリ側

    サーバー側の準備が完了したので、次はアプリ側の実装を行っていきます。

    まずは、アプリ側からメッセージを流すためのStreamを管理するControllerを保持するProvider を実装します。

    1/// メッセージを流すStreamを管理するController
    2final messageStreamControllerProvider =
    3    Provider.autoDispose<StreamController<String>>((ref) {
    4  final controller = StreamController<String>();
    5  ref.onDispose(controller.close);
    6  return controller;
    7});

    次に取得したメッセージ一覧のStreamを保持するProvider を実装します。

    1/// 取得したメッセージ一覧
    2final messagesProvider =
    3    StreamProvider.autoDispose<List<Message>>((ref) async* {
    4  // サーバーへアクセスするためのチャンネルを取得
    5  final channel = ref.watch(channelProvider);
    6
    7  final streamController = ref.watch(messageStreamControllerProvider);
    8
    9  // クライアント作成
    10  final client = ChatConnecterClient(channel);
    11
    12  // ResponseStreamを取得
    13  final responseStream = client.connect(streamController.stream
    14      .map((message) => ChatConnectRequest(message: message)));
    15
    16  await for (final response in responseStream) {
    17    // Streamが流れるごとにメッセージ一覧を返却
    18    yield response.messages;
    19  }
    20});

    最後に表示するWidget を作成します。

    1class ChatPage extends HookWidget {
    2  const ChatPage({super.key});
    3
    4  @override
    5  Widget build(BuildContext context) {
    6    final textController = useTextEditingController();
    7    return Scaffold(
    8      appBar: AppBar(title: const Text('Bidirectional streaming RPC')),
    9      body: SafeArea(
    10        child: Padding(
    11          padding: const EdgeInsets.symmetric(horizontal: 32),
    12          child: Column(
    13            children: [
    14              Expanded(
    15                child: Consumer(builder: (context, ref, child) {
    16                  final messages =
    17                      ref.watch(messagesProvider).valueOrNull ?? [];
    18                  return ListView.separated(
    19                    itemBuilder: ((context, index) {
    20                      final message = messages[index];
    21                      return Column(
    22                        crossAxisAlignment: CrossAxisAlignment.start,
    23                        children: [
    24                          Row(
    25                            children: [
    26                              const Expanded(child: Text('匿名さん')),
    27                              Text(
    28                                '${DateTime.fromMillisecondsSinceEpoch(message.unixTime.toInt())}',
    29                              ),
    30                            ],
    31                          ),
    32                          Text(message.message),
    33                        ],
    34                      );
    35                    }),
    36                    separatorBuilder: ((context, index) {
    37                      return const Padding(
    38                        padding: EdgeInsets.symmetric(vertical: 8.0),
    39                        child: Divider(),
    40                      );
    41                    }),
    42                    itemCount: messages.length,
    43                  );
    44                }),
    45              ),
    46              const Divider(
    47                color: Colors.blue,
    48              ),
    49              Consumer(
    50                builder: (context, ref, child) {
    51                  return Row(
    52                    children: [
    53                      Expanded(
    54                        child: TextField(
    55                          controller: textController,
    56                        ),
    57                      ),
    58                      ElevatedButton(
    59                        onPressed: () {
    60                          ref
    61                              .read(messageStreamControllerProvider)
    62                              .add(textController.text);
    63                          textController.clear();
    64                        },
    65                        child: const Text('送信'),
    66                      ),
    67                    ],
    68                  );
    69                },
    70              ),
    71            ],
    72          ),
    73        ),
    74      ),
    75    );
    76  }
    77}

    以上でアプリ側の実装は完了です。

    動作確認

    それでは動作確認をしてみます。

    それぞれの端末でメッセージを送信してみると…

    それぞれの端末でメッセージの送受信が確認できました!

    まとめ

    本記事ではgRPCの下記4つの通信方式を使ってFlutterアプリ内でAPI通信を行う実装をまとめました。

    • Unary RPC (単一リクエスト, 単一レスポンス)
    • Server streaming RPC (単一リクエスト, 複数レスポンス)
    • Client streaming RPC (複数リクエスト, 単一レスポンス)
    • Bidirectional streaming RPC (複数リクエスト, 複数レスポンス)

    各機能がシンプルなものだったのもありますが、Streamを使った機能がサクッと実装できました。

    gRPCをFlutterアプリで導入しようと考えている方の参考になれば幸いです。

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    広告メディア事業部

    広告メディア事業部

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background