10
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FastAPIを使ってバックエンドAPIをそれなりに作ってきました。

ただ、振り返ってみると、Pythonそのものを体系立てて整理してきたわけではなく、必要な場面ごとに都度覚えて、都度書いてきた感覚があります。

その結果、

  • Pythonの知識が断片的
  • 「動くコード」は書けるが、「よいコード」を言語化しづらい
  • 実装の良し悪しを説明しようとすると、経験則っぽくなりがち
  • クリーンアーキテクチャやセキュリティの話を、Python実装にどう落とすか整理しきれていない

という状態になっていたと感じていました。

そこで、1つのお題を通して、Pythonを実務目線で整理し直すことを目的に、順番に整理してみたいと思います。

今回は第1回として、このシリーズをどんな方針で進めるかを、実際にコードを動かしながら整理します。


今回実施すること

今回は、ローカル環境で次の3つを実際に動かします。

  1. ありがちな「とりあえず動く注文作成API[補足注1]」を動かす
  2. そのコードにあえてダメな変更を入れて、どこがつらいのかを体感する
  3. 少し整理した版に置き換えて、何が良くなったかを確認する

この記事は導入回なので、まだ大きなアプリは作りません。
ただし、 「今後このシリーズで何を改善していくのか」 が見えるところまでは持っていきます。


対象読者

  • Pythonを書いているが、知識が散らかっていると感じる人
  • FastAPI / Django / Flask などでAPIは書けるが、Python自体を整理したい人
  • 初学者に説明できるように、自分の知識を構造化したい人
  • 実務で通用するPythonコードの考え方を知りたい人

前提

この記事は、次の環境を前提に進めます。

  • VS Code が使える
  • Python 3.12 がインストール済み
  • ターミナルでコマンドが打てる

今回作成するもの

題材は、シリーズを通して扱う 注文作成API です。

この題材にする理由はシンプルで、実務で重要な論点をかなり自然に含められるからです。

  • 入力値の検証
  • 金額計算
  • 在庫確認
  • 例外処理
  • DB保存の考え方
  • レスポンス整形
  • 役割(責務[補足注3])分離
  • テストしやすさ
  • セキュリティの視点

単なる文法学習ではなく、 「どう書くと読みやすいか」「どう書くと保守しやすいか」 を考えやすい題材です。


0. 作業ディレクトリ作成

任意の場所で作業用ディレクトリを作成します。

mkdir python_practical_series_01
cd python_practical_series_01

仮想環境を作成します。

python -m venv .venv

仮想環境を有効化します。

macOS / Linux:

source .venv/bin/activate

Windows PowerShell:

.venv\Scripts\Activate.ps1

必要なパッケージをインストールします。

python -m pip install fastapi "uvicorn[standard]"

今回のファイル構成

最終的に、今回のディレクトリはこうなります。

python_practical_series_01/
├─ .venv/
├─ app_bad.py
├─ app_better.py
├─ domain.py
├─ reuse_bad.py
├─ smoke_test_bad.py
└─ smoke_test_better.py

1. まずは「ありがちな実装」を動かす

最初に、とりあえず動くけど、だんだんつらくなりそうな実装を動かします。

app_bad.py を作成してください。

from fastapi import FastAPI, HTTPException

app = FastAPI(title="Bad Order API")

# サンプル用の簡易 DB
# 本来は DB や repository にあるものを、今回は辞書で表現している
fake_db = {
    "items": {
        1: {"name": "Keyboard", "price": 5000, "stock": 10},
        2: {"name": "Mouse", "price": 3000, "stock": 0},
    },
    "orders": []
}


