【Flutter】Google Cloud(GCS)をデータサーバーにし、更新があった際だけhttpでDLする

しばらく記事を書いていませんでしたが、flutter を新たに学び始めてました。
レイヤが違うので単純には比べられませんが、個人的には rails に比べドツボってしまうことが少なく
手元のスマホをPCに繋いですぐに動かしてみたりできるので楽しいです。
バックエンドに比べるとフロントの人手はそこまで逼迫していない――といった話を時折見聞きしますが、その理由が何となく分かったような気がします。笑

\- - - - -

閑話休題。そんな flutter 初心者が、データに変更があった際だけDLする実装に挑んだ際に
単純に静的ページ・ファイルを配信するだけのWebサーバーは立てても借りてもいないので Google Cloud の Storage を使えないか試みたところ、
最終的に成功したもののそこに至るまで躓きポイントも2～3あったのでその記録を残しておきます。


## 環境及び前提

```console:$ flutter doctor
[√] Flutter (Channel stable, 3.24.4, on Microsoft Windows [Version 10.0.19045.5371], locale ja-JP)
[√] Windows Version (Installed version of Windows is version 10 or higher)
[√] Android toolchain - develop for Android devices (Android SDK version 33.0.1)
[√] Chrome - develop for the web
[√] Visual Studio - develop Windows apps (Visual Studio Community 2022 17.3.6)
[!] Android Studio (version 2021.3)
    X Unable to determine bundled Java version.
[√] Android Studio (version 2024.1)
[√] VS Code (version 1.96.3)
[√] VS Code, 64-bit edition (version 1.64.2)
[√] Connected device (3 available)
[√] Network resources
```

Google Cloud のアカウントおよび有効（課金も含め）なプロジェクトは既にあるものとします（ない場合は作成・有効化してください。手順は本記事では割愛します）。

データは json 形式で1ファイルとし、それとは別にデータ更新日時のみを記したテキストファイルも配します。
後者はアプリ起動時毎回必ず読み込み、端末内のデータ保存日時より後の場合のみ json データファイルをDL・読み込んで端末に上書き保存する[^1]構成とします。


## 手順

### 1. Cloud Storage に必要なファイルをアップロード

https://cloud.google.com/storage/docs/discover-object-storage-console?hl=ja

