🐍

Pythonの型ヒントと共に進化するコード(#9: モジュール分割)

に公開
これまでの連載記事


前回は Protocol を導入して ABC の辛みを解消しました。

Protocol でテスト容易性という強みも得られました。
ただ、address_fetcher.py という一枚岩のスクリプトがごちゃついているのが気になります。

ごちゃついている一枚岩のスクリプト
address_fetcher.py
from typing import Protocol, TypedDict, NewType, cast
import json as json_lib
import requests as requests_lib

type Headers = dict[str, str]
type JsonObject = dict[str, object]

ZipCode = NewType("ZipCode", str)


class HttpResponse(Protocol):
    @property
    def status_code(self) -> int: ...
    def json(self) -> object: ...


class HttpClient(Protocol):
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse: ...


class RequestsResponse:
    def __init__(self, response: requests_lib.Response):
        self._response = response

    @property
    def status_code(self) -> int:
        return self._response.status_code

    def json(self) -> object:
        return self._response.json()


class RequestsHttpClient:
    def __init__(self):
        self._session = requests_lib.Session()

    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> RequestsResponse:
        response = self._session.post(url, json=json, headers=headers)
        return RequestsResponse(response)


class AddressDict(TypedDict):
    zipcode: str
    prefecture: str
    prefecture_kana: str
    city: str
    city_kana: str
    town: str
    town_kana: str


class FormattedAddressDict(TypedDict):
    zipcode: str
    full_address: str
    prefecture: str
    city: str
    town: str


class FormattedAddressWithKanaDict(FormattedAddressDict):
    full_address_kana: str


def _build_full_address(address_data: AddressDict) -> str:
    return address_data["prefecture"] + address_data["city"] + address_data["town"]


def fetch_and_format_address(
    zipcode: ZipCode,
    include_kana: bool,
    http_client: HttpClient,
    headers: Headers | None = None,
) -> str | None:
    api_url = "https://api.zipcode-jp.example/v1/address"

    try:
        response = http_client.post(api_url, json={"zipcode": zipcode}, headers=headers)
        if response.status_code != 200:
            print(f"Error: Failed to fetch address. Status: {response.status_code}")
            return None

        # HTTPクライアントは「任意のJSON」を返すまでが責務。
        # 「このAPIはAddressDictを返す」という知識はドメイン側の責務。
        address_data = cast(AddressDict, response.json())

        full_address = _build_full_address(address_data)

        result: FormattedAddressDict = {
            "zipcode": zipcode,
            "full_address": full_address,
            "prefecture": address_data["prefecture"],
            "city": address_data["city"],
            "town": address_data["town"],
        }

        if include_kana:
            result_with_kana: FormattedAddressWithKanaDict = {
                **result,
                "full_address_kana": (
                    address_data["prefecture_kana"]
                    + address_data["city_kana"]
                    + address_data["town_kana"]
                ),
            }
            return json_lib.dumps(result_with_kana, indent=2, ensure_ascii=False)

        return json_lib.dumps(result, indent=2, ensure_ascii=False)

    except Exception as e:
        print(f"An error occurred: {e}")
        return None


if __name__ == "__main__":
    http_client = RequestsHttpClient()
    zipcode = ZipCode("1000001")
    result = fetch_and_format_address(zipcode, include_kana=True, http_client=http_client)
    if result is not None:
        print(result)

1 つのファイルが窮屈になってきた

今の address_fetcher.py を眺めてみると、いろんな役割のコードが混在しています。

  1. データ構造の定義
    • ZipCode, Headers, AddressDict, FormattedAddressDict ...
  2. HTTP 通信の責務
    • HttpClientプロトコル, RequestsHttpClientクラス ...
  3. ビジネスロジック(中核機能)
    • _build_full_address, fetch_and_format_address ...
  4. スクリプトの実行
    • if __name__ == "__main__":ブロック ...

これら全てが同じファイルに存在していてコードが密な状態になっています。

異なる責務を持つコードが 1 ファイルにまとまっていると以下のような辛みがあります。

  • 認知負荷が高い
    • 関係ないコードが視界に入ると「これは今関係あるんだっけ」と都度判断が必要になる
  • 再利用しにくい
    • HttpClient を別プロジェクトで使いたくても、関係のないドメインの構造体までついてくる

今は 150 行程度なので許容範囲かもですが、コードが成長するとなおさら辛くなります。なので今のうちにモジュール分割しておきます。

モジュールに分割する

それぞれの責務に対応するファイルを作ってコードを移していきます。

3 つのファイルは同じディレクトリに置いて python main.py で実行できる構成を想定しています。

  1. models.py

    • 責務: アプリケーションで共通して使うデータ構造を定義する
      • ドメイン固有の ZipCode や API レスポンスの構造に加えて、複数のモジュールで使う Headers もここに配置
    • 移管対象: ZipCode, Headers, AddressDict, FormattedAddressDict, FormattedAddressWithKanaDict
  2. http_client.py

    • 責務: HTTP 通信に関するインターフェースとその具体的な実装を提供する。
    • 移管対象: HttpClientプロトコル, RequestsHttpClientクラス
  3. main.py (元のaddress_fetcher.pyから改名)

    • 責務: アプリケーションの主要なビジネスロジックを実行し全体を組み立てる。
    • 移管対象: _build_full_address, fetch_and_format_address, if __name__ == "__main__":ブロック

これらの新しいファイルから必要な定義をimport文で読み込んで使います。

実際に分割してみる

変更前は address_fetcher.py に全てが入っていた状態です。これを 3 ファイルに分けます。

models.py

# models.py
from typing import TypedDict, NewType

ZipCode = NewType("ZipCode", str)
type Headers = dict[str, str]

class AddressDict(TypedDict):
    zipcode: str
    prefecture: str
    prefecture_kana: str
    city: str
    city_kana: str
    town: str
    town_kana: str

class FormattedAddressDict(TypedDict):
    zipcode: str
    full_address: str
    prefecture: str
    city: str
    town: str

class FormattedAddressWithKanaDict(FormattedAddressDict):
    full_address_kana: str

http_client.py

# http_client.py
from typing import Protocol
from models import Headers

type JsonObject = dict[str, object]

class HttpResponse(Protocol):
    @property
    def status_code(self) -> int: ...
    def json(self) -> object: ...

class HttpClient(Protocol):
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse: ...

class RequestsHttpClient:
    # requests.Session をラップして HttpClient Protocol を満たす実装
    ...

main.py

# main.py
import json

# 新しく作成したモジュールから必要なものをインポート
from models import ZipCode, Headers, AddressDict, FormattedAddressDict, FormattedAddressWithKanaDict
from http_client import HttpClient, RequestsHttpClient

def _build_full_address(address_data: AddressDict) -> str:
    # 処理省略
    ...

def fetch_and_format_address(
    zipcode: ZipCode,
    include_kana: bool,
    http_client: HttpClient,
    headers: Headers | None = None,
) -> str | None:
    # 処理省略
    ...

if __name__ == "__main__":
    # ここに実行ブロック
    ...

main.py では新しく作ったモジュールから ZipCodeHttpClient などを import して使います。

分割してみて分かったこと

これで各ファイルの役割がはっきりしました。

データ構造を変えたいときは models.py を、HTTP クライアントの挙動を変えたいときは http_client.py を見ればいいのです。迷わなくなりました。

そしてこのファイル分割で新たな問題点が見えてきました。

models.py を見てください。4 日目の記事で作った AddressDict は単なるデータの入れ物です。一方で _build_full_address のようなロジックは main.py に存在しています。

models.py
# データの定義(単なる入れ物)
class AddressDict(TypedDict):
    prefecture: str
    city: str
    town: str
    # ...
main.py
# データを操作するロジック(別の場所に存在)
def _build_full_address(address_data: AddressDict) -> str:
    return address_data["prefecture"] + address_data["city"] + address_data["town"]

データとロジックが分離してしまっています。_build_full_addressAddressDict がないと意味を持たないのに別の場所にいますね。

こういう強い関係性を持つデータとロジックは 1 つの単位としてまとめた方が、どこに何があるか分かりやすく修正時の影響範囲も把握しやすくなります。

次回予告

次回は TypedDict から dataclasses へステップアップしてデータとロジックをカプセル化する設計に進化させていきます。

処方後のコードはこちら

この章ではコードの「内容」ではなく「配置」が変わっただけなので 3 つのファイルそれぞれの全量コードを掲載します

models.py

# models.py
from typing import TypedDict, NewType

ZipCode = NewType("ZipCode", str)
type Headers = dict[str, str]

class AddressDict(TypedDict):
    zipcode: str
    prefecture: str
    prefecture_kana: str
    city: str
    city_kana: str
    town: str
    town_kana: str

class FormattedAddressDict(TypedDict):
    zipcode: str
    full_address: str
    prefecture: str
    city: str
    town: str

class FormattedAddressWithKanaDict(FormattedAddressDict):
    full_address_kana: str

http_client.py

# http_client.py
import requests as requests_lib
from typing import Protocol

from models import Headers

type JsonObject = dict[str, object]


class HttpResponse(Protocol):
    @property
    def status_code(self) -> int: ...
    def json(self) -> object: ...


class HttpClient(Protocol):
    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> HttpResponse: ...


class RequestsResponse:
    def __init__(self, response: requests_lib.Response):
        self._response = response

    @property
    def status_code(self) -> int:
        return self._response.status_code

    def json(self) -> object:
        return self._response.json()


class RequestsHttpClient:
    def __init__(self) -> None:
        self._session = requests_lib.Session()

    def post(self, url: str, json: JsonObject, headers: Headers | None = None) -> RequestsResponse:
        response = self._session.post(url, json=json, headers=headers)
        return RequestsResponse(response)

main.py

# main.py (旧 address_fetcher.py)
import json
from typing import cast

from models import ZipCode, Headers, AddressDict, FormattedAddressDict, FormattedAddressWithKanaDict
from http_client import HttpClient, RequestsHttpClient

def _build_full_address(address_data: AddressDict) -> str:
    return address_data["prefecture"] + address_data["city"] + address_data["town"]

def fetch_and_format_address(
    zipcode: ZipCode,
    include_kana: bool,
    http_client: HttpClient,
    headers: Headers | None = None,
) -> str | None:
    """郵便番号から住所を取得し、整形して返す"""

    api_url = "https://api.zipcode-jp.example/v1/address"

    try:
        response = http_client.post(api_url, json={"zipcode": zipcode}, headers=headers)
        if response.status_code != 200:
            print(f"Error: Failed to fetch address. Status: {response.status_code}")
            return None

        # HTTPクライアントは「任意のJSON」を返すまでが責務。
        # 「このAPIはAddressDictを返す」という知識はドメイン側の責務。
        address_data = cast(AddressDict, response.json())

        full_address = _build_full_address(address_data)

        result: FormattedAddressDict = {
            "zipcode": zipcode,
            "full_address": full_address,
            "prefecture": address_data["prefecture"],
            "city": address_data["city"],
            "town": address_data["town"],
        }

        if include_kana:
            result_with_kana: FormattedAddressWithKanaDict = {
                **result,
                "full_address_kana": (
                    address_data["prefecture_kana"]
                    + address_data["city_kana"]
                    + address_data["town_kana"]
                ),
            }
            return json.dumps(result_with_kana, indent=2, ensure_ascii=False)

        return json.dumps(result, indent=2, ensure_ascii=False)

    except Exception as e:
        print(f"An error occurred: {e}")
        return None

# 実行例
if __name__ == "__main__":
    http_client = RequestsHttpClient()
    zipcode = ZipCode("1000001")
    result = fetch_and_format_address(
        zipcode, include_kana=True, http_client=http_client
    )
    if result is not None:
        print(result)

Discussion