個人開発メモ
個人開発を始めたいなーと思いつつ、中々始めていなかったので記録をつけながら進めていく。
作るものは、サプリメントを管理できるWebアプリ。
在庫管理系のアプリは探せばある程度出てきそうだけど、あくまで個人開発なのでそこは考慮しない。
一旦ローカルで動くものが大体できてきてからクラウドに移す。
既にある程度作っているので、追いつくまでその過程を思い出しながら書いていく。
バックエンド側技術スタック
- Kotlin
- 自分の使用できる言語は、Java, Kotlin。Kotlinの方が記述が簡潔に書けるためこちらを採用。
- Spring Boot(Spring MVC)
- Kotlinのフレームワークの中で使用したことのあるものであり、開発に慣れているため。
- 作成するアプリの性質として、同期的な処理で問題なく、WebFluxを使用しなくても良いと判断。必要になったら移行を行う。
- PostgreSQL
- MySQLかPostgreSQLかで検討。書き込み処理より読み取り処理の方が多そうだが、アプリの規模的にそこまで考慮しなくて良いと判断。使用経験の長いPostgreSQLを採用。
- jOOQ
- jOOQかJPAかで検討。どちらも使用したことがあるため。jOOQのスキーマからのコード生成、SQLをコードとして記述できる点からjOOQの方を採用。
フロントエンド側技術スタック
- React
- Next.js
- TypeScript
業務でフロントエンドも行うようになったため、キャッチアップのため上記を選択。
バックエンド側のアーキテクチャは、アプリの規模も踏まえ基本的にDDD,オニオンアーキテクチャベースとする(クリーンアーキテクチャでなくても良いと判断)。
バックエンドのコードをある程度書いた時点で、バックエンドとフロントエンドの開発をスムーズに行うため、スキーマ駆動開発を行うことに。
Open API仕様でドキュメントを作成し、その仕様をもとにバックエンド、フロントエンドのコードを生成する。
API用のリポジトリを作成し、PRがマージされた時点でGitHub Actionsによってコードを自動生成、GitHub Packagesで公開する。
それをバックエンド、フロントエンドで依存関係に追加し、使用する。
アプリケーションの要件
- 今自分の所持しているサプリの登録
- サプリの量や使用量からいつまでの分があるかを確認したい
- 名前やブランドが異なるが、分類としては同じサプリの集まりをサプリグループとする
- 同じサプリグループのサプリの残り使用可能日数は合算して使い切る予定の日付を算出する
- 消費期限が使い切り予定日より前の場合は警告などでわかりやすくする
- 使い切りそうになってきたサプリがある場合は通知する
- 何日前に通知するか設定できるようにする
- 画面上でビジュアル化してどこまであるかを見やすくしたい
- ビジュアル化する場合は、この日付まで保つような日付を設定し、それを超えていないかどうか見ただけでわかるような作りにする
- 一年あたりサプリにかかる金額をわかるようにする
- 金額はサプリ1つあたりの単位でよく、それより細かい単位で金額は出さなくて良い(1日あたり◯円だから1年あたり◯円かかるみたいにはしない)
一旦上記の機能が揃うように実装していく。
機能実装は一気に全部行わず、可能なものからやっていく。
バックエンドの開発基盤を整えていく。
Spring Boot 3.3.0
Spring Initializr を使用してプロジェクトを作成。
ビルドツールは、gradleを使用する。
プロジェクトの構成としてgradleのマルチモジュールを使用し、アプリケーション、マイグレーション用の2つのプロジェクトを用意した。
マイグレーションにはflywayを使用する。
PostgreSQL 16.3-alpine3.20
PostgreSQLはDocker上で起動できるようにdocker-compose.ymlを用意した。
DBの接続情報は一旦.envファイル経由で行うようにした。
PostgreSQLのセットアップはスムーズにできたが、flywayのマイグレーションで躓いてしまった。
原因は、flywayのバージョンとPostgreSQLのバージョンの相性が悪かったことだった。
どちらもできるだけ最新のバージョンで行おうとしたところ、マイグレーション時にNo database found to handle jdbc...
のエラーが発生。
flywayのライブラリ構成がバージョン10になってから変わったらしく(issueあり)、必要な依存関係やそれ専用のgradleの記載が必要だったのでマイグレーションが行えるようになるまでかなり時間がかかってしまった。
結局、PostgreSQL 16.3-alpine3.20をマイグレーションできるflywayのバージョンは10.11.0だった。
最新が10.14.0だったのでこちらで試して試行錯誤していたが、段階的にバージョンを落としていった結果、最初に正常に動いたバージョンは10.11.0だった。
また、上記のエラー解消にあたってgradleでのプロジェクト作成を一からやったことがないことも原因だった。
そもそもgradleがどういうものかという理解からどのようなプロジェクト構成ができるのか、といったことを地道に調べ理解した。
導入はこちらの記事を見て、マルチプロジェクトの構成方法に関しては、公式ドキュメントに目を通した。
本来であれば、buildSrcを使用した構成にしたかったが、エラーの解消に時間がかかりそうだったのと、アプリ構成の複雑さ的にsubprojects等を使用した構成でも問題ないだろうと判断し、後者で構成した。
buildSrcを使用した構成にしてみるのは、一段落してからの宿題。
普段の開発ではbuild.gradle.ktsに必要な依存関係を追加するぐらいの設定程度しか触れる機会がなかったが、これを機に理解が深まり、業務で開発しているアプリの構成への解像度がかなり上がった。
テーブル定義は現状、サプリメントテーブルと、サプリメントグループテーブルのみとした。
カラムはアプリ作成前に必要な情報をある程度ピックアップし、開発過程で必要になったものを随時追加していく(現状金額情報を持っていないため追加する必要あり)。
いずれはログイン機能も追加するつもりなのでその時にユーザーテーブルなども追加していく。
テーブル定義には、COMMENT ON
で論理名を記載。
バックエンドのAPI開発を粛々と行なっていく。
以下の機能まで一旦実装。
- サプリ登録
- サプリ一覧取得
- サプリグループ取得
- サプリ取得
- サプリ更新
- サプリメントグループ更新
- サプリ削除
- サプリメントグループ削除
フロントエンド開発との効率化を見据えてスキーマ駆動開発にシフトする。
API定義作成が完了し、自動コード生成の仕組みが整ってからバックエンド開発を再開する。
上記機能開発を行う過程で直面した問題を記載する。
PATCH更新難しいね問題
サプリ更新APIを作成している際にSpring MVC特有の問題に遭遇した。
詳しくは↓の記事を参照。
簡単にいうと、PTACH更新時に更新対象がnullableだと、nullで更新したいのか更新しないから引数に存在しないのかが判断できないというもの。
サプリ更新ではサプリに関する情報はnonNullのため問題ないが、サプリが属しているサプリメントグループの情報はnullableだったためこの問題に当てはまってしまっていた。
そのため、今回はどのサプリメントグループ属するかの情報は別のAPIで更新するようにした。
これによって紐付けを解除するときは値がない場合、紐づけたいときは値がある場合と判断できる。
書いていて気づいたが、サプリメントグループ更新だけだとサプリメントグループに対する更新処理を刺してしまうから名称を変えた方が良い。
エラーハンドリング時に無限ループ問題
更新処理などで対象のサプリがなかった場合にExceptionを発生させ、それをまとめてハンドリングするクラスによってレスポンスの返し方を制御しようとした時に発生した事象。
SpringのControllerAdviceの記載方法に問題があり、エラーが発生すると存在しないパスへルーティングしてしまうというもの。
スクラップ作成前に以下の記事を作成していたので詳細はそちらを参照。
スキーマ駆動開発をするにあたってAPI定義からAPIドキュメントを作成するツールの選定を行う。
API定義を作成するツールであれば、
- Swagger Editor
- Stoplight Studio
- vscodeで直接記載する
ような候補があるが、ミスなく早く記述するならStoplight Studioを使用するのが良さそう。
Stoplight Studioは前に使用してたけど、アカウントが必須になったあたりで使うのをやめた気がする。
それからはvscodeで編集してたけど、どっちがいいのだろう?
APIドキュメント生成ツールは、作成後の見た目が好きなReDocを使用してみる。
使用してみて辛い部分があれば再検討する。
SpringのアノテーションにRestControllerってあったのか!
JSONを返却する今回のアプリの用途ならRestControllerで良いのでこちらを使用する。
ControllerAdviceもRestControllerAdviceにする。
Stoplight StudioはAPI定義の作成効率はかなり上がるけと、x-stoplightの項目が勝手に付与されるのは微妙。
いちいちエクスポートしないといけないのが手間かな。
手で全部記述するのに比べると全然マシだけど。
pre-commitでStoplight CLIを使ってyamlをエクポートするようにすればGUIでエクスポートしなくて良くなるかも。
→Stoplight CLIでは出来なさそうだった。。。コマンドは実行できるけどバージョンが表示されるだけで何も起こらない状態だった。これ以上時間を割くのはもったいないので手動エクスポートでいいかな。
とりあえず実装済みのものに関してはAPI定義の記載は完了(コード生成ツール側の対応状況からOpenAPIのバージョンは3.0)。
API定義からコード生成に移る。
まずはバックエンド側のコードを生成する。
OpenAPI Generatorを使用する。
ここでもgradleが登場。
設定項目が多いから時間がかかりそうな予感が。
使用するgeneratorはkotlin-spring。
作成したAPI定義をプッシュしていなかったようでリポジトリに反映されていなかった。。。
Apidogを使用して再度作成する。
API定義を作成しようと思ったが、アプリのモデルに違和感があったため見直しを行なった。
これによって既存処理を一旦削除し、最小限のバックエンド処理を実装した。
バックエンド側は一旦ここまでとし、フロント側を作成しようと思う。
フロントエンド開発メモ
フロントエンド側の実装は、大体のデザインをBoltで作成し、それを改修する形で実装していった。
Boltの生成結果では適切なコンポーネント分割がされていなかったため、コンポーネントにできるものはある程度コンポーネント化した。
この段階でローカルでバックエンドとの繋ぎこみを行い、疎通の確認ができたのでvercelにフロント側のみデプロイした。
バックエンドとフロントエンドのデプロイ先選定
選定ポイントとしては、なるべく費用を抑えることを第一とする。
バックエンド
feloを使用して情報を収集してみたところ、GCPの永久無料枠を利用することでコストを抑えられそう。
- アプリ:Cloud Run
- DB:Compute Engine
を使用し、サーバーレス VPC アクセスで接続する方法が良さそう。
フロントエンド
フロントエンドもCloud Runにすればバックエンドとのネットワーク設定が楽そうなので一旦Cloud Runで進める。
デプロイはCloud Buildで良さそう。
GCPのClound Runへフロント・バックエンドアプリをデプロイ
コンテナイメージのビルド
Clound Runへデプロイするにはコンテナイメージを作成する必要があり、なるべく費用を抑えるため、Artifact Registryは使用せず、Docker Hubのイメージを使用する方法を採用。
GCPの無料枠の使用量上限に1ヶ月あたり0.5GBまでは無料と記載があるのでこちらでも良かったかな。
ここで問題が発生、それはdockerイメージのビルドをapple siliconのPCで行ってしまったため、GCP上の実行環境とアーキテクチャが異なりExec Format Errorが発生してしまった。
この対処としてビルド時に--platform linux/amd64
オプションを付ける必要があった。
ビルド時間の増大
--platform linux/amd64
オプションを付けたことでローカルでのビルド時間がかなり長くなってしまった。
そのため、Docker Hubのイメージを使用する方法からGitHubのリポジトリを使用する方法へ移行した。
この方法ではリポジトリ直下にDockerfileがあれば良いためDocker Hubのイメージの時のDockerfileを流用してデプロイを試みた。
フロントエンド側は問題なくデプロイ・接続できることを確認した。
バックエンドのビルド失敗
GitHubのリポジトリを使用する方法でバックエンドもデプロイしたが、gradleのビルド時にエラーが発生してしまった。
原因は、gradle-wrapper.jar
がgitignore対象(正規表現)になっていたためだった。
これに関してはignore対象から外すことでエラーは発生しなくなった。
しかし、Cloud Buildでのデプロイで再度エラーが発生した。
このエラーに関してはDBをデプロイし、接続可能な状態にしてから解消することにした。
DBのデプロイ
Compute Engineでubuntu上にdockerをインストールし、compose.ymlを元にDBコンテナの起動を確認。
各サービスを連携させるためにサーバーレス VPC アクセスの設定を行う
サーバーレスVPCの設定を行っている際にある問題が浮上した。
サーバーレスVPCコネクタの料金がなかなかするということだった。。。
デプロイ先を再検討するため、一旦白紙に戻すことにした。
デプロイ先の再選定
以下の記事を元に接続先を再検討した。
構成
各環境を以下のサービスにデプロイする。
- フロントエンド:Vercel
- バックエンド:Render
- DB:Supabase
再デプロイ
フロントエンド
元々Vercelにデプロイしていたため、デプロイ構成の変更はなし。
Nextの環境変数設定
バックエンドAPIのURLに関する環境変数のプレフィックスにNEXT_PUBLIC_
がついていないとundefined
になるのは知らなかった。
早期リターンの記述位置
React Queryを使用しているコンポーネントで、早期リターンをフックの宣言より前に記述しておりエラーとなってしまった。
これの特定にあたり、NextのSourceMapsを有効にした(ローカルでも確認できたので不要だったのかも?)。
バックエンド
デプロイまで一番時間がかかった。
DBとの疎通
起動時にDBに接続できずエラーとなった。
環境変数経由で接続情報が微妙に異なっていたのが原因だった。
設定内容はSupabaseに記載されている通りに記述する必要があるので注意。
記載内容がちゃんと提示されているのは丁寧だと思った。
ヘルスチェック対応
Renderでは起動時にヘルスチェックをしており、ヘルスチェック用の設定をSpring Boot Acutuatorを使用して行った。
通常のコントローラーでも良かったが、Render以外での使用も考えAcutuatorを使用した。
また、ポートも10000に固定されているようで、環境変数からアプリのポートを指定する必要があった。
Flywayのマイグレーション
デプロイ時に毎回マイグレーションが走るため、毎回テーブルを削除する必要があった。
これについてはマイグレーション用のSQLを修正して対応した。
DB
SupabaseはGUIからの設定や直接SQLを流し込むこともできるが、今回はアプリ側からのマイグレーションによってテーブルを作成したのでDBを作成するだけで良かった。
上記によって、とりあえず最低限の機能で動くものを作るという目的を果たしたので、さらに機能追加を行なっていく。
機能充足に凝ってしまうより早期リリースが優先!
デプロイに関しては、紆余曲折があったがいい経験になった。
静的ウェブサイトで作りたいと思っているステロイド外用剤一覧を作るときはCloudflare Pagesでできるかもしれない。
デプロイ後の挙動監視
バックエンド停止問題
Renderにデプロイしたバックエンドアプリが一定時間使用していないと停止することを確認。
これでは困るため、バックエンドだけCloud Runを使用するのが良いのかもしれない。
Cloud RunにSpring Bootのバックエンドをデプロイした時の躓きポイント
ヘルスチェックの設定が厳しすぎる
初回ビルド時は時間がかかるが、Cloud Runのヘルスチェックの設定が厳しすぎてデプロイ中に落ちる現象が発生。
ログでは特定しづらく、急に落ちるという手掛かりからヘルスチェックが原因であることがわかった。
ヘルスチェックの初回遅延を最大まで伸ばし、タイムアウトやタイムアウトの閾値も多めにしておくことでデプロイできるようになった。
デプロイ後のアプリが503を返す
デプロイが完了し、ログからも起動していることがわかっていたが、直接アクセスしたり、Vercel上のフロントエンドからアクセスすると、なぜか503エラーになってしまった。
この原因については検索してヒットするものはなかった。
最終的に判明した原因は、ネットワーキングの設定でHTTP/2が有効になっていたからだった。
Spring Boot(Tomcat)ではデフォルトではHTTP/2は有効ではないため、503となってしまった。
これに関してはアプリ側にヒントとなるログすら出力されないため、特定に時間がかかった。
そもそもCloud RunにSpring Bootのアプリが不適切なのではないかと疑問に思うほど悩んでしまった。
改めてネットワーク関連の知識の足りなさが浮き彫りになったいい事象。
フロントエンド機能追加
conformによるフォームのバリデーションを追加
業務ではReact Hook Formを使用していたが、NextのServer Actionsに対応しているconformを使用することにした。
今回はServer Actionsの恩恵は受けれない構成だが、フォーム系ライブラリに慣れるという意図もあり使い方を調べながら実装した。
親子関係のコンポーネントへformの情報を伝播する部分を業務ではやっていなかったが、一から作ることでその点も踏まえた実装を経験できた。