@app.post("/orders")
async def create_order(user_id: int, item_id: int, quantity: int):
    # ユーザーIDの妥当性チェック
    if user_id <= 0:
        raise HTTPException(status_code=400, detail="invalid user_id")

    # 商品が存在するかを確認する
    if item_id not in fake_db["items"]:
        raise HTTPException(status_code=404, detail="item not found")

    # 商品データを取り出す
    item = fake_db["items"][item_id]

    # 数量が 0 以下なら不正
    if quantity <= 0:
        raise HTTPException(status_code=400, detail="quantity must be positive")

    # 在庫不足なら注文できない
    if item["stock"] < quantity:
        raise HTTPException(status_code=400, detail="out of stock")

    # 合計金額を計算する
    total_price = item["price"] * quantity

    # 注文データを辞書で作る
    order = {
        "user_id": user_id,
        "item_id": item_id,
        "quantity": quantity,
        "total_price": total_price,
    }

    # 保存処理と在庫更新もこの関数の中で行っている
    fake_db["orders"].append(order)
    item["stock"] -= quantity

    # HTTP レスポンスもこの関数の中で組み立てている
    return {
        "message": "order created",
        "order": order,
    }

補足: FastAPI

ざっくりいうと
FastAPI は、Python で Web API を組み立てるためのフレームワークです。URL と Python の関数をつなげて、JSON を返す API を作れます。

この場面でできること

  • POST /orders のような API を定義できる
  • 入力値チェックやエラー応答を書きやすい
  • Swagger UI (/docs) で動作確認しやすい

最小例

from fastapi import FastAPI  # FastAPI 本体を読み込む

app = FastAPI()              # API アプリ本体を作る

@app.get("/hello")           # GET /hello にアクセスしたときの処理を定義する
def hello() -> dict:         # 返り値は JSON にしやすい dict にしている
    return {"message": "hello"}  # API のレスポンスを返す

ではサーバーを起動してみます。

python -m uvicorn app_bad:app --reload --port 8000

ブラウザで以下を開いてください。

http://127.0.0.1:8000/docs

POST /orders を開き、Try it out から以下の値で以下を実行します。
image.png

  • user_id=1
  • item_id=1
  • quantity=2

出力結果は次の通りです。

{
  "message": "order created",
  "order": {
    "user_id": 1,
    "item_id": 1,
    "quantity": 2,
    "total_price": 10000
  }
}

ブラウザを使わず確認したい人向け

smoke_test_bad.py も作っておくと便利です。

import json
import urllib.parse
import urllib.request

# クエリパラメータを作る
params = urllib.parse.urlencode({
    "user_id": 1,
    "item_id": 1,
    "quantity": 2,
})

# app_bad.py に対して POST リクエストを送る
url = f"http://127.0.0.1:8000/orders?{params}"
request = urllib.request.Request(url, method="POST")

# レスポンス JSON を読みやすい形で表示する
with urllib.request.urlopen(request) as response:
    body = json.loads(response.read().decode("utf-8"))
    print(json.dumps(body, ensure_ascii=False, indent=2))

別ターミナルで以下を実行します。

python smoke_test_bad.py

このコードは何が良くて、何がつらいのか

このコードは、少なくとも動きます
学習用や小さな検証なら、十分役に立ちます。

ただ、実務で長く使うには少しずつ厳しくなります。

ここは読むだけだと伝わりにくいので、あえてダメな変更を入れて、壊れ方を体験してみます。


ハンズオン1. dict の typo に弱いことを体験する

まずは app_bad.py のこの行を探してください。

# 元の正しい実装
# 商品価格 × 数量で合計金額を計算する
total_price = item["price"] * quantity

これを、わざと typo して次のように変えます。

# わざと typo した例
# dict はキー名の間違いが実行するまで見つかりにくい
total_price = item["prcie"] * quantity

保存したら、そのままもう一度 POST /orders を以下を実行します。

  • user_id=1
  • item_id=1
  • quantity=2

すると、今度は成功せず、500エラーになります。

Uvicorn を起動しているターミナルを見ると、だいたい次のようなエラーが出ます。

KeyError: 'prcie'

ここで何がつらいのか

この壊れ方のつらいところは、キー名の間違いが実行するまで分からないことです。

しかも、

  • 書いている瞬間には気づきにくい
  • そのコードパスを通るまで発覚しない
  • 失敗したときの見え方が「500エラー」になりがち

という点がしんどいです。

dict は手軽ですが、項目の意味や構造がコード上に出にくく、typo にも弱いです。

元に戻しておきます。

# typo 確認後は元に戻す
total_price = item["price"] * quantity

ハンズオン2. HTTP と業務ロジックが強く結びついていて分けにくいことを体験する

