入力の正規化と検証
注文の入金・出荷イベントを処理します。注文IDは全角・半角と英字の大小を同一視する仕様とし、受注済み→入金済み→出荷済みの順だけを許可します。最後に途中まで反映する逐次処理と、全件成功時にまとめて反映する処理を比較します。
説明
外部から受け取った文字列は、そのまま状態更新へ使わず、構造への変換、値の検証、表記の統一を行います。JSONとして読めることと、業務上使える値であることは別です。
注文の入金・出荷イベントを処理します。注文IDは全角・半角と英字の大小を同一視する仕様とし、受注済み→入金済み→出荷済みの順だけを許可します。最後に途中まで反映する逐次処理と、全件成功時にまとめて反映する処理を比較します。
1JSONを構造へ変換する
全角の注文IDと、周辺に空白のあるイベント名を含むJSONを読みます。
import json, unicodedata; raw = '{"id":" A-01 ","event":" PAY ","amount":120}'; parsed = json.loads(raw)JSONを辞書へ変換; print(parsed)変換後の型と値JSONの構文は正しいので辞書になりますが、IDとイベント名の表記はまだ揃っていません。金額120は数値として読み込まれています。
2項目ごとの規則で正規化
IDにNFKC、周辺空白の除去、大小文字の統一を適用します。
normalized = unicodedata.normalize("NFKC", parsed["id"])全角などの互換表記を正規化.strip().casefold()周辺空白と大小文字を統一; print(repr(parsed["id"]), normalized, normalized == "a-01"同じIDとして照合)全角Aを含む表記がa-01になります。これはIDの比較規則として選んだ変換で、どんな文章や識別子にも無条件で適用するものではありません。異なる元の表記が同じ値へ集まるため、重複の扱いも統一後の値を基準にします。
3値の検査を通したイベントを返す
JSONを辞書へ変換した後で、必要な項目の型と範囲を検査します。成功時だけ正規化済みの辞書を返します。
def parse_event(text):
item = json.loads(text)構造へ変換
if not isinstance(item, dict):
raise ValueError("object required")
if not isinstance(item.get("id"), str) or not isinstance(item.get("event"), str):
raise ValueError("id and event must be strings")
if type(item.get("amount")) is not int整数型を要求 or item["amount"] < 0:
raise ValueError("amount must be a nonnegative integer")
identity = unicodedata.normalizeIDの表記を統一("NFKC", item["id"]).strip().casefold()
event = item["event"].strip().casefold()イベント名を統一
if not identity or event not in ("pay", "ship"):
raise ValueError("invalid id or event")
return {"id": identity検証済みの値を返す, "event": event, "amount": item["amount"]}構文エラーはjson.loadsで、項目の不足・型・範囲はその後の検査で拒否されます。文字列の"120"やboolは金額として受理しません。正規化は対象の文字列項目へだけ適用しています。
4処理する入力一覧を用意
成功する二件と、金額の型が違う一件、JSONが壊れた一件を用意します。validは検証に成功したイベントの保存先です。
inputs =四つの入力 [raw, '{"id":"a-01","event":"ship","amount":120}', '{"id":"a-01","event":"pay","amount":"120"}', '{"id":']; valid = []受入れ先を空で初期化; print(len(inputs), valid)件数と受入れ前の状態入力は4件、検証済みの一覧はまだ空です。次に各段階の失敗を確認しながら処理します。
5成功した入力だけを後段へ渡す
正しい二件、金額が文字列の一件、壊れたJSONの一件を順に処理します。
for index, text in enumerate(inputs, start=1):
try:
event = parse_event(text)変換と検査
valid.append(event)成功したものだけ保存
print(index, "accepted", event)
except ValueError as error失敗を後段へ渡さない:
print(index, "rejected", type(error).__name__)1と2だけがvalidへ入り、3は値の検査、4はJSONの構文で拒否されます。json.JSONDecodeErrorはValueErrorの派生型なので、ここでは同じexceptで受け取りつつ型名で段階を表示しています。
6許可する状態遷移を定義する
pendingでpayを受けるとpaid、paidでshipを受けるとshippedへ進む関数を作ります。
def apply_event(states, event):
transitions =許可する状態とイベントの組 {("pending", "pay"): "paid", ("paid", "ship"): "shipped"}
identity = event["id"]
current = states[identity]対象の現在状態
key = (current, event["event"])適用する遷移を探す
if key not in transitions:
raise ValueError("transition not allowed")
states[identity] = transitions[key]許可した遷移だけ反映
return states[identity]現在状態とイベントの組で、次状態を決めます。禁止した組では代入より前に例外となるので、状態を変更しません。この処理は状態遷移だけを扱い、金額の加算や残高の更新は行いません。
7入金イベントを適用
a-01はpendingから開始します。検証済みの一件目を適用します。
states = {"a-01": "pending"}初期状態; print(apply_event(states, valid[0])検証済みの入金を適用, states更新後の状態)正規化されたIDで同じ注文を見つけ、paidへ進みます。最初の全角表記のまま辞書のキーを探していた場合とは、照合される値が異なります。
8出荷イベントを適用
二件目を同じ注文へ適用します。
print(apply_event(states, valid[1])入金済みの注文を出荷, states適用後の状態)paidとshipの組なのでshippedになります。同じshipでも、pendingからの直接出荷や、出荷済みへの再適用は遷移表にありません。
9許可しない遷移で元の状態を保つ
出荷イベントをもう一度適用します。
try:
apply_event(states, valid[1])出荷済みへ再度出荷を要求
except ValueError as error禁止した遷移:
print(str(error), states拒否後に残る状態)transition not allowedとなり、shippedのままです。同じイベントを繰り返してよいかは仕様次第で、この状態機械では再出荷を拒否しています。
10一件ごとに反映する処理
複数のイベントを順に適用し、成功するたびに状態を表示する関数です。
def apply_sequential(states, events):
for event in events:一件ずつ処理
apply_event(states, event)直接状態へ反映
print("applied", states)成功した結果を公開途中で例外になっても、それまでに反映した状態は自動では戻りません。逐次処理で途中成功を公開するという契約です。
11全件成功時に返す処理
元の辞書のコピーへ適用し、全件成功したときだけ新しい状態を返す関数です。
def apply_batch(states, events):
working = states.copy()作業用の状態を分離
for event in events:
apply_event(working, event)コピーへだけ反映
return working全件成功後に返すこの試料の状態値は文字列なので、辞書の浅いコピーで更新を分離できます。入れ子の可変データや外部への送信があれば、同じ方法だけで取り消せるとは限りません。
12比較する二つの状態を用意
逐次処理と全件成功時の反映を、同じpendingから始めます。二つの辞書は別々に作ります。
sequence_state = {"a-01": "pending"}逐次処理の初期状態; batch_state = {"a-01": "pending"}バッチ処理の初期状態; print(sequence_state, batch_state)どちらも未入金の状態です。入金・出荷・再出荷という同じ三件を適用し、最後に失敗したときの状態を比較します。
13途中失敗後に残る逐次結果
pendingから入金・出荷・再出荷を実行します。
try:
apply_sequential一件ごとに反映(sequence_state, [valid[0], valid[1], valid[1]])
except ValueError:
print("failed", sequence_state)失敗後の公開状態入金と出荷の二件は反映済みなので、最後の再出荷で失敗してもshippedが残ります。
14途中失敗したバッチの結果
同じイベント列を、全件成功時にだけ状態を受け取る形で処理します。
try:
batch_state = apply_batch成功時だけ置き換える(batch_state, [valid[0], valid[1], valid[1]])
except ValueError:
print("failed", batch_state)失敗時は元の状態関数が正常に戻る前に失敗するので、代入は行われずpendingが残ります。同じ入力でも、途中成功を公開するか、全件成功を待つかで失敗後の状態が変わります。
まとめ
構文の解析、値の検証、項目ごとの正規化を分け、成功した入力だけを更新へ渡します。
状態機械は現在状態とイベントの組で遷移を決め、禁止した遷移では更新前に拒否します。
逐次処理とバッチ処理では、途中失敗のとき何を公開状態として残すかを決めます。
操作履歴と解説を振り返れます。
Python 3
import json, unicodedata; raw = '{"id":" A-01 ","event":" PAY ","amount":120}'; parsed = json.loads(raw); print(parsed){'id': ' A-01 ', 'event': ' PAY ', 'amount': 120}normalized = unicodedata.normalize("NFKC", parsed["id"]).strip().casefold(); print(repr(parsed["id"]), normalized, normalized == "a-01")' A-01 ' a-01 True
def parse_event(text):
item = json.loads(text)
if not isinstance(item, dict):
raise ValueError("object required")
if not isinstance(item.get("id"), str) or not isinstance(item.get("event"), str):
raise ValueError("id and event must be strings")
if type(item.get("amount")) is not int or item["amount"] < 0:
raise ValueError("amount must be a nonnegative integer")
identity = unicodedata.normalize("NFKC", item["id"]).strip().casefold()
event = item["event"].strip().casefold()
if not identity or event not in ("pay", "ship"):
raise ValueError("invalid id or event")
return {"id": identity, "event": event, "amount": item["amount"]}inputs = [raw, '{"id":"a-01","event":"ship","amount":120}', '{"id":"a-01","event":"pay","amount":"120"}', '{"id":']; valid = []; print(len(inputs), valid)4 []
for index, text in enumerate(inputs, start=1):
try:
event = parse_event(text)
valid.append(event)
print(index, "accepted", event)
except ValueError as error:
print(index, "rejected", type(error).__name__)1 accepted {'id': 'a-01', 'event': 'pay', 'amount': 120}
2 accepted {'id': 'a-01', 'event': 'ship', 'amount': 120}
3 rejected ValueError
4 rejected JSONDecodeErrordef apply_event(states, event):
transitions = {("pending", "pay"): "paid", ("paid", "ship"): "shipped"}
identity = event["id"]
current = states[identity]
key = (current, event["event"])
if key not in transitions:
raise ValueError("transition not allowed")
states[identity] = transitions[key]
return states[identity]states = {"a-01": "pending"}; print(apply_event(states, valid[0]), states)paid {'a-01': 'paid'}print(apply_event(states, valid[1]), states)shipped {'a-01': 'shipped'}try:
apply_event(states, valid[1])
except ValueError as error:
print(str(error), states)transition not allowed {'a-01': 'shipped'}def apply_sequential(states, events):
for event in events:
apply_event(states, event)
print("applied", states)def apply_batch(states, events):
working = states.copy()
for event in events:
apply_event(working, event)
return workingsequence_state = {"a-01": "pending"}; batch_state = {"a-01": "pending"}; print(sequence_state, batch_state){'a-01': 'pending'} {'a-01': 'pending'}try:
apply_sequential(sequence_state, [valid[0], valid[1], valid[1]])
except ValueError:
print("failed", sequence_state)applied {'a-01': 'paid'}
applied {'a-01': 'shipped'}
failed {'a-01': 'shipped'}try:
batch_state = apply_batch(batch_state, [valid[0], valid[1], valid[1]])
except ValueError:
print("failed", batch_state)failed {'a-01': 'pending'}演習完了