我流Flutter学習ステップ(4)http

Date:

Share post:

本シリーズ(?)の趣旨は「Flutterを使用したWebアプリの開発」でして、API方式のLaravelバックエンドに対するフロントエンドを自力開発できるようになることが目的でした。
しかし、Flutterでの開発自体が初めてでしたし、まずは動作するものが実装できなければ話にならないので、先の3つの投稿ではごく基本的な構造および必須と思われるパッケージ(Riverpod, GoRouter, Freezed)の用法に関して学習して来ました。
上記結果、何となくFlutterの実装にも慣れて来ましたので、今回は満を持してバックエンドとの結合を考えたいと思います。

LaravelのAPIではHTTP通信を使用しますので、Flutter側でもHTTP通信を行えるようにする必要があります。
そのためのパッケージが「http」(そのまんま)です。

パッケージのインストール

早速「http」パッケージをインストールしてみます。

# flutter pub add http

特に追加すべき他のパッケージもないようなので、以上で終了です。

処理内容

HTTP通信に関する処理は、かなり定型的に考えられると思います。
GETに関してはURL、POSTに関してはURLおよびPOSTパラメータを指定したリクエストを送信し、レスポンスとして戻されるJSON形式のデータをデコードしてマップ型として扱えるようにする、と言った処理は通信内容に関わらず共通的に行うものですので、この辺を一つのクラスとして実装したいと思います。

なお、実装に際しては同クラスがアーキテクチャ的にはどのような意味を持つかと言う点も考えてみるべきかと思います。

そもそもWebアプリ(フロントエンド)単体で考えた場合、本当に実施したい事は「どこかに保存されている情報を取得してくる(GET)」「手元にある情報をどこかに保存しておく(POST)」と言う事です。
その意味では、今回の実装では情報の保管場所としてリモート(バックエンド)を選択していますが、ケースによってはローカル環境のファイルを使用するなど他の方法も考えられるかもしれません。
目的はあくまでデータの管理(保存、参照、更新、削除等)であり、HTTP通信は実現方法の一つに過ぎないと言う事です。

上記のように考えると、特定のデータに関する扱いに関しては、まずは同データの管理機能が存在し、HTTP通信機能部分は管理機能内に隠蔽されているような構造にすると扱いやすそうです。
このようにデータ管理において具体的な手段を隠蔽し、あくまで同管理機能の用法のみを意識すれば良いように設計された機能群のことを一般的には「リポジトリ」と呼んだりしますので、今回の実装でもこれらの機能を「リポジトリ」に位置付けます。

以下、具体的に実装を行いますが、記載量の関係上、今回は下記のように仕様を制限したいと思います。

  • 今回対象とするHTTPメソッドはGETのみ
  • 扱うデータはユーザー情報とし、実装する機能はその一覧を取得する機能とする
  • 上記機能(アクション)のバックエンド(sample.com)におけるパスは「user/index」とする
  • ユーザー情報の管理機能は「UserRepository」クラス(lib/repositories/user_repository.dart)として実装
  • HTTP通信を行う機能は「ApiClient」クラス(lib/repositories/api_client.dart)として実装
  • 「ApiClient」クラスはリポジトリ(lib/repositories配下)からのみ利用可能とする

HTTP通信処理(lib/repositories/api_client.dart)

まずは、HTTP通信を行う共通処理「ApiClient」クラスを実装します。

import 'package:http/http.dart' as http;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'dart:io'; // SocketExceptionの定義
import 'dart:convert'; // JSONデコードに必要

import 'package:sample/constants.dart';

final apiClientProvider = Provider<ApiClient>((ref) => ApiClient(ref));

class ApiClient {
  ApiClient(this.ref);
  final Ref ref;

  Future<Map<String, dynamic>> get(String path) {
    final url = _getUrl(path);
    final headers = _getHeaders();
    return _httpRequest(() => http.get(url, headers: headers));
  }

  Uri _getUrl(String path) {
    const domain = ApiClientConstants.domain;
    return Uri.parse('$domain/api/$path');
  }

  Map<String, String> _getHeaders() {
    return {
      'content-type': 'application/json',
    };
  }

  Future<Map<String, dynamic>> _httpRequest(Future<http.Response> Function() reqFunc) async {
    http.Response jsonResponse;
    try {
      jsonResponse = await reqFunc();
    } on SocketException {
      throw Exception('ネットワークに接続できません');
    }
    return json.decode(jsonResponse.body);
  }
}

全てを解説すると膨大になるので要点のみ触れて行きます。

_httpRequest

