概要
Protocol で構造的サブタイピング(Ch6)
class NotificationChannel(Protocol): でインターフェースを定義。具体クラスが Protocol を明示継承しなくても、send() と channel_type を実装していれば型互換とみなされる(鴨型付け)。Open/Closed 原則を自然に実現し、新チャネル追加時に既存コードの変更ゼロを保証する。
frozen dataclass slots=True で値オブジェクト(Ch4)
@dataclass(frozen=True, slots=True) の SendResult は FrozenInstanceError で書き換え禁止・__dict__ なしで軽量。failed_ids: tuple[str, ...] は list だと hashable にならないため tuple を選択する。
StrEnum × match 文でマジックストリング排除(Ch2/Ch11)
class ChannelType(StrEnum): EMAIL = "email" ... でチャネル種別を型安全に管理。match channel_type: case ChannelType.EMAIL: return "high" ... の case _: raise ValueError で新チャネル追加時のハンドリング漏れを実行時に検出できる。
functools.reduce で中間リストなし集計(Ch11)
functools.reduce(merge, results, initial) で送信件数・成功件数を集計。list.extend を繰り返すよりメモリ効率が高く、initial を指定することで空リスト時も安全に動作する。
問題
ECサイト MOps チームでは、複数チャネル(メール・プッシュ通知・LINE)からキャンペーン通知を送信するバッチを Python で実装している。以下の「悪いコード」は if/elif の分岐でチャネルごとの処理を行い、結果を dict で返す実装です。
問題点を全て洗い出し、Protocol・TypeVar[bound=NotificationChannel]・@dataclass(frozen=True, slots=True)・StrEnum・match 文・functools.reduce を使って Bad→Good にリファクタリングしてください。
制約・前提条件
- Python 3.12+(PEP 695 型エイリアス構文使用可)
Protocolでsend()インターフェースを定義し、isinstanceチェックを廃止することTypeVar[bound=NotificationChannel]で型安全なディスパッチ関数を定義すること- 送信結果は
@dataclass(frozen=True, slots=True)のSendResultで返すこと StrEnum ChannelTypeでチャネル種別を型安全に管理することmatch文でチャネル種別に応じた優先度ラベルを返すことfunctools.reduceで全チャネルの送信件数・成功件数を集計すること- Google スタイル docstring・インラインコメント・名前付き定数を含めること
悪いコード (Before)
import logging
logger = logging.getLogger(__name__)
# 問題1: マジックストリング — "email", "push", "line" が散在
CHANNELS = ["email", "push", "line"]
def send_email(recipient_ids, message, campaign_id):
# 問題2: 型ヒントなし・返り値が dict(型安全性ゼロ)
logger.info("Email: %d 件送信", len(recipient_ids))
success = recipient_ids[:90]
failed = recipient_ids[90:]
return {"sent": len(recipient_ids), "success": len(success), "failed": failed}
def send_push(recipient_ids, message, campaign_id):
logger.info("Push: %d 件送信", len(recipient_ids))
success = recipient_ids[:95]
failed = recipient_ids[95:]
return {"sent": len(recipient_ids), "success": len(success), "failed": failed}
def send_line(recipient_ids, message, campaign_id):
logger.info("LINE: %d 件送信", len(recipient_ids))
success = recipient_ids[:85]
failed = recipient_ids[85:]
return {"sent": len(recipient_ids), "success": len(success), "failed": failed}
def run_campaign(campaign_id, recipient_ids, message, channels):
results = []
for ch in channels:
# 問題3: isinstance/str 比較で Open/Closed 原則違反
if ch == "email":
r = send_email(recipient_ids, message, campaign_id)
r["channel"] = "email"
# 問題4: 優先度が if/elif でハードコード
r["priority"] = "high"
elif ch == "push":
r = send_push(recipient_ids, message, campaign_id)
r["channel"] = "push"
r["priority"] = "medium"
elif ch == "line":
r = send_line(recipient_ids, message, campaign_id)
r["channel"] = "line"
r["priority"] = "low"
else:
# 問題5: 新チャネル追加時にここが silent に通り抜ける
logger.warning("未知のチャネル: %s", ch)
continue
results.append(r)
# 問題6: 集計がベタ書き for ループ(functools.reduce 未使用)
total_sent = 0
total_success = 0
total_failed = 0
for r in results:
total_sent += r["sent"]
total_success += r["success"]
# 問題7: r["failed"] は list — イミュータブルでなく後から変更可能
total_failed += len(r["failed"])
return {
"total_sent": total_sent,
"total_success": total_success,
"total_failed": total_failed,
}
"email", "push", "line" が散在。StrEnum ChannelType で型安全に管理frozen dataclass SendResult で型安全な値オブジェクトにelif を追記。Protocol で dispatch に統一し変更ゼロにmatch 文 + case _: raise ValueError で網羅性チェック付きにValueError raise で早期検出(Fail-Fast)functools.reduce(merge, results, initial) で宣言的・メモリ効率的にtuple[str, ...] でイミュータブルにヒント(段階的開示)
ヒント1 — 方向性
Protocol は typing.Protocol を継承したクラスで、メソッドシグネチャだけを定義する「インターフェース」。具体クラスが Protocol を明示継承しなくても、メソッドが一致すれば型チェッカーが互換性を認識する(構造的サブタイピング)。isinstance(ch, EmailChannel) の分岐を廃止して ch.send(payload) で統一できる。match 文の case _: raise ValueError で未知チャネルの silent スルーを防ぐ。
ヒント2 — アプローチ
class ChannelType(StrEnum): EMAIL = "email"; PUSH = "push"; LINE = "line"でマジックストリング排除class NotificationPayload(BaseModel): recipient_ids: list[str] = Field(min_length=1)で外部入力バリデーション@dataclass(frozen=True, slots=True) class SendResult: failed_ids: tuple[str, ...]で値オブジェクトclass NotificationChannel(Protocol): def send(self, payload: NotificationPayload) -> SendResult: ...ChannelT = TypeVar("ChannelT", bound=NotificationChannel)で型安全なディスパッチ関数match channel_type: case ChannelType.EMAIL: return "high" ... case _: raise ValueErrorfunctools.reduce(lambda acc, r: {**acc, "total_sent": acc["total_sent"] + r.sent_count}, results, initial)
ヒント3 — コードの骨格
from typing import Protocol, TypeVar
from enum import StrEnum
from dataclasses import dataclass
import functools
class ChannelType(StrEnum):
EMAIL = "email"; PUSH = "push"; LINE = "line"
@dataclass(frozen=True, slots=True)
class SendResult:
channel_type: ChannelType
sent_count: int
success_count: int
failed_ids: tuple[str, ...]
@property
def success_rate(self) -> float:
return self.success_count / self.sent_count if self.sent_count else 0.0
@property
def failure_count(self) -> int:
return self.sent_count - self.success_count
class NotificationChannel(Protocol):
@property
def channel_type(self) -> ChannelType: ...
def send(self, payload: NotificationPayload) -> SendResult: ...
ChannelT = TypeVar("ChannelT", bound=NotificationChannel)
def priority_label(channel_type: ChannelType) -> str:
match channel_type:
case ChannelType.EMAIL: return "high"
case ChannelType.PUSH: return "medium"
case ChannelType.LINE: return "low"
case _: raise ValueError(f"未知チャネル: {channel_type!r}")
def dispatch(channel: ChannelT, payload: NotificationPayload) -> SendResult:
return channel.send(payload)
def aggregate_results(results: list[SendResult]) -> dict[str, int]:
def merge(acc: dict[str, int], r: SendResult) -> dict[str, int]:
return {
"total_sent": acc["total_sent"] + r.sent_count,
"total_success": acc["total_success"] + r.success_count,
"total_failed": acc["total_failed"] + r.failure_count,
}
return functools.reduce(merge, results, {"total_sent": 0, "total_success": 0, "total_failed": 0})
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | マジックストリング散在 | 型安全 Ch2 | StrEnum ChannelType |
| 2 | 型ヒントなし・dict 返し | 型安全 Ch2/Ch4 | frozen dataclass SendResult |
| 3 | str 比較で OCP 違反 | 設計原則 Ch6 | Protocol で dispatch 統一 |
| 4 | 優先度が if/elif ハードコード | 設計 Ch7/Ch11 | match 文 + case _: raise ValueError |
| 5 | 未知チャネルが silent continue | Fail-Fast Ch10 | ValueError raise で早期検出 |
| 6 | 集計がベタ書き for ループ | 関数型 Ch11 | functools.reduce で宣言的に |
| 7 | failed が mutable list | 不変性 Ch4 | tuple[str, ...] でイミュータブルに |
模範解答
# 問題1: マジックストリング
CHANNELS = ["email", "push", "line"]
def send_email(recipient_ids, message, campaign_id):
# 問題2: 型ヒントなし・dict 返し
success = recipient_ids[:90]
failed = recipient_ids[90:]
return {"sent": len(recipient_ids), "success": len(success), "failed": failed}
def run_campaign(campaign_id, recipient_ids, message, channels):
results = []
for ch in channels:
# 問題3: str 比較で OCP 違反
if ch == "email":
r = send_email(recipient_ids, message, campaign_id)
r["channel"] = "email"
# 問題4: 優先度が if/elif ハードコード
r["priority"] = "high"
elif ch == "push":
...
else:
# 問題5: 未知チャネルが silent continue
logger.warning("未知: %s", ch)
continue
results.append(r)
# 問題6: 集計がベタ書き for ループ
total_sent = total_success = total_failed = 0
for r in results:
total_sent += r["sent"]
total_success += r["success"]
# 問題7: r["failed"] が mutable list
total_failed += len(r["failed"])
return {"total_sent": total_sent, ...}
"""notification_dispatcher.py — マルチチャネル通知ディスパッチャ
Ch2: StrEnum ChannelType / Protocol 構造的サブタイピング
Ch4: frozen dataclass slots=True SendResult(値オブジェクト)
Ch6: Protocol + TypeVar[bound=NotificationChannel](Open/Closed 原則)
Ch7: 名前付き定数(MAX_RETRY_COUNT, DEFAULT_PRIORITY)
Ch10: SendResult.failed_ids で部分失敗を型安全に保持
Ch11: match 文 優先度ラベル / functools.reduce 集計
"""
from __future__ import annotations
import functools, logging
from dataclasses import dataclass
from enum import StrEnum
from typing import Final, Protocol, TypeVar, runtime_checkable
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
# ── 名前付き定数(Ch7)
MAX_RETRY_COUNT: Final[int] = 3
DEFAULT_PRIORITY: Final[str] = "medium"
# ── StrEnum(Ch2)
class ChannelType(StrEnum):
EMAIL = "email"
PUSH = "push"
LINE = "line"
# ── 入力バリデーション(Pydantic v2)
class NotificationPayload(BaseModel):
model_config = {"extra": "ignore"}
campaign_id: str
recipient_ids: list[str] = Field(min_length=1) # 空リスト禁止
message: str = Field(min_length=1)
# ── 値オブジェクト(Ch4)
@dataclass(frozen=True, slots=True) # frozen: 書き換え禁止 / slots: 軽量化
class SendResult:
"""送信結果のイミュータブル値オブジェクト。"""
channel_type: ChannelType
sent_count: int
success_count: int
failed_ids: tuple[str, ...] # tuple: hashable かつ slots 対応
@property
def success_rate(self) -> float:
"""成功率(ゼロ除算ガード付き)。"""
return self.success_count / self.sent_count if self.sent_count else 0.0
@property
def failure_count(self) -> int:
return self.sent_count - self.success_count
# ── Protocol(Ch6 / Ch2)
@runtime_checkable # isinstance が必要な場合のみ
class NotificationChannel(Protocol):
"""通知チャネルのインターフェース(構造的サブタイピング)。"""
@property
def channel_type(self) -> ChannelType: ...
def send(self, payload: NotificationPayload) -> SendResult: ...
# ── TypeVar(bound で Protocol を制約)
ChannelT = TypeVar("ChannelT", bound=NotificationChannel)
# ── 具体チャネル(Protocol を明示継承しなくてよい)
class EmailChannel:
@property
def channel_type(self) -> ChannelType:
return ChannelType.EMAIL
def send(self, payload: NotificationPayload) -> SendResult:
logger.info("[Email] %s: %d 件", payload.campaign_id, len(payload.recipient_ids))
success_ids = payload.recipient_ids[:90] # stub: 90% 成功
failed_ids = tuple(payload.recipient_ids[90:])
return SendResult(
channel_type=ChannelType.EMAIL,
sent_count=len(payload.recipient_ids),
success_count=len(success_ids),
failed_ids=failed_ids,
)
class PushChannel:
@property
def channel_type(self) -> ChannelType:
return ChannelType.PUSH
def send(self, payload: NotificationPayload) -> SendResult:
logger.info("[Push] %s: %d 件", payload.campaign_id, len(payload.recipient_ids))
success_ids = payload.recipient_ids[:95] # stub: 95% 成功
failed_ids = tuple(payload.recipient_ids[95:])
return SendResult(
channel_type=ChannelType.PUSH,
sent_count=len(payload.recipient_ids),
success_count=len(success_ids),
failed_ids=failed_ids,
)
class LineChannel:
@property
def channel_type(self) -> ChannelType:
return ChannelType.LINE
def send(self, payload: NotificationPayload) -> SendResult:
logger.info("[LINE] %s: %d 件", payload.campaign_id, len(payload.recipient_ids))
success_ids = payload.recipient_ids[:85] # stub: 85% 成功
failed_ids = tuple(payload.recipient_ids[85:])
return SendResult(
channel_type=ChannelType.LINE,
sent_count=len(payload.recipient_ids),
success_count=len(success_ids),
failed_ids=failed_ids,
)
# ── 優先度ラベル(match 文 Ch11)
def priority_label(channel_type: ChannelType) -> str:
"""チャネル種別に応じた優先度ラベルを返す。
Raises:
ValueError: 未知チャネル種別。
"""
match channel_type:
case ChannelType.EMAIL:
return "high" # 開封率重視キャンペーンに最優先
case ChannelType.PUSH:
return "medium"
case ChannelType.LINE:
return "low"
case _:
# 新チャネル追加時のハンドリング漏れを Fail-Fast で検出
raise ValueError(f"未知チャネル: {channel_type!r}")
# ── 型安全ディスパッチ(TypeVar bound)
def dispatch(channel: ChannelT, payload: NotificationPayload) -> SendResult:
"""Protocol 準拠オブジェクトに send() を委譲する薄いラッパー。"""
label = priority_label(channel.channel_type)
logger.debug("ch=%s priority=%s recipients=%d",
channel.channel_type, label, len(payload.recipient_ids))
return channel.send(payload)
# ── 集計(functools.reduce)
def aggregate_results(results: list[SendResult]) -> dict[str, int]:
"""全 SendResult を functools.reduce で集計する。"""
def merge(acc: dict[str, int], r: SendResult) -> dict[str, int]:
return {
"total_sent": acc["total_sent"] + r.sent_count,
"total_success": acc["total_success"] + r.success_count,
"total_failed": acc["total_failed"] + r.failure_count,
}
initial: dict[str, int] = {"total_sent": 0, "total_success": 0, "total_failed": 0}
return functools.reduce(merge, results, initial)
# ── メイン処理
def run_campaign(
campaign_id: str,
recipient_ids: list[str],
message: str,
channels: list[NotificationChannel],
) -> dict[str, int]:
"""マルチチャネルにキャンペーン通知を送信し集計結果を返す。"""
payload = NotificationPayload(
campaign_id=campaign_id,
recipient_ids=recipient_ids,
message=message,
)
results = [dispatch(ch, payload) for ch in channels]
for r in results:
logger.info("ch=%s priority=%s rate=%.1f%% failed=%d",
r.channel_type, priority_label(r.channel_type),
r.success_rate * 100, r.failure_count)
return aggregate_results(results)
import logging
logging.basicConfig(level=logging.INFO)
# 宛先 ID を 100 件生成
recipient_ids = [f"user-{i:03d}" for i in range(100)]
# チャネルを Protocol 準拠オブジェクトとして用意
channels: list[NotificationChannel] = [
EmailChannel(),
PushChannel(),
LineChannel(),
]
# run_campaign 実行
summary = run_campaign(
campaign_id="CP-2026-SUMMER",
recipient_ids=recipient_ids,
message="夏セール開始!今すぐチェック",
channels=channels,
)
print(summary)
# INFO [Email] CP-2026-SUMMER: 100 件
# INFO ch=email priority=high rate=90.0% failed=10
# INFO [Push] CP-2026-SUMMER: 100 件
# INFO ch=push priority=medium rate=95.0% failed=5
# INFO [LINE] CP-2026-SUMMER: 100 件
# INFO ch=line priority=low rate=85.0% failed=15
# {'total_sent': 300, 'total_success': 270, 'total_failed': 30}
# SendResult はイミュータブル
from decimal import Decimal
email_result = EmailChannel().send(
NotificationPayload(campaign_id="CP-001", recipient_ids=["u1", "u2"], message="test")
)
print(f"success_rate={email_result.success_rate:.0%}") # success_rate=100%(2件で閾値未満)
print(f"failed_ids={email_result.failed_ids}") # failed_ids=()
# frozen → 変更不可
try:
email_result.sent_count = 999 # type: ignore
except Exception as e:
print(f"FrozenInstanceError: {type(e).__name__}")
# 未知チャネルは ValueError(Fail-Fast)
try:
priority_label(ChannelType("sms")) # type: ignore
except ValueError as e:
print(f"ValueError: {e}")
# ChannelType は str 互換(StrEnum)
print(f"EMAIL == 'email': {ChannelType.EMAIL == 'email'}") # True
print(f"JSON 出力: {ChannelType.EMAIL!r}") # 'email'
| ポイント | 適用した設計原則/パターン | 書籍対応章 |
|---|---|---|
StrEnum ChannelType でマジックストリング排除 | 型の活用・マジックストリング排除 | Ch2 |
NotificationPayload(BaseModel) で入力バリデーション | 型の活用・入力検証 | Ch2 |
@dataclass(frozen=True, slots=True) SendResult | 値オブジェクト・不変性・軽量化 | Ch4 / Ch2 |
Protocol NotificationChannel で構造的サブタイピング | Open/Closed 原則・インターフェース分離 | Ch6 |
TypeVar[bound=NotificationChannel] で型安全 dispatch | 型の活用・ジェネリクス | Ch6 / Ch2 |
MAX_RETRY_COUNT, DEFAULT_PRIORITY 名前付き定数 | マジックナンバー排除 | Ch7 |
tuple[str, ...] で failed_ids をイミュータブル化 | 不変性・型安全 | Ch10 / Ch4 |
match ... case _: raise ValueError Fail-Fast | Fail-Fast・網羅性チェック | Ch10 / Ch11 |
functools.reduce で宣言的集計 | 関数型プログラミング・可読性 | Ch11 |
# tests/test_notification_dispatcher.py
import pytest
from notification_dispatcher import (
ChannelType, NotificationPayload, SendResult,
EmailChannel, PushChannel, LineChannel,
priority_label, dispatch, aggregate_results, run_campaign,
)
class TestChannelType:
def test_str_compatibility(self):
assert ChannelType.EMAIL == "email"
assert str(ChannelType.PUSH) == "push"
class TestNotificationPayload:
def test_valid_payload(self):
p = NotificationPayload(campaign_id="CP-001", recipient_ids=["u1"], message="test")
assert p.campaign_id == "CP-001"
def test_empty_recipient_ids_raises(self):
from pydantic import ValidationError
with pytest.raises(ValidationError):
NotificationPayload(campaign_id="CP-001", recipient_ids=[], message="test")
class TestSendResult:
def make_result(self, **kwargs):
defaults = dict(channel_type=ChannelType.EMAIL, sent_count=10, success_count=9, failed_ids=("u1",))
return SendResult(**{**defaults, **kwargs})
def test_success_rate(self):
r = self.make_result(sent_count=10, success_count=8)
assert r.success_rate == pytest.approx(0.8)
def test_zero_sent_rate(self):
r = self.make_result(sent_count=0, success_count=0)
assert r.success_rate == 0.0
def test_frozen(self):
r = self.make_result()
with pytest.raises(Exception):
r.sent_count = 999 # type: ignore
def test_failed_ids_is_tuple(self):
r = self.make_result(failed_ids=("u1", "u2"))
assert isinstance(r.failed_ids, tuple)
class TestPriorityLabel:
def test_known_channels(self):
assert priority_label(ChannelType.EMAIL) == "high"
assert priority_label(ChannelType.PUSH) == "medium"
assert priority_label(ChannelType.LINE) == "low"
def test_unknown_raises(self):
with pytest.raises(ValueError):
priority_label(ChannelType("sms")) # type: ignore
class TestAggregateResults:
def test_empty_list(self):
result = aggregate_results([])
assert result == {"total_sent": 0, "total_success": 0, "total_failed": 0}
def test_multiple_results(self):
results = [
SendResult(ChannelType.EMAIL, 100, 90, tuple(f"u{i}" for i in range(10))),
SendResult(ChannelType.PUSH, 100, 95, tuple(f"u{i}" for i in range(5))),
]
agg = aggregate_results(results)
assert agg["total_sent"] == 200
assert agg["total_success"] == 185
assert agg["total_failed"] == 15
class TestRunCampaign:
def test_full_run(self):
channels = [EmailChannel(), PushChannel(), LineChannel()]
recipients = [f"u{i}" for i in range(100)]
summary = run_campaign("CP-TEST", recipients, "msg", channels)
assert summary["total_sent"] == 300
assert summary["total_success"] == 270
assert summary["total_failed"] == 30
設計図(Protocol 構造的サブタイピング)
ポイント解説
Protocolで構造的サブタイピング(Ch6): 具体クラスがProtocolを明示継承しなくても、send()とchannel_typeを実装すれば型互換。Open/Closed 原則を自然に実現し、新チャネル追加時にdispatch()・aggregate_results()の変更ゼロを保証する@dataclass(frozen=True, slots=True) SendResult(Ch4):frozen=TrueでFrozenInstanceErrorによる書き換え禁止、slots=Trueで__dict__を排除して軽量化。failed_ids: tuple[str, ...]はlistだと hashable にならないためtupleを選択StrEnum ChannelType(Ch2):"email","push","line"のマジックストリングを排除。StrEnumはstrと互換性があるため JSON 出力・ログでも自然に使えるmatch文 +case _: raise ValueError(Ch10/Ch11):if/elifより可読性が高く、型チェッカーが網羅性を確認できる。case _:で Fail-Fast することで新チャネル追加時のハンドリング漏れを実行時に即検出functools.reduceで宣言的集計(Ch11): 中間リストの作成や副作用のある for ループを避けて、初期値initialから純粋関数mergeで集計。空リスト時もinitialがそのまま返るため安全
実務への応用
- ECサイト MOps: メール・プッシュ・LINE の 3 チャネルで同一キャンペーンを送信するシナリオが典型。
Protocolを使うことで 4 チャネル目(SMS, WebPush 等)を追加する際に既存のdispatch()・aggregate_results()を変更ゼロで対応できる - Argo Workflows: 各チャネルを独立した Step として定義し、
dispatch()を共通エントリポイントに使う。SendResult.failed_idsを Argo のoutputs.parametersに渡してリドライブ(再送)に活用できる - DataDog/OTel:
priority_labelの結果をスパン属性(span.set_attribute("channel.priority", label))として付与し、優先度別のレイテンシ分布をダッシュボードで可視化できる - BigQuery 集計との対比:
functools.reduceは Python 内集計の小規模利用に適する。大規模(数百万件)では BigQuery のSUM(sent_count) OVER (PARTITION BY campaign_id)ウィンドウ関数に移管する判断基準を持つこと
今日のまとめ
Protocol で構造的サブタイピングを活用することで isinstance 分岐を廃止し、新チャネル追加時のコード変更ゼロを実現。frozen dataclass slots=True + StrEnum + match 文 + functools.reduce の組み合わせで型安全・軽量・拡張しやすいマルチチャネルディスパッチャを構築できる。