次は、app_bad.pycreate_order()FastAPI の外から再利用しようとしたらどうなるかを見ます。ここでは、アプリの本題となる処理やルール(業務ロジック[補足注2])と HTTP が強く結びついていて分けにくい状態(密結合[補足注4])になっている点を確認します。

reuse_bad.py を作成してください。

import asyncio

from fastapi import HTTPException
from app_bad import create_order


async def main():
    try:
        # FastAPI の外から業務処理を再利用しようとしている
        await create_order(user_id=1, item_id=2, quantity=1)
    except HTTPException as e:
        # HTTP サーバーではないのに HTTPException を理解する必要がある
        print(type(e).__name__)
        print(f"status_code={e.status_code}")
        print(f"detail={e.detail}")


asyncio.run(main())

以下を実行します。

python reuse_bad.py

出力例はこうです。

HTTPException
status_code=400
detail=out of stock

ここで何がつらいのか

このスクリプトは、HTTP サーバーではありません。
ただの Python スクリプトです。

それなのに、注文を作るための処理(注文作成ロジック)を使うために HTTPException[注2] を理解しないといけません。

つまり、業務ロジックの再利用先が

  • CLI
  • バッチ
  • 別 API
  • テストコード

であっても、FastAPI / HTTP の都合が漏れてきます。

これが、HTTP と業務ロジックが密結合している状態のつらさです。


ハンズオン3. 責務が多い関数は、仕様変更ですぐ太る

次は「この関数に新しい仕様を足すと、どれくらい散らかるか」を体験します。

今回は、次の仕様を追加してみてください。

3個以上まとめ買いしたら 1000 円引きにする

app_bad.pycreate_order() に、次の変更を入れます。

まず total_price を計算している部分を、次のように変えます。

# 3個以上まとめ買いなら 1000 円引きする
discount = 1000 if quantity >= 3 else 0

# 割引後の合計金額を計算する
total_price = item["price"] * quantity - discount

次に orderdiscount を持たせます。

# 仕様追加に合わせて、保存データの形も変更する
order = {
    "user_id": user_id,
    "item_id": item_id,
    "quantity": quantity,
    "discount": discount,
    "total_price": total_price,
}

そのまま実行して、今度は次の条件でリクエストしてください。

  • user_id=1
  • item_id=1
  • quantity=3

出力結果は次の通りです。

{
  "message": "order created",
  "order": {
    "user_id": 1,
    "item_id": 1,
    "quantity": 3,
    "discount": 1000,
    "total_price": 14000
  }
}

ここで何がつらいのか

たったこれだけの仕様追加でも、エンドポイント関数の中で

  • 業務ルールの追加
  • 計算ロジックの変更
  • 保存データの変更
  • レスポンスの変更

を同時に触ることになります。

まだ数十行だから耐えられますが、ここにさらに

  • 会員ランク割引
  • クーポン適用
  • 権限チェック
  • 監査ログ
  • 在庫引当

などが入ってくると、一気に読みにくくなります。

これが、責務が多い関数は変更に弱いということです。

ここまで確認したら、app_bad.py は元の実装に戻しておいてください。


ここまでの時点で見えた「つらさ」

このハンズオンで体験した問題を整理すると、次の4つです。

1. HTTPと業務ロジックが密結合している

create_order の中に、

  • HTTPの都合
  • 入力検証
  • 在庫確認
  • 注文作成
  • 永続化
  • レスポンス整形

が全部入っています。

つまり、FastAPIのエンドポイント関数が何でもやっている状態です。

2. 責務が多すぎる

この関数は、

  • 入力を受け取る
  • 値を検証する
  • 商品を取得する
  • 在庫を確認する
  • 合計金額を計算する
  • 注文を保存する
  • レスポンスを返す

を一気にやっています。

この状態だと、読みづらく、テストしづらく、変更にも弱くなります。

3. dict ベースで意味が曖昧

item["price"]order["total_price"] は手軽ですが、

  • キー名 typo に弱い
  • データの意味が見えづらい
  • 型の恩恵が弱い
  • バリデーション責務が曖昧

という問題が出やすいです。

