家系図アプリプロジェクト① Firebase HostingされたFlutterのWebアプリを作り、自動デプロイする

# はじめに
個人的に[家系図 by 名字由来net](https://apps.apple.com/jp/app/家系図-by-名字由来net-日本no-1-100万人/id698143107)というiOSアプリを使い、家系図を記録していますが、同アプリは無料会員では表示される広告が邪魔だったり、登録人数が50名までになってます...
そこで、個人開発でWebアプリを作ってみました。
仕事でよく使うFlutterやGoogle Cloudを使っていきます。
モチベーションを維持するため、アジャイル手法で最初は保存した家系図を見るだけの機能に絞ります。
色々手順の記載を割愛しているので、単なる作業ログ的な記事になってるかもしれません...

## 開発環境
**Flutter Web**: アプリのフロントエンドを実装
**Firestore**: 家系図データの保存
**Hosting**: Webアプリのホスティング

ちなみに、使用するPCはM1 ProのMac 14インチです。 
今回のアプリなら、さほど古くなければIntel Macでも大丈夫だと思います。

## IDEの選定
VS Codeをフォークして作られた話題の[Cursor](https://cursor.sh)を使います。 
課金はしません。無料プランの範囲で問題ないと思います。

## 環境構築
ローカル環境にFlutter SDKを入れます。
手順は割愛で...
Cursorの拡張機能にも[Dart](https://marketplace.visualstudio.com/items?itemName=Dart-Code.dart-code)と[Flutter](https://marketplace.visualstudio.com/items?itemName=Dart-Code.flutter)を入れておきます。

## GitHubレポジトリ作成
ここも手順割愛...
レポジトリ名は`family_tree`にしました。
Visibilityはpublicにしておきます。
（API Keyなどをコミットしないよう注意...）
最後にローカル環境の任意のフォルダ（私の場合は`~/Projects`）で`github clone <レポジトリのURL>`を実行します。

## Firebaseセットアップ
### Firebaseコンソール側
[Firebaseコンソール](https://console.firebase.google.com)から新たなFirebase プロジェクトを作成し、以下のプロダクトをセットアップします。
- Firestore Database
- Authentication  
  - 認証方法はシンプルにメールアドレスとパスワード
- Hosting
- Cloud Messaging
- Functions
- Storage

ちなみに、Cloud MessagingとFunctionsとStorageは今回は使いませんが、次のリリース以降で使う想定なので、ついでにセットアップしておきます。
Functionsは実は従量課金制のプランに加入する必要がありますが、どケチな私は予算を¥100に設定して、極力無料範囲内に収まるようにします...
セキュリティ保護ルールは「本番環境モード」を指定し、ロケーションはasia-northeast2(Osaka)を指定しました。

### ローカル環境側
Cursorの上記フォルダ配下（私の場合は`~/Projects/family_tree`）にて`firebase init`コマンドを実行し、以下を選択・入力します。（長いので折りたたみました）
<details>
<summary>質問と回答</summary>

- Which Firebase features do you want to set up for this directory? Press Space to select features?
  - "Firestore"
  - "Functions"
  - "Hosting" (Configure files for Firebase Hosting and (optionally) set up GitHub Action deploys)
  - "Storage"
- Please select an option
  - "Use an existing project"
- Select a default Firebase project for this directory  
  - 上記で Firebase プロジェクトを作成した時のプロジェクトを選択
- What file should be used for Firestore Rules?
  - "firestore.rules"
- What file should be used for Firestore indexes? 
  - "firestore.indexes.json"
- What language would you like to use to write Cloud Functions? 
  - "TypeScript"
- Do you want to use ESLint to catch probable bugs and enforce style? 
  - "Y"
- Do you want to install dependencies with npm now?
  - "Y"
- What do you want to use as your public directory?
  - "public"        
- Configure as a single-page app (rewrite all urls to /index.html)?
  - "N"
- Set up automatic builds and deploys with GitHub?   
  - "Y"
- For which GitHub repository would you like to set up a GitHub workflow? (format: user/repository) 
  - 上記で作成したGitHubレポジトリを指定する
- Set up the workflow to run a build script before every deploy? 
  - "y" 
- What script should be run before every deploy? 
  - npm ci && npm run build
- Set up automatic deployment to your site's live channel when a PR is merged?
  - "Y"
- What is the name of the GitHub branch associated with your site's live channel? 
  - "main"
- What file should be used for Storage Rules? 
  - "storage.rules"
    </details>

## Webアプリ作成
Cursorの上記フォルダ配下（私の場合は`~/Projects/family_tree`）にて、任意のgitブランチ（今回の場合は`feature/deploy_firebase_hosting`）を切り、`flutter create <プロジェクト名>`（今回の場合は`flutter create family_tree`）のコマンドを実行します。`README.md`のファイルは適当に編集しておきます。さらに`flutter pub add`コマンドでパッケージ追加します。
`main.dart`を開き、以下の`title`と`home`の部分を適当に修正します。
```dart:main.dart
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'みんなの家系図',
```
```dart:main.dart
      home: const MyHomePage(title: 'ホーム'),
```

`flutter run -d chrome`でローカルサーバー上でWebアプリを起動してみます。
![local.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/b323d3d5-ca28-5ba9-680e-a6922132ff0d.png)
まだデフォルトの画面ですが、Webアプリが起動できました。

続いて、Gitコミット＆プッシュすることで、上記firebase initで生成された`firebase-hosting-pull-request.yml`からGitHub Actionsが実行され、自動でFirebase Hostingにデプロイされます。
![スクリーンショット 2023-12-31 16.51.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/71de3158-eb26-5748-fd73-ff04be701718.png)

## コードレビュー
ここでmainにマージしますが、その前に生成AIによるコードレビューをしてもらいましょう。
本当はReviewDogなどを使ってPRに直接コメントしてもらいたいところですが、
OPENAIのAPI_KEYが必要だったりするので、どケチな私は、無料で使えるGPT-4を搭載したPerplexity AIにブランチの差分を取得したものを、以下のプロンプトに入れてレビューしてもらいました（コマンド例：`git diff main feature/deploy_firebase_hosting`）
```
あなたは OpenAIによって訓練された言語モデルです。
あなたの目的は、非常に経験豊富なソフトウェアエンジニアとして機能し、コードの一部を徹底的にレビューし、
以下のようなキーエリアを改善するためのコードスニペットを提案することです：
  - ロジック
  - セキュリティ
  - パフォーマンス
  - データ競合
  - 一貫性
  - エラー処理
  - 保守性
  - モジュール性
  - 複雑性
  - 最適化
  - ベストプラクティス: DRY, SOLID, KISS

些細なコードスタイルの問題や、コメント・ドキュメントの欠落についてはコメントしないでください。
重要な問題を特定し、解決して全体的なコード品質を向上させることを目指してくださいが、細かい問題は意図的に無視してください。
コメントはすべて日本語で記載してください。

レビューの対象は添付ファイルのコードです。これはFlutterとGoogle Cloudを使ったWebアプリのGitHubレポジトリのあるブランチ（feature/deploy_firebase_hosting）とmainブランチの差分を取得したものなので、気をつけてください。
```

引用元：https://gist.github.com/matsubo/ed1ee1de5093ca53e8cae50ea00ea310

今回は特に指摘はなさそうだったので、マージしちゃいます。

## 家系図データの用意
これが一番苦労しました。flutterのパッケージでいい感じに表示できるかと思ってたら、できない...一番使えそうだったのが[graphview](https://pub.dev/packages/graphview)でしたが、肝心の以下のような親子関係の表示ができなかったです...（[Issue](https://github.com/nabil6391/graphview/issues/51)にも挙がっているようです）
![スクリーンショット 2024-01-05 8.51.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/0a3651ee-0f9d-017d-93f7-10d7ec4f34ba.png)

仕方ないので、一旦[familytreemaker](https://github.com/adrienverge/familytreemaker)というPythonスクリプトで生成することにしました。最終的にはWeb上で家系図編集もできるようにしたいので、リアルタイムで生成するようにしたい。なので、Cloud Functions上で実装してみました。
とは言え、上記スクリプトはdot言語で書かれたテキストファイルから、Graphvizというグラフを描画できるツールを使って画像生成しており、Cloud Functionsにはdot コマンドを動かせる環境がないので、Cloud Functions に外部コマンドを持ち込みます。[^1]

### ローカル環境にPython3をインストールする
ここも面倒なので手順割愛で...（私はすでにPython 3.8をインストール済でした）

### dotファイルを生成する
本来はテキストファイルからdotファイルを生成する処理までCloud Functions上のPythonスクリプトに実装したいのですが、少しややこしそうなので、今回は予め生成させておきます。
ここでサンプルの家系データを用意しますが、実際には私の家系データを追加する予定ですが、流石にそうはいきません。今回は以下のサザエさんの家系図を利用します。

<details><summary>＜参考＞磯野家家系図</summary>

![kakeizu-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/1cd75c96-048c-7978-61b7-a2ee6a2b36d6.png)
</details>

<details><summary>＜参考＞石田家家系図</summary>

![kakeizu2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/a1aca8bc-4604-d624-8f5c-6507ece4113a.png)
</details>

上記の`familytreemaker`をローカル環境の任意のフォルダにCloneし、同レポジトリ内の`LouisXIVfamily.txt`を参考に、`family_tree.txt`という名前で以下のように磯野家の家系図書きます。

```txt:family_tree.txt
# This file is an example to show how to format a family.

# Two lines represent an union:
磯野 XX (id=namihei_father, M)
磯野 XXX (id=namihei_mother, F)
# Indented lines after the union represent children
	磯野 波平 (id=namihei, M, notes=54歳)
	磯野 海平 (id=umihei, M, notes=54歳)
	波平の妹 (id=namihei_sister, F)

石田 XX (id=fune_father, M)
石田 XXX (id=fune_mother, F)
	磯野 フネ (id=fune, F, notes=50ゥン歳)
	石田 鯛造 (id=taizou, M)

# Another union (2 parents + 3 children), father is one the previous union's children:
磯野 波平 (id=namihei)
磯野 フネ (id=fune, F, notes=50ゥン歳)
	フグ田 サザエ (id=sazae, F, notes=24歳)
	磯野 カツオ (id=katsuo, M, notes=11歳)
	磯野 ワカメ (id=wakame, F, notes=9歳)

# When several persons have the same name, ids can be used to differentiate them.
フグ田 サザエ (id=sazae)
フグ田 マスオ (id=masuo, M, notes=28歳)
	フグ田 タラオ (id=tarao, M, notes=3歳)
```

これを以下のコマンドでdotファイルを生成します。
```
./familytreemaker.py -a '磯野 XX' family_tree.txt > family_tree.dot
```

しかし、ここで問題が...
[同レポジトリのPR](https://github.com/adrienverge/familytreemaker/pull/4)で問題提起がされてるようですが、上記の「石田 XX」「石田 XXX」「石田 鯛造」が家系図からはみ出してしまっています。
![IsonoFamily.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/e1a1c12c-5701-7cc1-73eb-6ca1de30f3eb.png)

`-a '石田 XX'`のように引数を変えると以下のように表示されましたが、今度は磯野波平の兄弟や両親がはみ出してしまいました...
原理的には、指定した最上位の親の子孫とその配偶者の家系図しかちゃんと作れないようです。
![IshidaFamily.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/cdb847f3-bbc5-fe3a-db5c-16b53ea37d6a.png)

うーん、これを改善するのもややこしそうなので、次回以降でアプリ側で最上位の親を切り替えられるようにしようと思います。。

### Pythonで動かせるCloud Functionsの実行環境構築
dotファイルが用意できたので、次にローカル環境にPythonで動かせるCloud Functionsの実行環境を作ります。
Cursorのプロジェクトフォルダ配下（私の場合は`~/Projects/family_tree`）でmkdirコマンドで`functions_python`というフォルダを作成し、その配下に上記のdotファイルと[このレポジトリ](https://github.com/68user/gcp-cloud-functions-graphviz-sample)の`graphviz.tar`を配置し、`main.py`というスクリプトを以下のように実装します。

```python:main.py
#!/usr/bin/python3

import sys
from flask import escape
import subprocess
import os

dotfile = "family_tree.dot"

def run_dot(imgfile):
  os.system("tar xfp ./graphviz.tar -C /tmp")

  cmd = "/tmp/graphviz/bin/dot -Kdot -Tpng {} -o {}".format(dotfile, imgfile)
  res = subprocess.run(cmd.split(' '), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  print("returncode["+str(res.returncode)+"]", file=sys.stderr)

  buflist = str(res.stdout).split("\\n")
  for buf in buflist:
    print("stdout["+buf+"]", file=sys.stderr)
  buflist = str(res.stderr).split("\\n")
  for buf in buflist:
    print("stderr["+buf+"]", file=sys.stderr)

  if res.returncode != 0:
    raise Exception("dot failed. cmd["+cmd+"]")

def do_graphviz(request):
  imgfile = "/tmp/out.png"
  run_dot(imgfile)
  f = open(imgfile, "rb")
  headers = {"Access-Control-Allow-Origin": "*", "Content-type": "image/png"}
  return (f.read(), 200, headers)

if __name__ == '__main__':
	do_graphviz()
```

```ディレクトリ構造.
functions_python
├── family_tree.dot
├── graphviz.tar
└── main.py
```

以下のコマンドでCloud Functionsにデプロイできたら完成です。

```
gcloud functions deploy do_graphviz --runtime python310 --trigger-http;
```

一応[Google Cloudのコンソール](https://cloud.google.com/cloud-console?hl=ja)でちゃんとデプロイされてるか確認しておきます。
![スクリーンショット 2024-01-05 22.51.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/2a56b6e9-9dfe-2d5c-5b97-a9f27e35f1f6.png)


## 家系図表示
最後に上記サンプルデータをWebアプリ上に表示できるようにします。
`main.dart`を以下のように修正します。

```dart:main.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(const MyApp());
}

///
class MyApp extends StatelessWidget {
  ///
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'みんなの家系図',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'ホーム'),
    );
  }
}

///
class MyHomePage extends StatefulWidget {
  ///
  const MyHomePage({required this.title, super.key});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: InteractiveViewer(
        boundaryMargin: const EdgeInsets.all(20),
        minScale: 0.1,
        maxScale: 1.6,
        child: Center(
          child: FutureBuilder<Uint8List>(
            future: fetchImageData('do_graphviz'),
            builder: (BuildContext context, AsyncSnapshot<Uint8List> snapshot) {
              if (snapshot.connectionState == ConnectionState.waiting) {
                return const CircularProgressIndicator();
              } else if (snapshot.hasError) {
                return Text('Error: ${snapshot.error}');
              } else if (snapshot.hasData) {
                return Image.memory(snapshot.data!);
              } else {
                return const Text('No image data found');
              }
            },
          ),
        ),
      ),
    );
  }

  ///
  Future<Uint8List> fetchImageData() async {
    try {
      final response = await http.get(Uri.parse('https://us-central1-xxxxxx.cloudfunctions.net/do_graphviz'));
      if (response.statusCode == 200) {
        return response.bodyBytes;
      } else {
        throw Exception('Failed to load image data');
      }
    } catch (e) {
      throw Exception('fetchImageData error:$e');
    }
  }
}
```

`https://us-central1-xxxxxx.cloudfunctions.net/do_graphviz`の部分には、Google CloudコンソールのトリガータブのURLからコピペします。

![スクリーンショット 2024-01-05 23.20.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/5d733ea7-39b1-7b6b-b4f3-4a9119d8bee1.png)

さて、また`flutter run -d chrome`でアプリ起動してみます。

![スクリーンショット 2024-01-05 22.49.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3033378/05ef79fa-b99c-b702-da08-a7124346b077.png)

表示が何かおかしい...!!!
日本語の文字エンコーディングの問題？
それともdotファイルで日本語フォントを指定する必要がある...？
色々試行錯誤しましたが、時間かかりそうなので、また次回挑戦します...

[^1]:https://cloud-textbook.com/2542/#google_vignette