直接的に通信を行っているのは_httpRequestメソッド内の下記行です。

jsonResponse = await reqFunc();

ただ、実際に実行されるのは引数「reqFunc」として渡されてくる関数です。
今回実行される関数は以下です。

() => http.get(url, headers: headers)

httpパッケージのgetメソッドを呼び出すだけの無名関数です。
このように実行すべきHTTPリクエストの内容を関数化し、引数で指定させることで、他のパターン(例えばPOST等)に対しても本メソッドが共通的に利用できるようになっています。

なお、http.getメソッドは「Future<Response>」型の値を返します。
Futureは非同期処理を扱うデータ型です。
同型の戻りを持つメソッドは、メソッド内で実行した非同期処理の完了を待ち合わせず完了します。
つまり、メソッドが完了した段階で、メソッド内で実行した非同期処理は未完了の可能性があることになります。
上記関係でFuture型のインスタンスでは対象となった非同期処理に関する「未完了」「完了」の状態を持ち、その状態が「完了」になるのを待ち合わせるための方法が提供されています。
その一つが上記例にある「await」を使用する方法です。
これは文字通りその場で非同期処理の完了を待ち合わせるものです(強制的に同期処理に戻していると言っても良いかもしれません)。
別の方法としては「then」により完了時の処理をクロージャとして指定する方法もあります。
先に例示した処理を「then」を使用する方法に書き換えると以下のようになります。

reqFunc().then((jsonResponse) {
  // 非同期処理完了後の処理(1)
}).catchError((error) {
  // 例外処理
});
// 非同期処理完了を待ち合わせずに実行できる処理(2)

上記の処理で特徴的な点は、reqFuncの完了を待ち合わせずに処理を継続できる点です。
上記例では記述順序とは逆に(2)の処理の方が先行して実施され、非同期処理が完了した段階で(1)が実行されます。
上記特性は、非同期処理における待ち合わせ時間を有効に使える点は良いですが、処理のコンテキスト(文脈)を追いにくくなる印象はあります。
今回はGETの応答待ち合わせ中に並行して実施したい処理もありませんので、単純にawaitで待ち合わせています。

http.getの戻りは「http.Response」型のオブジェクトですが、その「body」プロパティにバックエンドから戻されたJSONデータが格納されています。
最終的には上記JSONデータをデコードしたマップ型データ(Map<String, dynamic>)を返しますが、_httpRequestは前述のように非同期処理を扱っていることから当該メソッドも非同期(async宣言が必要)となり、戻り値もFuture型(Future<Map<String, dynamic>>)にする必要があります。

以上のように、_httpRequestは引数で指定された、HTTPリクエストを内包する無名関数を実行し、レスポンスに含まれるJSONのデコード結果(マップ型)を返すメソッドです。

なお、_httpRequestは先頭がアンダースコアになっていますが、これはFlutter(Dart)の書式としては当該メソッドがプライベート要素であることを意味します。
つまり、_httpRequestはApiClientクラス内からしか利用できないことになります。
_httpRequestはApliClientクラス内のメソッドから利用され、実際のHTTP通信を実行する役割を担います。

get

ApiClientクラスとしてGETリクエストの実行機能を提供するためのパブリックメソッドがgetです。

getは引数としてアクセス対象となるパス(アクション)を指定させます。ただ、http.getの引数としてはUri型のデータを渡す必要があるので、_getUrlメソッドを用いて指定されたパスをUri型のデータに変換しています。

なお、_getUrlでバックエンドのURLを生成する際のドメイン名に関しては当該処理内に直接記述せずに別ファイル「constants.dart」で定義しています。

class ApiClientConstants {
  static const domain = 'https://sample.com';
}

接続先は開発時と本番運用時では別になりますし、このようにチューニングが必要な値は「constants.dart」で一元管理しておくのが良いと思っています(Laravelの.envのようなものですね)。

実際のHTTP通信に関しては前述した_httpRequestを用いて実行しますが、その際の実行内容は無名関数としてget側が指定しています(この点は_httpRequestで触れた通りです)。

getは戻り値として_httpRequestの戻り値をそのまま返します。
よって自動的に同メソッドの型は「Future<Map<String, dynamic>>」になります。

apiClientProvider

ApiClientクラスの実装に関しては上記の通りですが、このクラスをどのように使用させるかを考える必要があります。
利用する度にインスタンスを生成するのは無駄なので、RiverpodのProviderとして実装しておくのが良さそうです。
このようにすることで、当該クラスをシングルトンのように扱えます。