[Cloud Storage のコンソール](https://console.cloud.google.com/storage/browser) から、上記公式ドキュメント記載手順のうち下記3項目をその通りに行えば大丈夫でした。

* バケットの作成
* バケットにオブジェクトをアップロード
* オブジェクトの共有（アクセス権設定）

:::note warn
一つ注意点があるとすれば、バケットを作成しファイルをアップロードしただけでファイルのメニューや詳細画面に「認証済みURL」が表示されますが、
「認証済み」とある通り 認証されたアカウント[^2]でないとアクセスできないURLなので
このURLを公開したりアプリに組み込んでもファイルにアクセスすることはできません。
必ず「オブジェクトの共有」まで設定を行ってください。
なお当たり前の話ですが、設定を行うことで誰でも見れるようになるのでファイルの内容的にそれが問題ないかは十分吟味の上行ってください。
:::

### 2. ファイルの公開URLを取得

前項の手順が完了していれば、コンソールでバケットのファイル一覧を表示した際「公開アクセス」の列が下記画像のようになります。
この「**URLをコピー**」をクリックして得られるのが誰でもアクセスできるURLなので、アプリからもこれでアクセスします。
![GCS_URL.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688478/9b6f475e-d700-6fa4-fc51-ce8fcb64814a.png)

### 2-1. アプリからhttpリクエスト：データ更新日時

:::note info
flutterアプリからのアクセス・ダウンロードには一番シンプルに [http](https://pub.dev/packages/http) パッケージを用いています。
利用パッケージが異なる場合は適宜読み替えてください[^3]
:::

処理の流れとしては前提の項に記載の通り、まずデータ更新日時を取得し端末のデータ保存日時と比較します。

```dart
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';

const host = 'storage.googleapis.com';

// host + それ以降(第2引数)が GCS コンソールから得た公開URL
final res = await http.get(Uri.https(host, 'your-bucket-name/updated_at.txt')));
final updatedAt = DateTime.tryParse(
  res.statusCode == 200 ? res.body.trim() : ''  // ←躓きポイントその1対処
) ?? DateTime.fromMillisecondsSinceEpoch(0);
// 取得・パース失敗時は更新日時は古いものとし🔼
// ローカルに保存済データファイルがあればそれを優先

final localDir = await getApplicationDocumentsDirectory();
final dataFile = File('${localDir.path}/dataFileName'));
if (dataFile.existsSync() && dataFile.lastModifiedSync().isAfter(updatedAt)) {
  // ローカルに存在するデータファイルの保存日時が
  // データ更新日時より後ならそこからデータ読出し
} else {
  // 更新日時の方が新しい、もしくはローカルにデータファイルが
  // 存在しない場合はデータをサーバーから取得、端末内に保存
}
```

データ更新日時ファイル `updated_at.txt` の内容はシンプルに `YYYY-MM-DD HH:mm(:ss)` を記しただけ。
これを http で GET し、レスポンスの `body` をそのまま `DateTime` へパースします――が、末尾に改行を入れていたりするとパースに失敗するのでご注意ください（私の躓きポイントその1）。
`trim()` で空白除去してからパースすれば問題ないですが、うっかりを確実に回避するにはこれだけの内容のファイルでも手抜きせずにjsonやYAMLにするべきかもしれません。

### 2-2. アプリからhttpリクエスト：jsonデータ本体

端末内にデータファイルが存在しない、もしくは保存日時が更新日時より古い場合は json データファイルを http で GET して読み込みますが
ここで要注意なのが2byte文字も含むデータを素直にアップロード➡ `response.body` で取得、とだけやると http パッケージが勝手に文字コードを変換し文字化けを起こしてしまうことです。
その主因が http パッケージ側だとは当初予想だにしなかったので、下記の参考記事が見つかるまで詰んでました…（躓きポイントその2）

https://qiita.com/takyam/items/98d6336f1b2fe912fd56

こちらも回避策は2つあり、アプリのコード側でレスポンスを文字列と見做してしまわないように取得・処理する：
具体的には `response.body` の代わりに `response.bodyBytes` で `Uint8List` 型の生バイト列を取得してからUTF-8文字列にデコードするか🔽、
```dart
import 'dart:convert';  // utf8.decode に必要
import 'package:http/http.dart' as http;

const host = 'storage.googleapis.com';

// host + それ以降(第2引数)が GCS コンソールから得た公開URL
final res = await http.get(Uri.https(host, 'your-bucket-name/your_datafile_name')));
if (res.statusCode == 200) {
  String jsonData = utf8.decode(res.bodyBytes);  // ←躓きポイントその2対処
} else {
  // エラー処理
}
```

或いはGCS側で文字コードを厳密に指定する：
具体的にはオブジェクト（ファイル）＞「メタデータを編集」で下記のように設定することでも回避できました。
![GCS_MetaData.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3688478/0ff32d93-69a9-03da-ad34-627f50228004.png)

:::note warn
`text/html` を `application/json` とするだけで大丈夫（`charset`は不要）という情報もどこかで見かけたので試しましたが上手く行きませんでした
:::

対処法として明らかに然るべきなのは後者で、メタデータのコントロールが可能な環境（勿論GCSも含む）ならそれで回避すべきですが
何らかの理由でコントロールできない環境も考慮する必要がありそうなら前者の対処も入れておけば文字化けの恐れを完全に排除できるんじゃないかと思います（両方併存させても、少なくともGCSでは特に問題はありませんでした）。


## ソース全文

<details>

```dart:repository.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';

// DataModel は fromJson 実装を持つモデルクラス 詳細は割愛
Future<DataModel> load() async {
  const host = 'storage.googleapis.com';
  const bucketName = 'your-bucket-name';
  const dataFileName = 'your_data.json';

  final resUpdatedAt = await http.get(
    Uri.https(host, '$bucketName/updated_at.txt')
  );
  final updatedAt = DateTime.tryParse(
    resUpdatedAt.statusCode == 200 ? resUpdatedAt.body.trim() : ''
  ) ?? DateTime.fromMillisecondsSinceEpoch(0);

  final localDir = await getApplicationDocumentsDirectory();
  final dataFile = File('${localDir.path}/$dataFileName');

  String jsonData = '[]';
  if (dataFile.existsSync() && dataFile.lastModifiedSync().isAfter(updatedAt)) {
    jsonData = dataFile.readAsStringSync();
  } else {
    final resJson = await http.get(
      Uri.https(host, '$bucketName/$dataFileName')
    );
    if (resJson.statusCode == 200) {
      jsonData = utf8.decode(resJson.bodyBytes);
      dataFile.writeAsStringSync(jsonData);
    } else {
      // エラー処理
    }
  }

  return DataModel.fromJson(jsonDecode(jsonData));
  // List なら jsonDecode(jsonData).map<DataModel>((j) => DataModel.fromJson(j)).toList() とか
}
```

</details>

[^1]: もちろん端末内にデータが保存されていない場合も同様
[^2]: 特に何も設定していない場合オーナーのみ
[^3]: 後述の躓きポイントその2は http パッケージ固有事象のようなので、パッケージが異なる場合そもそも無縁かもしれません