4. 業務処理が HTTPException を知っている

本来、注文作成ロジックはHTTPに依存しないはずです。
でもこの実装では、業務ルールの中に HTTPException が入り込んでいます。

この状態だと、あとでCLIやバッチから使い回したくなったときに扱いにくくなります。


2. 少し整理した版に置き換える

ここから、いきなり難しい設計パターンには行かず、まずは次の方向で改善します。

  1. データの意味をはっきりさせる
  2. 業務ルールをHTTP層から分ける
  3. 例外の境界を分ける

domain.py を作る

まず、注文や商品を dataclass[注1] で表現し、業務ロジックを分離します。

domain.py を作成してください。

from dataclasses import dataclass


# 業務上の失敗を意味のある例外で表す
class InvalidUserIdError(Exception):
    pass


class InvalidQuantityError(Exception):
    pass


class OutOfStockError(Exception):
    pass


# 商品を意味のあるデータ構造で表す
@dataclass(frozen=True)
class OrderItem:
    item_id: int
    name: str
    price: int


# 注文も意味のあるデータ構造で表す
@dataclass(frozen=True)
class Order:
    user_id: int
    item: OrderItem
    quantity: int

    @property
    def total_price(self) -> int:
        # 合計金額は注文が知っているルールとして持たせる
        return self.item.price * self.quantity


def create_order(user_id: int, item: OrderItem, quantity: int, stock: int) -> Order:
    # 業務ルールのチェックだけを担当する
    if user_id <= 0:
        raise InvalidUserIdError("user_id must be positive")

    if quantity <= 0:
        raise InvalidQuantityError("quantity must be positive")

    if stock < quantity:
        raise OutOfStockError("out of stock")

    # HTTP や保存処理を知らず、注文そのものを返す
    return Order(
        user_id=user_id,
        item=item,
        quantity=quantity,
    )

補足: dataclass

ざっくりいうと
dataclass は、「商品」や「注文」のように、関連する値をひとまとめで持たせたいときに使う標準機能です。データ用のクラスを、短く・読みやすく書けます。

この場面でできること

  • ItemOrder のようなデータ構造を、辞書より意味が伝わる形で表せる
  • item["price"] ではなく item.price のように読める
  • 初期化コードを短く保ちやすい

最小例

from dataclasses import dataclass          # dataclass を使うために import する

@dataclass(frozen=True)                    # データ用クラスとして定義する
class Item:                                # 商品を表すクラス名を付ける
    item_id: int                           # 商品IDを持たせる
    name: str                              # 商品名を持たせる
    price: int                             # 価格を持たせる

item = Item(item_id=1, name="Keyboard", price=5000)  # 商品データを作る
print(item.price)                          # 辞書ではなく属性で値を読む

ここで大事なのは、create_order()FastAPIを知らない ことです。

この関数は、

  • HTTPを知らない
  • FastAPIを知らない
  • 画面を知らない
  • バッチを知らない

という状態になっています。

つまり、純粋に「注文を作る業務ルール」だけを表現しているわけです。


app_better.py を作る

次に、FastAPI側ではHTTPの入出力と例外変換に集中させます。

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

from domain import (
    InvalidQuantityError,
    InvalidUserIdError,
    OrderItem,
    OutOfStockError,
    create_order,
)

app = FastAPI(title="Better Order API")


class CreateOrderRequest(BaseModel):
    # HTTP request body の形
    user_id: int
    item_id: int
    quantity: int


# 今回は簡易的に辞書を使うが、商品と注文は分けて管理する
fake_items = {
    1: {"name": "Keyboard", "price": 5000, "stock": 10},
    2: {"name": "Mouse", "price": 3000, "stock": 0},
}

fake_orders = []


