型を活用した Pydantic の共有バリデーションの設計
Python のデータを型や条件で検証するライブラリ Pydantic で、バリデーションを共通化する設計を考える。送信側と受信側で同じ条件を使う場合に、フィールドの制約をどう再利用するかをコード例で示す。
バリデーションを共有するモチベーション
システムの設計上、バリデーションを同じ条件で実行したいことがある。例えば、クライアントがデータを送信する前と、サーバーが受け取ったデータを処理する前に、同じ条件で検証したい場合だ。検証条件を共通化すれば、送信側と受信側で条件が食い違うのを防げる。
バリデーションとフィールドの共有
Pydantic のモデルを定義する際にフィールドに対してバリデーションを追加できる。例えば、次のようにFieldクラスを使用してバリデーションを追加できる。
| from pydantic import BaseModel, Field
class User(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
age: int = Field(..., ge=0, le=150)
|
バリデーションやフィールドの設定を共通化するには、次のようにUserを継承したクラスを作成できる。
| from pydantic import BaseModel, Field
class AdminUser(User):
role: str = Field(..., min_length=1, max_length=100)
class NormalUser(User):
pass
|
上記のようにクラス継承すると、フィールドやバリデーションを共通化できる。しかし、クラス継承には次のような問題点がいくつか生じる。
- 継承元のクラスを小分けにすると、継承先のフィールドを細かく設定できる。一方で、1 行のフィールドだけを記述したクラスが乱立する
- 深い継承は可読性を損なう
- mixin で複数のクラスを継承する際に、同じフィールドに対してどのクラスのバリデーションを優先するかが不明瞭
バリデーションの共通化
クラス継承の問題点を解決しつつ、バリデーションを共通化する設計を考えてみる。次のように型アノテーションを使用して、バリデーションを共通化できる。
| from typing import Annotated
from pydantic import (
AfterValidator,
PlainSerializer,
TypeAdapter,
WithJsonSchema,
)
Name = Annotated[
str,
AfterValidator(lambda x: x.strip()),
PlainSerializer(lambda x: x.upper(), return_type=str),
WithJsonSchema({'type': 'string'}, mode='serialization'),
]
Age = Annotated[
int,
AfterValidator(lambda x: max(0, min(x, 150))),
PlainSerializer(lambda x: f'{x:03}', return_type=str),
WithJsonSchema({'type': 'number'}, mode='serialization'),
]
class User(BaseModel):
name: Name
age: Age
class Person(BaseModel):
age: Age
address: str
class Game(BaseModel):
host_name: Name
guest_name: Name
team: str
|
上記のように型アノテーションを使用してバリデーションを共通化する方法には、2 つの利点がある。第一に、クラス継承の課題を解決しつつ、バリデーションを再利用しやすい。第二に、個別のフィールドに対してバリデーションを共通化するので、データの整合性を保てるうえ、個々のクラスで同じ定義を重複して書く必要もなくなる。
参考リンク