一覧SE-02-2 · 設計の分割と契約

外部データと内部モデルの変換

成功値と例外を含む内部契約を定め、外部形式の差をアダプターへ集める効果と限界を説明します。

説明

外部のデータ形式が変わるたびに業務処理を書き換えると、変更が広い範囲へ伝わります。境界に変換処理を置き、内部で使う値と失敗の扱いを決めておくと、外部の形式に依存する箇所をまとめられます。

円建ての受領額を表す二種類のJSONを使います。古い形式では金額が文字列、新しい形式ではmoney内の整数です。直接参照した場合の失敗から始め、両形式を共通のPaymentへ変換し、真偽値・小数・異なる通貨・支払拒否をどう扱うか確認します。

成功値と例外を含む内部契約を定め、外部形式の差をアダプターへ集める効果と限界を説明します。

1二つの外部形式

準備した二つの応答ファイルを読みます。どちらも1200円の受領を表しますが、キー名・階層・金額の型が違います。

cat legacy.json旧形式のJSONを読む cat current.json新形式のJSONを読む

legacyではamountが文字列、新形式ではmoney.minor_unitsが整数です。JSONとして正しく読み込めることと、業務処理が期待する構造を満たすことは別です。

2キー名を直接参照すると壊れる

direct.pyはdata['amount']を整数へ変換します。二つの形式へ同じ処理を適用します。

python3 direct.py外部キーを直接参照する処理を実行する

旧形式は1200を取得できますが、新形式ではamountがなくKeyErrorになります。業務処理が外部のキー名を直接使うと、その変更が呼出し側へ伝わります。

3内部の成功値と失敗を決める

内部契約を読みます。金額が整数であるだけでなく、負額とboolを拒否し、JPYだけを扱うことも決めています。

cat contract.txt入力・成功値・失敗の契約を読む

通貨によって最小単位が異なるため、USDの1200を1200円として扱ってはいけません。業務で支払を拒否された場合と、想定外の形式を受け取った場合も別の例外にします。

4型注釈だけでは入力検証にならない

annotation.pyは型注釈だけのLabelOnlyと、値を検査するPaymentを作ります。文字列・bool・小数・負数・正しい整数を渡します。

python3 annotation.py型注釈と実行時検証の挙動を比較する

LabelOnlyは文字列を保持します。Pythonの型注釈は実行時に型を強制しません。Paymentは明示した検査で不正な値を拒否します。boolはintの派生型なので、この契約ではtype(value) is intで除外しています。

5契約を守る値の生成

core.pyを読みます。dataclassの初期化後に__post_init__が呼ばれ、金額と通貨を検査します。receiptは外部JSONのキーを参照していません。

cat core.py値の検証と内部の表示処理を読む

Paymentを生成できた場合に、receiptが必要とする条件がそろいます。frozenは属性の通常の代入を禁止する設定であり、金額の型や範囲を検査する機能ではありません。

6形式ごとの変換を境界へ集める

adapters.pyを読みます。legacyは数字だけの文字列を整数にし、currentはmoneyから値を取り出します。最後はどちらもPaymentへ渡します。

cat adapters.py形式別の変換と例外への対応を読む

旧形式の文字列変換と、新形式の階層の読取りを分離しました。共通の金額・通貨の条件はPayment側にあるため、形式ごとに同じ検査を複製する必要がありません。

7異なる形式から同じ内部値を作る

旧形式にはlegacy、新形式にはcurrentを指定します。読取り用コマンドは変換後の値と同じreceipt関数の結果を表示します。

python3 read.py legacy legacy.json旧形式を旧形式用アダプターへ渡す python3 read.py current current.json新形式を新形式用アダプターへ渡す

どちらもPayment(amount=1200, currency='JPY')となり、「受領額: 1200 円」を表示します。業務処理は外部のamountやmoneyというキーを知る必要がありません。

8不正値と業務上の拒否を分ける

新形式にtrueを金額として入れた例、支払拒否、USD建ての例を読み込みます。

python3 read.py current invalid.json failed.json usd.json型違反・拒否・通貨違反の3入力

trueとUSDはInvalidPayload、支払拒否はPaymentRejectedです。呼出し側は、データ形式の不一致と利用者の支払が成立しない状態を別々に扱えます。外部の詳細文字列をそのまま内部契約にする必要はありません。

9黙って丸めず形式の不一致を返す