@app.post("/orders")
async def create_order_endpoint(request: CreateOrderRequest):
    # API 層ではまず商品取得を行う
    item_data = fake_items.get(request.item_id)
    if item_data is None:
        raise HTTPException(status_code=404, detail="item not found")

    # 辞書データを domain の OrderItem へ変換する
    item = OrderItem(
        item_id=request.item_id,
        name=item_data["name"],
        price=item_data["price"],
    )

    try:
        # 業務ルール自体は domain.create_order() に委譲する
        order = create_order(
            user_id=request.user_id,
            item=item,
            quantity=request.quantity,
            stock=item_data["stock"],
        )
    except InvalidUserIdError as e:
        # 業務例外を HTTP 表現へ変換する
        raise HTTPException(status_code=400, detail=str(e))
    except InvalidQuantityError as e:
        raise HTTPException(status_code=400, detail=str(e))
    except OutOfStockError as e:
        raise HTTPException(status_code=400, detail=str(e))

    # 保存処理と在庫更新は API 側でまだ行っている
    fake_orders.append(order)
    item_data["stock"] -= order.quantity

    # レスポンス整形も API 層の責務として行う
    return {
        "message": "order created",
        "order": {
            "user_id": order.user_id,
            "item_id": order.item.item_id,
            "item_name": order.item.name,
            "quantity": order.quantity,
            "total_price": order.total_price,
        },
    }

改善版を起動する

今度は別ポートででは起動してみます。

python -m uvicorn app_better:app --reload --port 8001

ブラウザで以下を開きます。

http://127.0.0.1:8001/docs

POST /orders を開き、次のJSONで実行してください。

{
  "user_id": 1,
  "item_id": 1,
  "quantity": 2
}

出力結果は次の通りです。

{
  "message": "order created",
  "order": {
    "user_id": 1,
    "item_id": 1,
    "item_name": "Keyboard",
    "quantity": 2,
    "total_price": 10000
  }
}

スクリプトから試す

smoke_test_better.py を作成します。

import json
import urllib.request

# 改善版 API に送る request body
payload = {
    "user_id": 1,
    "item_id": 1,
    "quantity": 2,
}

request = urllib.request.Request(
    url="http://127.0.0.1:8001/orders",
    data=json.dumps(payload).encode("utf-8"),
    headers={"Content-Type": "application/json"},
    method="POST",
)

# app_better.py のレスポンスを確認する
with urllib.request.urlopen(request) as response:
    body = json.loads(response.read().decode("utf-8"))
    print(json.dumps(body, ensure_ascii=False, indent=2))

以下を実行します。

python smoke_test_better.py

異常系も確認する

今度は quantity0 に変えてみてください。

# 異常系を確認するため、quantity を 0 に変える
payload = {
    "user_id": 1,
    "item_id": 1,
    "quantity": 0
}

この場合は 400 エラーになり、レスポンスはこうなります。

{
  "detail": "quantity must be positive"
}

つまり、

  • 業務ルール違反は domain.py 側で判定する
  • HTTPレスポンスへの変換は app_better.py 側でやる

という境界が作れています。


3. 何が良くなったのかを整理する

ここが今回の本題です。

まだ大規模なアプリではありませんが、この時点でもかなり違いがあります。


1. データの意味がコードに表れるようになった

dict から dataclass に変えたことで、

  • 何が商品か
  • 何が注文か
  • 合計金額はどこに属する知識か

が見えやすくなりました。

Order.total_price のように、注文に属するロジックは注文の近くに置く、という考え方ができます。


2. 業務ロジックがFastAPIから独立した

create_order() はFastAPIを知りません。

この状態だと、あとで同じ注文作成ロジックを

  • CLI
  • バッチ
  • 別API
  • テストコード

から使い回しやすくなります。


3. 例外の境界がはっきりした

改善前は HTTPException を直接投げていました。

改善後は、

  • 業務の異常は業務例外
  • HTTPの失敗表現はHTTP例外

と分けています。

この差は、最初は地味に見えます。
でも実務ではかなり効いてきます。


4. テストしやすくなった

たとえば domain.pycreate_order() は、将来的にこういうテストが書きやすくなります。

def test_create_order_returns_order():
    # domain.py の create_order() は FastAPI なしで直接テストしやすい
    item = OrderItem(item_id=1, name="Keyboard", price=5000)
    order = create_order(user_id=1, item=item, quantity=2, stock=10)

    # 合計金額が正しく計算されることを確認する
    assert order.total_price == 10000

まだ今回はテストまで書きませんが、テストしやすい形に寄せるという感覚はここで持っておくと強いです。


