4_AIで下駄を履いてなんかすごそうなゲームやらツールを作るのではなく、まずは再学習も兼ねて身の丈に合った開発から始めた話
前回↑は、GitHub Actionsを使って、
GitHubへpush
↓
別の環境を用意
↓
pytestを実行
↓
成功・失敗をGitHub上で確認する
という最小限のCIを作った。
今回はそこから少し離れて、
FastAPIを触り始めることにした。
最初からAPIを色々作りたいわけではない。
まず知りたかったのは、
「ブラウザなどから来たHTTPリクエストが、Pythonコードを通ってHTTPレスポンスとして返る」とは、実際にはどういうことなのか?
というところ。
なので今回の目標もかなり小さく、
FastAPIでGET /healthを作る
↓
pytestで確認する
↓
GitHub Actionsでも緑にする
ところまでにした。
1. 今回形にしたかったもの
作りたかったAPIは一つだけ。
GET /health
へアクセスすると、
{
"status": "ok"
}
が返ってくるもの。
コードとしては最終的に、
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def get_health():
return {"status": "ok"}
というかなり小さいものになった。
ただ、今回知りたかったのはこの6行そのものというより、
HTTP Request
↓
Python
↓
HTTP Response
の間で何が起きているのかだった。
2. 新しいプロジェクトなので、今回はuvを使うことにした
Pythonの環境構築はこれまで、
venv
+
pip
でやることが多かった。
ただ、新しくプロジェクトを作るときは、
今後できるだけuvを使うようにしたいと思っていた。
なので今回は、
uv init --bare
でプロジェクトを初期化し、
必要なものを、
uv add ...
で追加していくことにした。
開発用の依存関係については、
uv add --dev ...
を使う。
そして実行するときは、
uv run ...
を使う。
最初は、
uvを使う
↓
venvを使わない
くらいのイメージが少しあった。
ただ実際にはそういう関係ではなく、
従来
python -m venv
↓
activate
↓
pip install
↓
python ...
uv
pyproject.toml / uv.lock
↓
uvが.venvも含めて環境を管理
↓
uv run ...
という違いだと整理した。
uvとvenvが競合しているというより、
仮想環境を自分で直接操作する部分まで、uv側へ寄せられる
という理解の方が近そうだった。
3. まずはGET /healthを動かした
開発用サーバーは、
uv run fastapi dev src/main.py
で起動した。
そして、
/health
へGETすると、
{"status":"ok"}
が返ってくる。
ここまではかなりあっさり動いた。
ただ、
@app.get("/health")
def get_health():
return {"status":"ok"}
を見ているだけだと、
これのどこがHTTPなのか?
という感じもまだ残る。
今回整理した範囲では、
ブラウザ / curl
↓
HTTP Request
↓
Uvicorn
↓
FastAPI
↓
get_health()
↓
FastAPI
↓
Uvicorn
↓
HTTP Response
↓
ブラウザ / curl
という流れになる。
@app.get("/health")自体が、
ネットワークからHTTPを受信しているわけではない。
UvicornがHTTPリクエストを受け取り、
FastAPIへ渡す。
FastAPIは、
GET
+
/health
に対応する処理として登録されている、
get_health()
を呼ぶ。
そして返された、
{"status": "ok"}
をレスポンスへ変換し、
最終的にはUvicornを通してクライアントへ返す。
単純化すると、
受け取ったHTTPリクエストをuvicornが解釈してFastAPIに渡す
→対応する処理をFastAPIが行なってレスポンスを返す(返す時もuvicorn経由)
つまり、
Uvicorn
→ HTTPとPython Webアプリの境界
FastAPI
→ どのPython処理を呼び、何を返すか
という役割分担だと考えると分かりやすかった。
4. pytestから同じappを使う
次にテストを書いた。
from fastapi.testclient import TestClient
from src.main import app
client = TestClient(app)
def test_check_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status":"ok"}
ここで少し面白かったのが、
from src.main import app
の部分。
最初は、
importするとファイルの中身を実行して、その中に作られた
appを持ってくる
くらいに考えていた。
これは大体合っていたが、
関数についてはもう少し分けて考える必要があった。
src.mainをimportすると、
app = FastAPI()
↓
FastAPIインスタンス作成
def get_health():
↓
関数オブジェクト作成
@app.get("/health")
↓
その関数をGET /healthの処理としてappへ登録
までは行われる。
ただ、
return {"status":"ok"}
がその時点で実行されるわけではない。
実際に、
client.get("/health")
が実行されたときに初めて、
get_health()
の中身が走る。
つまり、
import時
↓
アプリを作る
↓
ルートを登録する
と、
リクエスト時
↓
登録した関数を実行する
は別だった。
5. TestClientではUvicornを通っていなかった
もう一つ整理できたのが、
client.get("/health")
と、
ブラウザやcurlから、
http://localhost:8000/health
へアクセスする場合の違い。
ブラウザやcurlなら、
client
↓
実際のHTTP通信
↓
Uvicorn
↓
FastAPI
↓
get_health()
となる。
一方、pytestで使っているTestClientでは、
pytest
↓
TestClient
↓
FastAPI app
↓
get_health()
という形になる。
つまり今回のテストは、
Uvicornを起動してネットワーク越しにアクセスしているわけではない。
同じ、
client.get("/health")
という見た目なのでHTTP通信しているようにも見えたが、
FastAPIアプリをテスト用クライアントから直接呼んでいる。
この違いは今回初めて少し具体的になった。
6. 本体より、httpx周りで一度止まった
FastAPI本体の処理より、
テスト環境の方で一度引っかかった。
pytest自体は、
1 passed
になったものの、
同時にwarningが出た。
内容は、
Using `httpx` with `starlette.testclient` is deprecated;
install `httpx2` instead.
というもの。
最初は、
テストに必要なhttpx自体が廃止されたのか?
くらいに受け取った。
ひとまず表示されている案内に従い、
httpx2を追加した。
あとから整理すると、
TestClient周辺が
httpx
↓
httpx2
へ移行している途中らしい。
自分が書いた、
TestClient(app)
自体はかなり単純なのに、
その下では別ライブラリが動いていて、
そこにも依存関係がある。
今回はその境界でwarningを踏むことになった。
7. GitHub Actionsでもuvを使おうとして、uv initで転んだ
ローカルでpytestが通ったので、
次はGitHub Actions。
今回も以前作ったCIと同じく、
push
↓
runner起動
↓
コード取得
↓
Python環境を用意
↓
pytest
までを作る。
ただ今回はuvを使っているので、
Workflow側にもuvを入れる必要がある。
途中で、
uv init
もWorkflowに入れていた。
すると、
Project is already initialized
(`pyproject.toml` file exists)
として失敗した。
少し考えれば当然で、
ローカル
↓
uv init
↓
pyproject.tomlを作る
↓
GitHubへpush
している。
つまりGitHub Actionsでcheckoutした時点で、
すでにpyproject.tomlは存在している。
そこでもう一度、
uv init
をする必要はなかった。
uv initは、
プロジェクトを最初に作る処理
であって、
プロジェクトを動かすたびに行う処理
ではない。
最終的には、
Checkout repo
↓
Pythonを用意
↓
uvを用意
↓
uv run python -m pytest
というところまで減らした。
8. ActionsのYAMLも、やっぱりまだ思い出しながらだった
前回一度GitHub Actionsを書いていたので、
今回は何も分からない状態ではなかった。
ただ、
name:
on:
permissions:
jobs:
runs-on:
steps:
uses:
with:
run:
あたりは、
やはり思い出しながら書くことになった。
今の理解としては、
name
→ Workflowの名前
on
→ 起動条件
permissions
→ Workflowへ与えるGitHub上の権限
jobs
→ 実行する処理のまとまり
runs-on
→ jobを動かすrunner
steps
→ job内で順番に行う処理
uses
→ 既存Actionを使う
with
→ そのActionへ入力値を渡す
run
→ shell上でコマンドを実行
くらい。
途中、
with:
を環境変数の指定だと思っていたが、
これはusesで呼んだActionへの入力値だった。
環境変数なら、
env:
を使う。
一度使ったものでも、
数日後には普通に細かい書き方を忘れている。
ただ今回は、
全部暗記するより、YAMLの階層と各要素の役割が分かっていれば戻ってこられる
くらいに考えることにした。
9. 最小構成が緑になった
最終的に、
GET /health
↓
ローカルでレスポンス確認
↓
pytest
↓
GitHub Actions
まで通った。
pytestでは、
assert response.status_code == 200
assert response.json() == {"status":"ok"}
を確認している。
GitHub Actions上でもpytestが成功し、
今回最初に置いていた、
FastAPIで
GET /healthを作り、pytest+GitHub Actionsまで緑にする
というところまでは到達した。
10. 今回一番残ったのは、FastAPIだけではなかった
始める前は、
FastAPIを触る
↓
GET /healthを書く
くらいが中心になると思っていた。
実際に時間を使ったのは、
uvとvenvの違いを整理する
↓
TestClient周辺のwarningを見る
↓
httpx2を追加する
↓
GitHub ActionsのYAMLを思い出す
↓
CIでuv initして失敗する
といった周辺部分の方だった。
ただ、その過程で、
Uvicorn
FastAPI
TestClient
uv
GitHub Actions
import
が全部同じものではなく、
少しずつ役割を分けて見られるようになった。
例えば今なら、
Uvicorn
→ HTTPを受け取り、FastAPIとの間をつなぐ
FastAPI
→ ルーティングしてPython処理を呼ぶ
TestClient
→ Uvicornを起動せずFastAPIアプリをテストする
uv
→ Pythonプロジェクトの依存関係や環境を管理する
GitHub Actions
→ 別のrunner上で指定したコマンドを実行する
というくらいには分けられる。
11. 今日できたもの
今回の終了時点では、
- FastAPIアプリの最小構成
GET /health-
{"status":"ok"}のJSONレスポンス -
TestClientを使ったpytest - HTTP status code 200の確認
- response bodyの確認
- uvを使ったプロジェクト環境
- GitHub Actionsからのpytest実行
- push時にCIが緑になる状態
までが残った。
APIとしては、
まだ/health一つしかない。
ただ、
HTTP Request
↓
FastAPI
↓
Python関数
↓
HTTP Response
という一番小さい流れを、
実際に動くコードとして確認するところまではできた。
12. 次は「HTTPからPythonへ値が入るところ」を見る
今回の/healthには入力がない。
GET /health
↓
固定のJSONを返す
だけ。
なので次は、
URLに含まれる値
Query Parameter
JSON Body
などをFastAPIがどうPythonへ渡すのかを見ていく予定。
小さいTask APIのようなものを作って、
POST /tasks
GET /tasks/{task_id}
GET /tasks?done=false
くらいまで触ってみたい。
その中で、
HTTP入力
↓
FastAPI / Pydanticによる検証
↓
Pythonオブジェクト
↓
Python関数
↓
正常レスポンス / エラー
という流れを見る。
DBまで入れると、
FastAPIを理解しているのかDBを理解しているのか分からなくなりそうなので、
最初はメモリ上のdict程度に留めるつもり。
13. 終わりに
今回は本当に最小構成だった。
書いたFastAPIのコードだけを見れば、
数行しかない。
それでも実際に動かしてみると、
HTTPを受けるのは誰なのか
@app.getは何をしているのか
returnしたdictはどう返るのか
pytestではUvicornも動いているのか
importした時点で何が実行されるのか
uvはvenvの代わりなのか
CIでは何を初期化する必要があるのか
と、細かい疑問が色々出てきた。
最初は、
FastAPIでAPIを一つ作る
くらいのつもりだった。
今のところは、
HTTPとPythonの間に何が挟まっているのかを、一つずつ名前付きで見られるようにする
作業だった気がする。
まだ、
GET /health
しかない。
次は実際にHTTPから値を受け取って、
Python側で扱ってみる。
まずはそこまででいい気がしている。
Discussion