前回は、このシリーズの方針として、FastAPIを書いてきた視点で整理してみるという話を書きました。
今回はその続きとして、かなり重要なテーマである
「悪いPythonコードは、何が悪いのか?」
を、実際に動かしながら整理してみます。
Pythonは書きやすい言語です。
だからこそ、動くコードは比較的すぐ書けます。
一方で、実務では次のようなコードに出会いがちです。
- とりあえず動く
- でも読みにくい
- 修正しづらい
- テストしにくい
- 仕様変更で壊れやすい
- セキュリティや運用の観点が抜けやすい
今回は、そうした「悪いコード」をいくつかの観点に分けて、わざとつらさを体験する形で見ていきます。
なお、ここでいう「悪い」は、書いた人が悪いという意味ではありません。
多くの場合は、
- 締切が近い
- 要件がまだ固まっていない
- 小さく始めたコードが大きくなった
- とりあえず通す必要があった(突貫工事的に依頼があった)
といった事情の中で自然に生まれるものです。
なのでこの記事では、責めるためではなく、見分けられるようになる事を目的に整理できたらと思っています。
今回実施すること
今回は次の流れで進めます。
- ありがちな「つらいコード」をまず動かす
- そのコードに少しずつ変更を入れて、何がつらいのか体験する
- 少し改善した版に置き換える
- FastAPIから使うときに、どこが扱いやすくなるか確認する
前提
この記事は次の環境を前提にしています。
- VS Code が使える
- Python 3.12 がインストール済み
- ターミナルでコマンドが打てる
0. 作業ディレクトリ作成
まずは作業ディレクトリを作ります。
mkdir python_practical_series_02
cd python_practical_series_02
仮想環境を作成します。
python3 -m venv .venv
仮想環境を有効化します。
macOS / Linux:
source .venv/bin/activate
Windows PowerShell:
.venv\Scripts\Activate.ps1
FastAPI と Uvicorn を入れておきます。
pip3 install fastapi "uvicorn[standard]"
今回のファイル構成
最終的に、今回のディレクトリはこうなります。
python_practical_series_02/
├─ .venv/
├─ bad_order.py
├─ run_bad_examples.py
├─ better_order.py
├─ run_better_examples.py
└─ app_part2.py
1. まずは「ありがちなつらいコード」を動かす
まずは、あえて少しつらい実装を書きます。
bad_order.py を作成してください。
def create_order(user_id, item_id, quantity, db, is_admin=False):
# 最初にユーザーIDの妥当性を確認する
if user_id is None or user_id <= 0:
return {"status": 400, "message": "invalid user"}
# 商品IDが存在しない場合はエラーを返す
if item_id not in db["items"]:
return {"status": 404, "message": "item not found"}
# 数量が 0 以下なら注文として不正
if quantity <= 0:
return {"status": 400, "message": "invalid quantity"}
# ここで商品情報を取り出す
item = db["items"][item_id]
# 在庫不足なら注文を作れない
if item["stock"] < quantity:
return {"status": 400, "message": "out of stock"}
# 元の合計金額を計算する
total_price = item["price"] * quantity
# 管理者なら 20% 割引、それ以外は割引なし
if is_admin == True:
discount = 0.2
else:
discount = 0
# 割引後の最終金額を計算する
final_price = int(total_price * (1 - discount))
# 注文データを dict で組み立てる
order = {
"user_id": user_id,
"item_id": item_id,
"quantity": quantity,
"price": final_price,
}
# 注文を保存する
db["orders"].append(order)
# 在庫を減らす
db["items"][item_id]["stock"] = db["items"][item_id]["stock"] - quantity
# 開発中の確認用に print でログを出している
print("order created:", order)
# 成功時も dict で返している
return {"status": 200, "data": order}
次に、動作確認用の run_bad_examples.py を作ります。
from pprint import pprint
from bad_order import create_order
def build_db():
# サンプル用の簡易 DB を毎回作り直す
return {
"items": {
1: {"name": "Keyboard", "price": 5000, "stock": 10},
2: {"name": "Mouse", "price": 3000, "stock": 0},
},
"orders": [],
}
print("=== 1. 正常系 ===")
db = build_db()
# 一般ユーザーとして注文を作成する
result = create_order(user_id=1, item_id=1, quantity=2, db=db, is_admin=False)
pprint(result)
print()
print("=== 2. 管理者割引 ===")
db = build_db()
# 管理者として注文を作成し、割引が入ることを確認する
result = create_order(user_id=1, item_id=1, quantity=2, db=db, is_admin=True)
pprint(result)
print()
print("=== 3. 在庫不足 ===")
db = build_db()
# 在庫 0 の商品を注文して失敗パターンを見る
result = create_order(user_id=1, item_id=2, quantity=1, db=db, is_admin=False)
pprint(result)
print()
print("=== 4. DBの中身 ===")
db = build_db()
# 注文後に DB の内容がどう変わるか確認する
create_order(user_id=1, item_id=1, quantity=2, db=db, is_admin=False)
pprint(db)
以下を実行します。
python3 run_bad_examples.py
出力のイメージはこんな感じです。
=== 1. 正常系 ===
order created: {'user_id': 1, 'item_id': 1, 'quantity': 2, 'price': 10000}
{'data': {'item_id': 1, 'price': 10000, 'quantity': 2, 'user_id': 1},
'status': 200}
=== 2. 管理者割引 ===
order created: {'user_id': 1, 'item_id': 1, 'quantity': 2, 'price': 8000}
{'data': {'item_id': 1, 'price': 8000, 'quantity': 2, 'user_id': 1},
'status': 200}
=== 3. 在庫不足 ===
{'message': 'out of stock', 'status': 400}
=== 4. DBの中身 ===
order created: {'user_id': 1, 'item_id': 1, 'quantity': 2, 'price': 10000}
{'items': {1: {'name': 'Keyboard', 'price': 5000, 'stock': 8},
2: {'name': 'Mouse', 'price': 3000, 'stock': 0}},
'orders': [{'item_id': 1, 'price': 10000, 'quantity': 2, 'user_id': 1}]}
ここで見てほしいのは、「一応ちゃんと動いている」ことです。
ただし、この時点ですでに次のつらさが見え始めています。
- 正常系も異常系も同じ
dictで返している -
priceやstockの意味がdictのキー名頼みになっている - DB 更新まで同じ関数がやっている
つまり、今は動くけれど、変更や仕様追加が入るとつらくなりやすい形です。
ここから少し触ると、つらさが見えてきます。
2. 「どこがつらいのか」を実際に体験する
ここが今回の本題です。
読むだけではなく、わざと困る変更を入れてみます。
2-1. dict は typo に弱い
まず、run_bad_examples.py の build_db() をわざと壊してみます。
※これは第1回でも同じことを記載していますが、少し掘り下げて説明しています。
※十分理解しているという方は読み飛ばしてください。
def build_db():
# わざと price を prcie に typo している
# dict はこのような typo が実行時まで見つかりにくい
return {
"items": {
1: {"name": "Keyboard", "prcie": 5000, "stock": 10},
2: {"name": "Mouse", "price": 3000, "stock": 0},
},
"orders": [],
}
price を prcie に typo しました。
この状態で、もう一度以下を実行します。
python3 run_bad_examples.py
すると、こんなエラーになります。
KeyError: 'price'
ここで大事なのは、KeyError 自体よりも
「データ構造の意味がコード上に出ていないため、typo が実行時まで潜りやすい」
という点です。
dict は便利ですが、キー名の正しさを読み手が頭で覚え続ける必要があります。
このあと dataclass[注1] を使うと、この「意味の見えにくさ」がかなり改善されます。
では、dataclass を使うと何が良くなるのか?
dataclass を使う一番の利点は、データの意味と形をコード上に明示しやすいことです。
たとえば dict だと、次のような問題が出やすいです。
- キー名の typo に弱い
- 何のデータなのか読み手に伝わりにくい
- 補完や型チェックの助けを受けにくい
一方、dataclass で Item や Order を定義すると、
- 「これは商品」「これは注文」と名前で表現できる
- どんな属性を持つのかが定義として見える
- 属性アクセスが
item.priceのように自然になる - テストコードでも、何を渡しているか分かりやすい
という形で、コードの意味がかなり見えやすくなります。
つまり、dataclass は「class を簡単に書くための機能」というだけでなく、
dict に押し込んでいた意味を、明示的な構造として取り出すための道具
として使うと、とても効果的です。
ここで押さえておきたいのは、dataclass の利点は「短く書けること」だけではない、という点です。
本当に大きいのは、データの意味と構造がコード上に見えるようになることです。
2-2. 仕様追加ですぐ関数が太る
次は、割引ルールを増やしてみます。
bad_order.py の create_order() を次のように書き換えてみてください。
def create_order(
user_id,
item_id,
quantity,
db,
is_admin=False,
is_premium=False,
is_first_order=False,
):
# ユーザーIDのチェック
if user_id is None or user_id <= 0:
return {"status": 400, "message": "invalid user"}
# 商品IDの存在チェック
if item_id not in db["items"]:
return {"status": 404, "message": "item not found"}
# 数量チェック
if quantity <= 0:
return {"status": 400, "message": "invalid quantity"}
item = db["items"][item_id]
# 在庫不足チェック
if item["stock"] < quantity:
return {"status": 400, "message": "out of stock"}
# 元の合計金額
total_price = item["price"] * quantity
# 割引率をフラグごとに加算していく
discount = 0
if is_admin == True:
discount += 0.2
if is_premium == True:
discount += 0.1
if is_first_order == True:
discount += 0.05
# 割引後の価格
final_price = int(total_price * (1 - discount))
# 注文データを作成
order = {
"user_id": user_id,
"item_id": item_id,
"quantity": quantity,
"price": final_price,
}
# 保存と在庫更新もこの関数でまとめてやっている
db["orders"].append(order)
db["items"][item_id]["stock"] = db["items"][item_id]["stock"] - quantity
print("order created:", order)
return {"status": 200, "data": order}
次に、run_bad_examples.py の呼び出し部分も変更してみます。
ここは bad_order.py ではなく、run_bad_examples.py の「正常系」の呼び出し部分を変更するイメージです。
変更前(run_bad_examples.py)
print("=== 1. 正常系 ===")
db = build_db()
# 変更前は引数がまだ少ないので、呼び出し側も比較的読みやすい
result = create_order(user_id=1, item_id=1, quantity=2, db=db, is_admin=False)
pprint(result)
print()
変更後(run_bad_examples.py)
print("=== 1. 正常系(割引ルール追加後) ===")
db = build_db()
# 変更後はフラグが増え、呼び出し側の情報量も増えている
result = create_order(
user_id=1,
item_id=1,
quantity=2,
db=db,
is_admin=False,
is_premium=True,
is_first_order=True,
)
pprint(result)
print()
または、もっと雑に位置引数で呼ぶとこうなります。
# 位置引数で渡すと、どの True / False が何を意味するか分かりづらい
result = create_order(1, 1, 2, db, False, True, True)
つまり、このステップでやりたいのは次の2つです。
-
bad_order.py側でcreate_order()の引数を増やす -
run_bad_examples.py側で、その新しい引数に合わせて呼び出しコードも書き換える
この2か所をセットで変更することで、仕様追加が入ると関数定義だけでなく呼び出し側もすぐ複雑になることを体験できます。
たとえば、この呼び出しでは割引率は次のようになります。
-
is_admin=False-> 0% -
is_premium=True-> 10% -
is_first_order=True-> 5%
合計 15% 引きなので、元の 10000 円に対して最終価格は 8500 円です。
想定される出力はこうです。
order created: {'user_id': 1, 'item_id': 1, 'quantity': 2, 'price': 8500}
{'data': {'item_id': 1, 'price': 8500, 'quantity': 2, 'user_id': 1},
'status': 200}
ここで見てほしいのは、「出力が正しいか」だけではありません。
本当に大事なのは、仕様追加が入った瞬間に、関数シグネチャと呼び出し側の両方が複雑になり始めることです。
特に、次のような位置引数呼び出しはかなり読みづらくなります。
# 位置引数だけを見ると、各フラグの意味を読む側が推測しないといけない
result = create_order(1, 1, 2, db, False, True, True)
どの True / False が何を意味するのか、呼び出し位置だけでは分かりません。
この時点で、かなり危うさが見えてきます。
2-3. 戻り値でエラーを表現すると、呼び出し側が毎回つらい
次は、呼び出し側のコードを書いてみます。
run_bad_examples.py に次の関数を追加してください。
def notify_order_result(result):
# 成功以外は失敗として扱う
if result["status"] != 200:
# message が入っている前提でエラー内容を表示する
print("注文失敗:", result["message"])
return
# 成功時は data が入っている前提で表示する
print("注文成功:", result["data"])
そして実行部分をこう変えます。
print("=== 5. 呼び出し側のつらさ ===")
db = build_db()
# 在庫不足になるケースをわざと作る
result = create_order(user_id=1, item_id=2, quantity=1, db=db, is_admin=False)
# 呼び出し側が毎回 status を見て分岐しないといけない
notify_order_result(result)
以下を実行します。
python3 run_bad_examples.py
想定される出力はこうです。
=== 5. 呼び出し側のつらさ ===
注文失敗: out of stock
この出力だけ見ると分かりやすそうですが、呼び出し側は毎回
-
statusを見る -
messageがある前提で扱う -
dataがある前提で扱う
という分岐を書かなければいけません。
つまり、異常の意味が戻り値の辞書構造に押し込まれている状態です。
このあと独自例外[補足注2]に変えると、失敗の種類をもっと自然に扱えるようになります。
2-4. print ログはとりあえず便利。でも運用はしづらい
今の実装にはこれがあります。
# 開発中は便利だが、実務では print ログだけだとつらくなりやすい
print("order created:", order)
開発中ならかなり便利です。
でも実務では次がつらくなります。
- ログレベルがない
- 出力先を切り替えにくい
- 検索しづらい
- 機微情報を混ぜやすい
- JSON構造化しづらい
今回は logging までは踏み込みませんが、「print が悪」ではなく、運用で使えるログかどうかが大事という感覚は持っておくとよいです。
2-5. ネストが深いと、本題が見えにくい
次は、別の例を見ます。
run_bad_examples.py の末尾に追加してください。
def process_order(user, item, quantity):
# 条件を順番に確認しているが、正常系までの道のりが深い
if user is not None:
if user["is_active"]:
if item is not None:
if quantity > 0:
if item["stock"] >= quantity:
return "ok"
return "ng"
print()
print("=== 6. 深いネスト ===")
# 条件をすべて満たすので ok
print(process_order({"is_active": True}, {"stock": 10}, 2))
# 在庫不足なので ng
print(process_order({"is_active": True}, {"stock": 1}, 2))
以下を実行します。
python3 run_bad_examples.py
想定される出力はこうです。
=== 6. 深いネスト ===
ok
ng
この例では、「結果が間違う」ことを見せたいわけではありません。
見てほしいのは、正常系にたどり着くまでの条件が深いネストに埋もれると、本題が見えにくくなることです。
つまりこれは、バグの例というより 読みにくさの例 です。
動きます。
でも読みやすいかと言われると、かなり微妙です。
3. 少し改善した版に置き換える
ここから、いきなり大規模なアーキテクチャにはせず、まずは素直に改善します。
better_order.py を作成してください。
from dataclasses import dataclass
# 異常の種類ごとに例外を分ける
class InvalidUserError(Exception):
pass
class ItemNotFoundError(Exception):
pass
class InvalidQuantityError(Exception):
pass
class OutOfStockError(Exception):
pass
# 管理者割引率を定数として切り出す
ADMIN_DISCOUNT_RATE = 0.2
# 商品データを意味のある構造として表す
@dataclass(frozen=True)
class Item:
item_id: int
name: str
price: int
stock: int
# 注文データも dataclass で表す
@dataclass(frozen=True)
class Order:
user_id: int
item_id: int
quantity: int
final_price: int
def validate_user(user_id: int) -> None:
# 不正なユーザーなら例外を送出する
if user_id <= 0:
raise InvalidUserError("invalid user")
def find_item(db: dict, item_id: int) -> Item:
# 商品を取り出し、見つからなければ例外を送出する
item_data = db["items"].get(item_id)
if item_data is None:
raise ItemNotFoundError("item not found")
# dict から Item dataclass へ変換する
return Item(
item_id=item_id,
name=item_data["name"],
price=item_data["price"],
stock=item_data["stock"],
)
def calculate_discount_rate(is_admin: bool) -> float:
# 割引率の決定だけを担当する
return ADMIN_DISCOUNT_RATE if is_admin else 0.0
def create_order(user_id: int, item: Item, quantity: int, is_admin: bool) -> Order:
# ユーザーの妥当性を確認する
validate_user(user_id)
# 数量チェック
if quantity <= 0:
raise InvalidQuantityError("invalid quantity")
# 在庫不足チェック
if item.stock < quantity:
raise OutOfStockError("out of stock")
# 合計金額と割引後金額を計算する
total_price = item.price * quantity
discount_rate = calculate_discount_rate(is_admin)
final_price = int(total_price * (1 - discount_rate))
# 成功時は Order を返す
return Order(
user_id=user_id,
item_id=item.item_id,
quantity=quantity,
final_price=final_price,
)
def save_order(db: dict, order: Order) -> None:
# 保存処理だけを担当する
db["orders"].append(
{
"user_id": order.user_id,
"item_id": order.item_id,
"quantity": order.quantity,
"price": order.final_price,
}
)
# 在庫更新もここで行う
db["items"][order.item_id]["stock"] -= order.quantity
次に、実行用の run_better_examples.py を作ります。
from pprint import pprint
from better_order import (
InvalidQuantityError,
InvalidUserError,
ItemNotFoundError,
OutOfStockError,
create_order,
find_item,
save_order,
)
def build_db():
# サンプル用の簡易 DB を毎回作る
return {
"items": {
1: {"name": "Keyboard", "price": 5000, "stock": 10},
2: {"name": "Mouse", "price": 3000, "stock": 0},
},
"orders": [],
}
def run_case(user_id: int, item_id: int, quantity: int, is_admin: bool):
db = build_db()
try:
# まず商品を取得する
item = find_item(db, item_id)
# 商品と入力値から注文を作る
order = create_order(
user_id=user_id,
item=item,
quantity=quantity,
is_admin=is_admin,
)
# 成功した注文だけ保存する
save_order(db, order)
print("注文成功")
pprint(order)
pprint(db)
except InvalidUserError as e:
print("ユーザーエラー:", e)
except ItemNotFoundError as e:
print("商品エラー:", e)
except InvalidQuantityError as e:
print("数量エラー:", e)
except OutOfStockError as e:
print("在庫エラー:", e)
print("=== 1. 正常系 ===")
run_case(user_id=1, item_id=1, quantity=2, is_admin=False)
print()
print("=== 2. 管理者割引 ===")
run_case(user_id=1, item_id=1, quantity=2, is_admin=True)
print()
print("=== 3. 在庫不足 ===")
run_case(user_id=1, item_id=2, quantity=1, is_admin=False)
print()
print("=== 4. 数量不正 ===")
run_case(user_id=1, item_id=1, quantity=0, is_admin=False)
以下を実行します。
python3 run_better_examples.py
想定される出力はこうです。
=== 1. 正常系 ===
注文成功
Order(user_id=1, item_id=1, quantity=2, final_price=10000)
{'items': {1: {'name': 'Keyboard', 'price': 5000, 'stock': 8},
2: {'name': 'Mouse', 'price': 3000, 'stock': 0}},
'orders': [{'item_id': 1, 'price': 10000, 'quantity': 2, 'user_id': 1}]}
=== 2. 管理者割引 ===
注文成功
Order(user_id=1, item_id=1, quantity=2, final_price=8000)
{'items': {1: {'name': 'Keyboard', 'price': 5000, 'stock': 8},
2: {'name': 'Mouse', 'price': 3000, 'stock': 0}},
'orders': [{'item_id': 1, 'price': 8000, 'quantity': 2, 'user_id': 1}]}
=== 3. 在庫不足 ===
在庫エラー: out of stock
=== 4. 数量不正 ===
数量エラー: invalid quantity
ここで見てほしいのは、単に「動く」ことではなく、出力そのものの意味が読みやすくなっていることです。
たとえば bad 版では辞書で出ていた値が、改善版ではこう表示されます。
Order(user_id=1, item_id=1, quantity=2, final_price=10000)
これにより、
- これは
Orderという概念である -
priceではなくfinal_priceである - どのフィールドを持つかが明確である
といったことが、実行結果を見ただけでもかなり伝わりやすくなります。
何が改善されたのか
ここでは、完璧な設計にしたというより、悪さの原因を少しずつ分離したと捉えると分かりやすいです。
1. 異常の種類が明確になった
例外クラスを分けたので、
- ユーザー不正
- 商品不存在
- 数量不正
- 在庫不足
が区別しやすくなりました。
2. データの意味が見えやすくなった
Item と Order に分けたことで、
「何を扱っているのか」がコード上に表れやすくなりました。
3. 関数ごとの役割(責務)が少し明確になった
-
validate_user()はユーザー検証 -
find_item()は商品取得 -
create_order()は注文生成 -
save_order()は保存
という形で、変更理由が分かれ始めています。
4. アプリの本題となる処理やルールがHTTPから独立した
ここがFastAPI経験者目線だとかなり重要です。
better_order.py は FastAPI を知りません。
つまり、業務ルールが Web フレームワークの都合に引っ張られていません。
4. FastAPI から使うと、何が嬉しいのか
ここで、FastAPIから呼ぶ形も作ってみます。
app_part2.py を作成してください。
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from better_order import (
InvalidQuantityError,
InvalidUserError,
ItemNotFoundError,
OutOfStockError,
create_order,
find_item,
save_order,
)
app = FastAPI(title="Part 2 Order API")
# 簡易的な in-memory DB
db = {
"items": {
1: {"name": "Keyboard", "price": 5000, "stock": 10},
2: {"name": "Mouse", "price": 3000, "stock": 0},
},
"orders": [],
}
class CreateOrderRequest(BaseModel):
# FastAPI が受け取る request body の形
user_id: int
item_id: int
quantity: int
is_admin: bool = False
@app.post("/orders")
async def create_order_endpoint(request: CreateOrderRequest):
try:
# 商品取得
item = find_item(db, request.item_id)
# 業務ロジックで注文生成
order = create_order(
user_id=request.user_id,
item=item,
quantity=request.quantity,
is_admin=request.is_admin,
)
# 保存処理
save_order(db, order)
except InvalidUserError as e:
# 業務例外を HTTP 400 に変換する
raise HTTPException(status_code=400, detail=str(e))
except ItemNotFoundError as e:
# 商品がなければ HTTP 404
raise HTTPException(status_code=404, detail=str(e))
except InvalidQuantityError as e:
# 数量不正は HTTP 400
raise HTTPException(status_code=400, detail=str(e))
except OutOfStockError as e:
# 在庫不足も HTTP 400
raise HTTPException(status_code=400, detail=str(e))
# 成功時のレスポンスだけを返す
return {
"message": "order created",
"order": {
"user_id": order.user_id,
"item_id": order.item_id,
"quantity": order.quantity,
"final_price": order.final_price,
},
}
ではサーバーを起動してみます。
uvicorn app_part2:app --reload --port 8002
ブラウザで以下を開きます。
http://127.0.0.1:8002/docs
POST /orders で次のJSONを投げてみてください。
{
"user_id": 1,
"item_id": 1,
"quantity": 2,
"is_admin": true
}
レスポンスは次の通りです。
{
"message": "order created",
"order": {
"user_id": 1,
"item_id": 1,
"quantity": 2,
"final_price": 8000
}
}
ここでのポイントは、FastAPI 側が HTTP の変換役に集中していることです。
- 業務ロジック側では独自例外を使う
- FastAPI 側で
HTTPExceptionに変換する
という分離になっているので、業務ルールそのものは Web フレームワークから独立しています。
異常系も確認しておくと、さらに理解しやすいです。
商品が存在しない場合
入力例:
{
"user_id": 1,
"item_id": 999,
"quantity": 2,
"is_admin": false
}
想定レスポンス:
{
"detail": "item not found"
}
HTTP ステータスは 404 です。
数量が 0 の場合
入力例:
{
"user_id": 1,
"item_id": 1,
"quantity": 0,
"is_admin": false
}
想定レスポンス:
{
"detail": "invalid quantity"
}
HTTP ステータスは 400 です。
在庫不足の場合
入力例:
{
"user_id": 1,
"item_id": 2,
"quantity": 1,
"is_admin": false
}
想定レスポンス:
{
"detail": "out of stock"
}
HTTP ステータスは 400 です。
この異常系レスポンスで見てほしいのは、
業務上の失敗が、HTTP の世界では自然な 400 / 404 に変換されていることです。
つまり、FastAPI 側は「Web の都合」を担当し、
better_order.py 側は「業務の都合」を担当する、という役割分担ができています。
5. 何が「悪いコード」だったのかを整理する
今回の「悪いコード」のつらさは、だいたい次に集約されます。
1. 責務が混ざっていた
最初の create_order() には、次が全部入っていました。
- 入力値チェック
- データ取得
- 在庫確認
- 割引計算
- 注文生成
- 保存
- ログ出力
- レスポンス整形っぽい戻り値
これは「長い関数が悪い」というより、責務が多すぎるのが問題です。
2. dict が便利すぎて、意味が曖昧だった
item["price"] や order["price"] は手軽ですが、
- typo に弱い
- 何のデータか見えにくい
- 型の恩恵を受けにくい
という問題があります。
dataclass を使うだけでも、かなりマシになります。
3. 戻り値に異常を押し込みすぎていた
戻り値の {"status": 400, ...} は一見便利です。
でも呼び出し側で毎回分岐が必要になります。
しかも、異常の種類が見えにくくなります。
4. 真偽値フラグが複雑化の入口になっていた
is_admin は最初は簡単です。
でも、そこに is_premium や is_first_order が増え始めると、関数と呼び出し側の両方がつらくなります。
5. HTTPの都合が業務ロジックに近すぎた
今回は最初の悪い例では status を返していましたが、これはかなりHTTP寄りの表現です。
FastAPIを書く立場から見ると、
- API層
- 業務ロジック
- 保存処理
の境界を分けるだけで、だいぶ見通しが良くなります。
6. レビューで見るポイント
レビュー時に見るポイントとして、次のチェックリストはかなり使えると思います。
- この関数は何個の仕事をしているか
- 変更理由が複数ないか
- ネストが深くなりすぎていないか
-
dictに意味を押し込みすぎていないか - 異常系が曖昧ではないか
- HTTP/DB/ログの都合が混ざっていないか
- テストを書くとしたら大変ではないか
これを意識するだけでも、コードの見え方はかなり変わります。
まとめ
今回は、悪いPythonコードがなぜつらいのかを、実際に動かして壊しながら整理してみました。
ポイントをまとめると、次の通りです。
- 悪いコードは「書き方が下手」というより、責務や境界が崩れていることが多い
- 長い関数より、責務が多い関数がつらい
-
dictの多用は意味を曖昧にしやすい - 戻り値に異常を押し込みすぎると、呼び出し側がつらくなる
- 真偽値フラグは将来の複雑化の入口になりやすい
- FastAPIの都合を業務ロジックから離すと、再利用しやすく、テストしやすくなる
Pythonは柔軟なので、悪いコードも簡単に書けてしまいます。
だからこそ、「何が悪いのか」を説明できるようになることはとても大事だと思います。
記事中記載事項の補足
補足注1
dict ベースの実装
辞書に必要な情報を全部入れて進める書き方です。小さなサンプルでは手軽ですが、キー名の typo に弱く、データの意味が見えにくくなりやすいです。
補足注2
独自例外
アプリ側で意味を付けて作る例外です。InvalidQuantityError のように名前で失敗理由が分かると、読む側が理解しやすくなります。
補足注3
業務ロジック
注文を作るときの本題の処理です。HTTP の都合ではなく、『注文できるか』『いくらになるか』を決める部分を指します。
補足注4
責務分離
役割ごとにコードを分ける考え方です。入力確認・計算・保存・レスポンス作成を少しずつ分けると、変更しやすくなります。
参考文献
注1
Python 公式ドキュメント, dataclasses — Data Classes
https://docs.python.org/3/library/dataclasses.html