旧形式の金額に12.50という小数表記が入った例を処理します。内部契約は整数の円だけを受け付けます。

python3 read.py legacy legacy-bad.json整数の円として扱えない文字列を渡す

InvalidPayloadになります。小数を切り捨てて12円にしたり、単位を推測して1250円にしたりせず、契約との不一致として扱います。変換は値の意味を保つ範囲で行います。

10追加項目との互換性を確認

各JSONへrequest_idを追加して変換前後のPaymentを比較します。このアダプターは必要な項目だけを読みます。

python3 compatibility.py追加項目の有無で変換結果を比較する

両形式で結果は同じです。この実装では無関係な項目の追加を受け入れますが、必須キーの削除や金額の型変更も許されるわけではありません。互換性は「JSONが読めるか」ではなく、利用側の契約が満たされるかで判断します。

まとめ

外部形式を内部のPaymentへ変換する境界を設け、業務処理を外部キー名から離します。

内部契約には値の型・範囲・単位と、失敗時の例外も含めます。型注釈だけでは実行時の検証になりません。

項目追加に対応できても、型や意味の変更まで安全とは限りません。契約に合わない値を推測で変換しません。

操作履歴と解説を振り返れます。

シェル / Python 3

1
cat legacy.json cat current.json
{"state":"ok","amount":"1200","currency":"JPY"}
{"ok":true,"money":{"minor_units":1200,"currency":"JPY"}}
2
python3 direct.py
legacy.json: 1200
current.json: KeyError: 'amount'
3
cat contract.txt
内部契約: Payment(amount: int, currency: str)
amountは0以上の整数。bool・文字列・小数は受け付けない。
currencyはJPYのみ。この処理は円建ての請求を扱う。
成功: Paymentを返す。
失敗: 外部の拒否はPaymentRejected、形式の不一致はInvalidPayload。
呼出し側は内部契約だけを扱い、外部JSONのキー名には依存しない。
4
python3 annotation.py
LabelOnly(amount='1200')
'1200' -> amount must be a nonnegative integer
True -> amount must be a nonnegative integer
1200.5 -> amount must be a nonnegative integer
-1 -> amount must be a nonnegative integer
1200 -> Payment(amount=1200, currency='JPY')
5
cat core.py
from dataclasses import dataclass

class InvalidPayload(ValueError):
    pass

class PaymentRejected(Exception):
    pass

@dataclass(frozen=True)
class Payment:
    amount: int
    currency: str

    def __post_init__(self):
        if type(self.amount) is not int or self.amount < 0:
            raise InvalidPayload('amount must be a nonnegative integer')
        if self.currency != 'JPY':
            raise InvalidPayload('currency must be JPY')

def receipt(payment):
    return f'受領額: {payment.amount} 円'
6
cat adapters.py
import re
from core import Payment,InvalidPayload,PaymentRejected

def legacy(data):
    if not isinstance(data,dict):
        raise InvalidPayload('response must be an object')
    if data.get('state') == 'rejected':
        raise PaymentRejected('payment rejected')
    if data.get('state') != 'ok':
        raise InvalidPayload('legacy state must be ok or rejected')
    amount=data.get('amount')
    if not isinstance(amount,str) or not re.fullmatch(r'[0-9]+',amount):
        raise InvalidPayload('legacy amount must contain digits only')
    return Payment(int(amount),data.get('currency'))

def current(data):
    if not isinstance(data,dict):
        raise InvalidPayload('response must be an object')
    if data.get('ok') is False:
        raise PaymentRejected('payment rejected')
    if data.get('ok') is not True or not isinstance(data.get('money'),dict):
        raise InvalidPayload('current response must contain money')
    money=data['money']
    return Payment(money.get('minor_units'),money.get('currency'))
7
python3 read.py legacy legacy.json python3 read.py current current.json
legacy.json: Payment(amount=1200, currency='JPY')
受領額: 1200 円
current.json: Payment(amount=1200, currency='JPY')
受領額: 1200 円
8
python3 read.py current invalid.json failed.json usd.json
invalid.json: InvalidPayload: amount must be a nonnegative integer
failed.json: PaymentRejected: payment rejected
usd.json: InvalidPayload: currency must be JPY
9
python3 read.py legacy legacy-bad.json
legacy-bad.json: InvalidPayload: legacy amount must contain digits only
10
python3 compatibility.py
legacy: extra field preserves result=True
current: extra field preserves result=True

演習完了