final apiClientProvider = Provider<ApiClient>((ref) => ApiClient(ref));

ユーザ情報管理(lib/repositories/user_repository.dart)

ApiClientは汎用的なHTTP通信機能ですが、外部インタフェース上に同特性が出てしまっており(ApiClient.getの引数が実行対象となるバックエンドのアクションである点など)、リポジトリにおける永続化手段の隠蔽という目的を達成できていません。

よって、今回対象とするユーザー情報の管理に特化したリポジトリ「UserRepository」を別に用意し、ApiClientは前述のリポジトリ内から間接的に使用する形にします。

import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'api_client.dart';

final userRepositoryProvider = Provider<UserRepository>((ref) => UserRepository(ref));

class UserRepository {
  UserRepository(this.ref);
  final Ref ref;

  Future<Map<String, dynamic>> index() async {
    const path = 'user/index';
    final result = await ref.read(apiClientProvider).get(path);
    // resultの内容に関するチェックや加工
    return result;
  }
}

indexメソッドはユーザー情報の一覧を取得するためのメソッドです。
内部的にはapiClientProviderで管理されるApiClientクラスのgetメソッドを呼び出してバックエンドからユーザー情報の一覧を取得します。
バックエンドとしてユーザー情報の一覧を返すアクションは「user/index」であり、これをApiClient.getメソッドの引数として渡しています。

なお、ApiClient.getメソッドの戻りはFuture<Map<String, dynamic>>型であり、同メソッド完了段階では通信結果が取得できていない可能性が大です。
よって、awaitで通信完了を待ち合わせ、取得した結果を以降の処理で利用できるようにしています。
また、上記結果としてindexメソッド自体に非同期性が生じる(async宣言が必要となる)ので、同メソッドの戻り値もFuture<Map<String, dynamic>>型になります。

UserRepositoryの用法に関してはApiClientと同様にRiverpodのProviderとして実装し、ユーザー情報の操作を行う局面で使い回せるようにしています。
具体的な使用方法は以下のようになるかと思います。

final response = await ref.read(userRepositoryProvider).index();

まとめ

改めて内容を俯瞰すると、HTTP通信に関する説明と言うよりも、非同期処理に関する説明の方に多くの労力を割いているように思います。

私も若い頃はUNIXにおける通信系の機能を10年以上に渡って担当していましたが、OS内の通信機能においては、送信と受信は全く別物です。
HTTP通信においてはHTTPリクエストの送信からHTTPレスポンスの受信までを一連の動作として考えますが、下位の通信機能にとっては、あくまで「データの送信」と「データの受信」と言う異なる動作がそれぞれ行われただけです。
その点を考えれば、HTTP通信が非同期的なインタフェース(まずは送信処理だけ行っておいて、応答が返された段階で改めて受信処理を行えるような形態)で実装されていると言うのは合理的なことと言えます。
実際には本記事で何度か例示したように、非同期処理呼び出し時に合わせてawaitを実行し、実質的には同期処理とあまり変わらない用法にしてしまっていますが、この辺は利用者側の判断であって、その選択権が与えられていることに意味があると思っています。

いずれにしても、とりあえずはバックエンドとの通信(今回はGETのみですが)を実装できたので、これで「Webアプリ」としての開発を進めるための技術的な材料が一通り整ったと言っても良いでしょう。
ただ、今回の説明で端折った(誤魔化した)点があります。
それは、HTTP通信の実装に際しての動作検証が面倒であると言う点です。
今回実装したHTTP通信に関しても、実際に動かしてみるためには、まずは同機能を呼び出す側の処理を記述する必要がありますし、HTTP通信相手であるバックエンド側の実装も必要です。
それらを揃えなければ通信に関する機能の検証ができないのは、かなりの足枷です。

バックエンドでも同様に外部APIに依存するような機能の動作検証をどのように行うかという問題はありますが、Laravelではphpunitを使用した単体テスト環境と、一部機能をモックで代替する仕組みが提供されており、外部通信が関わるようなリポジトリ機能の動作検証も簡単に行えるようになっています。
実は、Flutterにおいても単体テスト環境やモックでの代替の仕組みは提供されているようです。
と言うことで、次回の投稿ではその辺に関して書いてみようと思います。

Related articles

Laravel Filamentを使用した管理画面...

前回Breezeをインストールしたこと...

Laravel Filamentを使用した管理画面...

前回、filamentでのリソース作成...

Laravel Filamentを使用した管理画面...

前回、Filamentをインストールし...

Laravel Filamentを使用した管理画面...

以前触ったことがあるLarav...