🧪「触って試せるポートフォリオ」へ育てる 第1段階
個人開発しているFlaskアプリを、
「見るだけのポートフォリオ」から「実際に触って試せるポートフォリオ」へ育てたい。
そう考えて、ゲストデモモードの開発を始めました。
今回の対象は、個人開発中の売上管理・分析アプリです。
現在は、
- 商品登録
- 日次売上入力
- 売上ランキング
- グラフ表示
- AIによる売上分析
などを実装しています。
ただし、これまでは管理者ログイン前提でした。
採用担当者やエンジニアの方に見てもらえても、自由に操作してもらうことはできません。
そこで、
ログイン情報を渡さなくても、ゲストとして商品登録や売上入力まで実際に試せるようにしたい
と考えました。
ところが、ここで一つ大きな問題に気付きました。
ゲストに書き込みを許可したら、既存の管理者データまで触られてしまうのでは?
Guestログイン画面を作る前に、
管理者データとGuestデータを分離できる土台
を作る必要があります。
結果として、第1段階ではGuest画面を一つも作りませんでした。
代わりに取り組んだのが、
既存の本番データを守るためのDataset設計とDB migration
です。
今回のゴール
今回の第1段階で目指したのは、
既存の管理者データをDatasetという領域へ所属させ、将来Guest Datasetを追加してもデータが混ざらない土台を作ること
です。
まだGuest機能そのものは実装しません。
まず現在のデータを、
PostgreSQL
│
└─ Admin Dataset
│
└─ Product
│
└─ DailySales
という形へ移します。
将来的には、
PostgreSQL
│
├─ Admin Dataset
│ └─ Product
│ └─ DailySales
│
├─ Guest Dataset A
│ └─ Product
│ └─ DailySales
│
└─ Guest Dataset B
└─ Product
└─ DailySales
のように、利用者ごとにデータを分離する想定です。
なぜGuestを先に作れなかったのか
変更前のProductには、
「この商品は誰のデータなのか?」
という情報がありませんでした。
例えば、
id = 1
name = 食パン
という商品があっても、
- 管理者の商品
- Guest Aの商品
- Guest Bの商品
のどれなのかDB側では判別できません。
そのままGuest機能を追加すれば、
Adminの商品
Guest Aの商品
Guest Bの商品
が同じ棚へ並んでしまいます。
そこで、商品に所属先を持たせることにしました。
Datasetを「部屋」として考えた
自分の中では、今回の構造を次のように整理しました。
PostgreSQL = 建物
Dataset = 部屋
Product = 部屋に置いてある商品
dataset_id = 「どの部屋の商品か」を示す名札
例えば、
Product
├─ id
├─ name
├─ price
└─ dataset_id
という構造にします。
dataset_idがAdmin Datasetを指していれば、
この商品はAdminの部屋にある商品
と判断できます。
将来的にGuestが増えても、
Adminの商品
→ dataset_id = Admin
Guest Aの商品
→ dataset_id = Guest A
Guest Bの商品
→ dataset_id = Guest B
と分離できます。
Datasetモデルを追加する
まずDatasetモデルを追加しました。
大まかな構造は次のようなものです。
class Dataset(db.Model):
__tablename__ = "datasets"
id = db.Column(UUID(as_uuid=True), primary_key=True)
kind = db.Column(
db.String(16),
nullable=False,
)
system_key = db.Column(
db.String(100),
unique=True,
)
created_at = db.Column(...)
last_activity_at = db.Column(...)
absolute_expires_at = db.Column(...)
kindには、
admin
guest
を持たせます。
Admin Datasetは、
kind = admin
system_key = admin
Guest Datasetは、
kind = guest
system_key = NULL
というルールにしました。
Guest自体はまだ使いませんが、将来追加するための構造だけ先に用意します。
PythonだけでなくDB自身にもルールを守らせる
今回意識したことの一つが、
「Pythonコードが正しく使ってくれること」だけに依存しない
ということでした。
例えばDatasetのkindは、
admin
guest
以外を許可したくありません。
そこでPostgreSQL側にもCHECK制約を追加しました。
sa.CheckConstraint(
"kind IN ('admin', 'guest')",
name="ck_datasets_kind",
)
さらに、
Adminなら system_key = admin
Guestなら system_key = NULL
という条件もDB側に持たせました。
自分の理解では、
Python側のチェック = 建物の受付
DBのCHECK制約 = 建物そのものの規則
です。
受付側で間違った値を通してしまっても、最後にDB自身が止めてくれる構造にします。
Productへ「所属先の名札」を追加する
次にProductへdataset_idを追加しました。
最終的なモデルでは、
dataset_id = db.Column(
UUID(as_uuid=True),
db.ForeignKey(
"datasets.id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
としています。
これにより、Productは必ず何らかのDatasetへ所属します。
自分の中では、
「名札の付いていない商品は倉庫に入れない」
というイメージです。
ただし、本番DBにはすでにProductが存在しています。
ここで新たな問題が出てきました。
既存商品がある状態で、いきなりNOT NULLにはできない
変更前のProductには、
dataset_id
というカラム自体がありません。
そこへいきなり、
dataset_id NOT NULL
を追加するとどうなるでしょうか。
既存Productにはdataset_idの値がありません。
既存Product
↓
dataset_id追加
↓
既存Productには所属先がない
↓
NOT NULL違反
となります。
「最終的にNOT NULLにしたい」からといって、最初からNOT NULLを付ければいいわけではありませんでした。
既存データを新しい構造へどう移すかまで考える必要があります。
そこでmigrationを2段階に分けました。
Expand → Migrate → Contract
今回のmigrationは、結果的に次の流れになりました。
1. Expand
dataset_idをNULL許可で追加
2. Migrate
既存ProductへAdmin Datasetを設定
3. Contract
dataset_idをNOT NULL化
いわゆる、
Expand → Migrate → Contract
に近い形です。
最初から門を閉じるのではなく、
新しい通路を作る
↓
既存データを新しい通路へ移す
↓
全員が移ったことを確認してから古い状態を禁止する
という順番です。
第1 migration:Dataset作成とbackfill
最初のmigrationは、
c7a1d9e4f2b6
です。
ここでは、
-
datasetsテーブルを作成 -
products.dataset_idをNULL許可で追加 - Admin Datasetを作成
- 既存ProductをAdmin Datasetへbackfill
- 外部キーを追加
- INDEXを追加
という処理を行います。
backfillとは?
今回のbackfillは、
既存商品へ、あとからAdmin Datasetの名札を付ける処理
です。
変更前
Product 1 食パン
Product 2 クロワッサン
Product 3 メロンパン
↓
Admin Datasetを作成
↓
Product 1 食パン
└─ dataset_id = Admin
Product 2 クロワッサン
└─ dataset_id = Admin
Product 3 メロンパン
└─ dataset_id = Admin
これで、既存Productも新しいDataset構造へ移せます。
migration自身にも検査を入れる
今回は単純に、
UPDATEできた
↓
migration成功
とはしませんでした。
migration開始前に、
product_count_before = _count_rows(bind, products)
sales_count_before = _count_rows(bind, daily_sales)
として件数を保存します。
backfill後には、
- Product件数が変わっていないか
- DailySales件数が変わっていないか
-
dataset_id = NULLが残っていないか - 全ProductがAdmin Datasetへ所属したか
- Datasetを持たない孤立Productがないか
を確認します。
異常があれば、
raise RuntimeError(...)
でmigration自体を止めます。
今回かなり意識したのは、
「migrationが最後まで走った」=「正しくデータ移行できた」ではない
という点です。
件数や所属状態まで確認して、初めて成功と判断するようにしました。
第2 migration:最後にNOT NULL化する
第2 migrationは、
f2b6c8d4e1a9
です。
ここで最終的に、
products.dataset_id
をNOT NULLへ変更します。
ただし、その前にもう一度、
- Admin Datasetが1つ存在するか
-
dataset_id = NULLの商品が残っていないか - 存在しないDatasetを参照しているProductがないか
を確認します。
問題がなければ、
batch_op.alter_column(
"dataset_id",
existing_type=sa.Uuid(as_uuid=True),
nullable=False,
)
として制約を強くします。
DBだけ新しくしても危険だった
ここでもう一つ問題があります。
DBを新しい構造へ変えても、古いアプリが、
Product(
name="新商品",
price=300,
)
のようにDatasetなしでProductを作れば、
新しいDBではエラーになります。
つまり、
新しいDB
dataset_id NOT NULL
×
古いアプリ
dataset_idなしでProduct作成
という組み合わせが危険です。
DB migrationでは、DBだけではなく、その瞬間に動いているアプリとの互換性も考える必要がありました。
そこで、
- Product新規作成
- seed処理
- pytest内のProduct生成
もすべてDataset対応させることにしました。
新規ProductにもAdmin Datasetを付ける
商品を新規登録するときには、
admin_dataset = get_admin_dataset()
でAdmin Datasetを取得します。
そのうえで、
Product(
dataset=admin_dataset,
...
)
として保存するようにしました。
これにより、新しく登録された商品にも必ず、
dataset_id = Admin Dataset
が入ります。
さらに、Admin Datasetが見つからない異常状態では、
勝手に新しいDatasetを作らず、商品登録そのものを失敗させる
ようにしています。
正常な状態だと勝手に決めつけず、安全側へ倒す形です。
seed_demo.pyもDataset対応する
本番アプリ起動時にはseed_demo.pyも実行しています。
もしseed側だけ古いままなら、
dataset_id NOT NULL
になった後でDatasetなしのProductを作ろうとして失敗します。
そこでseed処理でもAdmin Datasetを取得し、
Admin Dataset
↓
Product作成
という流れへ変更しました。
Admin Datasetが存在しない場合には、こちらも処理を失敗させます。
実装の途中で「理解度チェック」も挟んだ
今回は、Codexにまとめて実装を任せて最後に確認するのではなく、
調査
↓
仕組みを理解する
↓
小さく実装
↓
pytestで確認
↓
自分の理解度を確認
↓
次へ進む
という流れで進めました。
例えば、
- Datasetは何のために必要なのか
-
dataset_idは何を表しているのか - なぜ最初から
NOT NULLにできないのか - backfillでは何をしているのか
- CHECK制約をPython側だけでなくDB側にも置く理由
- migrationを2段階に分ける理由
- Auto-DeployをOFFにした理由
など、要所ごとに一度立ち止まって、自分の言葉で説明できるか確認しました。
今回の自分の中でのルールは、
「コードが動いたから次へ」ではなく、「なぜそうするのか説明できたら次へ」
でした。
AIを使って開発しているからこそ、
実装速度だけ上げて、自分の理解だけ置いていかれないようにする
ことは意識しました。
pytestがGREENでも、一度立ち止まった
Dataset対応を進めている途中、既存pytestは普通にGREENになっていました。
しかしコード全体を調べてみると、
テストコード内に、
Product(...)
をDatasetなしで作っている箇所が複数残っていました。
通常pytestだけでも20箇所です。
つまり、
アプリ本体はDataset対応したが、テスト用Productは旧構造のまま
という状態でした。
dataset_idを一時的にNULL許可にしていたため、テストが通ってしまっていたわけです。
そこでテスト側のProduct生成もすべて見直しました。
Dataset関連のpytestも追加
Datasetそのものについてもテストを追加しました。
例えば、
- Admin Datasetを作れる
- Guest Datasetを作れる
- 不正な
kindを拒否する - Adminなのに不正な
system_keyを持つ状態を拒否する - Guestなのに
system_keyを持つ状態を拒否する - ProductがDatasetへ所属できる
- 存在しないDataset IDを拒否する
- migration後に
dataset_id = NULLを拒否する - 既存Product件数を維持したままmigrationできる
などです。
最終結果は、
114 passed
となりました。
GREENになったこと以上に、
「このGREENは、新しいDataset構造を本当に通った結果なのか?」
を見ることの方が重要だと感じました。
ON DELETE CASCADEは追加したが、まだ完成形ではない
Product.dataset_idの外部キーには、
ondelete="CASCADE"
を追加しています。
これは、Datasetを削除したときに、そのDatasetへ所属するProductも削除できるようにするための土台です。
将来的には、
Dataset削除
↓
Product削除
↓
DailySales削除
というGuest cleanupの削除連鎖を目指しています。
ただし、第1段階ではDailySalesまで含めた削除連鎖はまだ完成していません。
DailySalesはProductを経由してつながっているため、Guest Datasetの自動削除機能を実装する後続段階で、削除時の整合性まで改めて詰める予定です。
今回はあくまで、
「AdminデータへDatasetという境界を付ける段階」
です。
Guest Datasetの寿命管理や、DailySalesまで含めた自動削除の完成形まで実装したわけではありません。
ここで本番Deploy……はしなかった
pytestが114件すべて通りました。
普通なら、
よし、Deploy!
と進みたくなります。
しかし今回はDB migrationです。
本番PostgreSQLには、すでに使っている管理者データがあります。
今回の最優先事項は、
新機能を早く出すことではなく、既存データを壊さないこと。
ここからは「安全第一⛑️」で進めました。
RenderのAuto-DeployをOFFにする
このアプリではDocker起動時に、
flask db upgrade
↓
python seed_demo.py
↓
gunicorn
という順番で処理しています。
つまりRenderのDeployが始まれば、
本番DB migrationも自動的に始まります。
そこで、まずAuto-DeployをOFFにしました。
これにより、
GitHubへmerge
しても、
Manual Deployを押す
までは本番DBが変わりません。
GitHubへのmergeと本番DB変更を一度切り離しました。
「mergeした瞬間に本番工事開始」
ではなく、自分で開始タイミングを決められる状態にします。
本番DBをバックアップする
次に、本番PostgreSQLのバックアップを取得しました。
ここでも一つ問題がありました。
ローカル環境に入っていたpg_dumpはPostgreSQL 16。
一方、本番PostgreSQLは18系でした。
そこで、PostgreSQL 18のDockerイメージを利用して、
同じメジャーバージョンのpg_dumpを使用しました。
バックアップ取得後には、
pg_restore --list backup.dump
でarchiveを読み取れることも確認しました。
これは完全なrestoreテストではありません。
今回は、
取得したbackup archiveをpg_restoreが正常に読み取れること
まで確認しています。
migration前の「基準値」を記録する
バックアップだけではなく、
本番DBが変更前にどのような状態だったのか
も記録しました。
結果は、
Alembic revision
9d3c1b7e5a42
Product
8件
ID 1~8
販売中 8件
DailySales
56件
ID 1~56
orphan DailySales
0件
でした。
これをmigration後の比較基準にします。
「たぶんデータは残っている」
ではなく、
変更前 8件 → 変更後 8件
のように数字で確認できるようにしました。
PRをmergeしても、まだ本番は変えない
変更をPRへまとめ、mainへmergeしました。
今回の差分は、
18 files changed
1301 insertions
15 deletions
でした。
しかしAuto-DeployはOFFのままです。
つまり、この時点では、
GitHub
→ 新しいコード
Render本番
→ まだ旧コード・旧DB
という状態です。
ローカルのmainも最新化し、
nothing to commit, working tree clean
を確認しました。
いよいよ本番migration
ここで本番アプリへの書き込み操作を止め、
Renderから、
Manual Deploy
↓
Deploy latest commit
を実行しました。
Deployログには、
Running upgrade 9d3c1b7e5a42 -> c7a1d9e4f2b6
Running upgrade c7a1d9e4f2b6 -> f2b6c8d4e1a9
と表示されました。
続いて、
Demo seed skipped: database already contains data.
Starting gunicorn
Your service is live
まで到達しました。
ここで、
migration実行
↓
seed判定
↓
Gunicorn起動
まで完走しました。
「Deploy succeeded」で終わらせない
Render上ではDeploy成功。
しかし、それだけでは既存データが正しく残っているか分かりません。
そこで、migration後の本番DBを直接確認しました。
Alembic
f2b6c8d4e1a9
Dataset
総数 1
Admin 1
Guest 0
Product
8件
ID 1~8
dataset_id NULL 0
Admin所有 8
orphan Product 0
DailySales
56件
ID 1~56
orphan DailySales 0
migration前は、
Product 8件
DailySales 56件
migration後も、
Product 8件
DailySales 56件
です。
既存8商品・56件の売上を失わず、8商品すべてへAdmin Datasetの名札を付けることができました。
最後は本番スモークテスト
DB上の数字が正しくても、実際のアプリが動かなければ意味がありません。
そこでRender本番環境でスモークテストを行いました。
今回確認したのは、
- 管理者ログイン
- 商品一覧表示
- 日次売上入力画面
- ダッシュボード
- 売上ランキング
- グラフ
- AIアドバイス
です。
すべて正常に動作しました。
スモークテストとは?
スモークテストは、
「細かい条件をすべて確認するテスト」ではなく、「主要機能が最低限壊れていないか」を確認するテスト
です。
自分の仕事に置き換えるなら、
pytest = 点検簿を使った細かい点検
スモークテスト = 整備後に実際にエンジンをかけて、走る・曲がる・止まるを確認
くらいのイメージで理解しました。
書き込み系も本番で確認する
表示だけでなく、書き込みも確認しました。
新商品として、
明太フランス
を登録。
さらに日次売上数量も入力しました。
その結果が、
- 売上ランキング
- グラフ
へ正常に反映されることも確認できました。
最後の最後に「名札」を確認する
画面で動いたからOK。
……では終わりません。
新しく登録した「明太フランス」が、
本当にAdmin Datasetへ所属したのか
をPostgreSQLから確認しました。
SELECT
p.id,
p.name,
d.kind,
d.system_key
FROM products p
JOIN datasets d ON d.id = p.dataset_id
WHERE p.name = '明太フランス'
ORDER BY p.id DESC
LIMIT 1;
結果は、
id 9
name 明太フランス
kind admin
system_key admin
でした。
新しく登録した商品にも、
Admin Datasetの「名札」がきちんと付いていました。
これで、
商品登録
↓
Admin Dataset所属
↓
売上入力
↓
DB保存
↓
集計
↓
ランキング
↓
グラフ表示
まで、本番環境で一連の動作を確認できました。
ゲストデモを作りたいだけだった
今回の始まりは、
ポートフォリオを実際に触ってもらいたい
という単純なものでした。
しかし、
Guestに書き込みを許可する
と考えた瞬間、
既存Adminデータはどう守る?
Guest同士はどう分ける?
既存Productは誰のもの?
migration中に旧アプリが書き込んだら?
NULLが残ったら?
migrationでデータが消えたら?
という問題が次々に出てきました。
結果として、
Dataset設計
↓
DB制約
↓
migration
↓
backfill
↓
NOT NULL
↓
アプリ側対応
↓
pytest
↓
バックアップ
↓
本番migration
↓
DB照合
↓
スモークテスト
まで広がりました。
Guestログインボタンを一つ作る前に、かなり大きな工事になりました。
「動いた」だけでは終わらなくなってきた
以前なら、
画面で動いた
↓
完成
と考えていたと思います。
今回は、
壊れたらどうなる?
失敗した途中状態は?
既存データは本当に残った?
DB自身も不正値を拒否できる?
本番で新しく作ったProductにもDatasetが付く?
Deploy成功後もデータ件数は同じ?
まで確認しました。
pytest強化を続けてきたことで、
「GREENかどうか」より、「そのGREENは何を保証しているのか」
を見る意識が、少しずつ他の開発工程にも広がってきたように感じます。
第1段階完了
これで、
第1段階:既存の管理者データをDatasetで守る
は完了です。
現在は、
PostgreSQL
│
└─ Admin Dataset
│
└─ Product
│
└─ DailySales
という状態です。
Guestはまだ存在しません。
次は、
PostgreSQL
│
├─ Admin Dataset
│
└─ Guest Dataset
へ進めていきます。
Guestには、
- 一時セッション
- Guestごとのデータ分離
- Adminデータへのアクセス防止
- 無操作時の期限切れ
- Guest Datasetの自動削除
- AI利用回数の制限
など、Adminとは異なる仕組みが必要になります。
ここからようやく、
「実際に触って試せるポートフォリオ」
のGuest側へ進みます。
おわりに
第1段階では、
Guest画面を一つも作りませんでした。
一見すると、ゲストデモ開発はほとんど進んでいないようにも見えます。
でも実際には、
「誰でも触れるようにする」前に、「誰に触られても守るべきデータは守る」
ための土台を作りました。
今回の第1段階で一番大きかったのは、
新機能を追加する前に、既存データを守る設計を先に置いたこと
だったと思います。
次は、このAdmin Datasetの隣に、
本当にGuest専用の「部屋」
を作っていきます。🧪
🔗 関連リンク
Qiita
GitHub - sales_data_app