4. このシリーズで目指したいこと

このシリーズで目指したいのは、Pythonの機能を羅列することではありません。

目指したいのはむしろこちらです。

「この実装はなぜ良いのか / なぜ悪いのか」を、自分の言葉で説明できるようになること

同じ要件を満たすコードでも、実務ではかなり差が出ます。

  • とりあえず動くコード
  • 読みにくいコード
  • テストしにくいコード
  • 変更に弱いコード
  • 例外処理が雑なコード
  • セキュリティ的に危ないコード
  • 責務が分離されていて拡張しやすいコード

これらを区別できるようになると、Pythonの理解がかなり整理されると思っています。


5. クリーンアーキテクチャっぽく見るとどうか

今回の改善版を、少し設計寄りに見るとこう整理できます。

API層

  • FastAPIのエンドポイント
  • Request/Response
  • HTTPステータスコード
  • 例外変換

Domain層

  • Order
  • OrderItem
  • create_order()
  • 業務例外

この時点ではまだ UseCase 層や Repository 層は作っていません。
でも、 「どこに何を置くべきか」 の感覚は見え始めています。

今後の回では、ここに

  • UseCase
  • Repository
  • Infrastructure

を少しずつ足していきます。


6. セキュリティの観点も少しだけ意識する

第1回なので軽く触れるだけですが、注文作成APIでは最初から意識したい点があります。

クライアントの値を信用しない

たとえば total_price をクライアントから受け取る設計は危険です。

金額や権限のような重要な値は、サーバー側で計算・判定するべきです。

入力検証を曖昧にしない

  • 数量は正の整数か
  • 存在しない商品ではないか
  • 不正なユーザーIDではないか

こうした検証は、後回しにすると事故りやすいです。

内部事情をそのまま返さない

本番運用では、内部例外やスタックトレースをそのまま返さないことが重要です。


7. 今回の時点で整理できるPythonの知識

関数

  • 何を引数に取るべきか
  • 戻り値は何にするべきか
  • 副作用をどこに置くべきか

dataclass

  • 生の dict より意味が明確になる
  • データと振る舞いを近づけられる
  • 型の恩恵を受けやすい

例外

  • 異常系を明確に表現できる
  • HTTP例外と業務例外を分けられる

  • 可読性が上がる
  • 補完が効く
  • レビューしやすい
  • 静的解析と相性がよい

つまり、「Pythonを整理する」とは、文法を暗記することではなく、どこでどう使うとよいかを整理することだと思っています。


まとめ

今回は、シリーズの導入として、注文作成APIを題材に、雑な実装と少し整理した実装を実際に動かして比較しました。

  • Pythonの知識が散らかるのは自然
  • 文法単位ではなく「お題ベース」で整理すると理解が深まりやすい
  • 実装の良し悪しは、動くかどうかだけでは決まらない
  • dictdataclass に置き換えるだけでも、意味がかなり明確になる
  • 業務ロジックをFastAPIから分けると、再利用しやすく、テストしやすくなる
  • 例外の境界を分けると、設計の見通しが良くなる

全10回くらいを想定して順次記事にできればと思っています。生暖かい目で見守っていただけると幸いです :pig2:


記事中記載事項の補足

補足注1

注文作成API
「注文を受け付けて、内容を確認し、結果を返すAPI」のことです。今回の題材では、商品・数量・在庫を見て注文できるか判断する入口を指します。

補足注2

業務ロジック
アプリの“本題”になる処理です。今回なら「数量が正しいか」「在庫が足りるか」「合計金額はいくらか」を決める部分です。

補足注3

責務
そのコードが担当する役割のことです。1つの関数が入力確認・計算・保存・レスポンス作成まで全部やると、責務が多すぎて読みにくくなります。

補足注4

密結合
2つのコードが強く依存していて、片方を変えるともう片方も一緒に直す必要が出やすい状態です。

参考文献

注1

Python 公式ドキュメント, dataclasses — Data Classes
https://docs.python.org/3/library/dataclasses.html

注2

FastAPI 公式ドキュメント, Handling Errors
https://fastapi.tiangolo.com/tutorial/handling-errors/

10
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?