FastAPI / DI(依存性注入)とValidator
こんにちは、依存性注入してますか!?
っていうと、薬物依存症みたいですがweb APIを作るときのお話です。
ちょっと分かりづらい概念だったり、自動でやってくれるので作業後には動作原理を忘れがち。 といことで、爆速APIフレームワークFastAPIで使われているデータ検証ライブラリ Pydanticの機能を解説します。
PydanticはAPIの処理で何かと煩わしいリクエストが受け取る”データ(パラメータ)”を検証する機能に使われています。
どんな検証かと言うと、例えばあるAPIが id , page, limit, offset をパラメータとして受け取り、idが必須、page は整数、limitは100以下、、、などパラメータの「あるべき姿」を規定しチェックすることが出来ます。
同じようにレスポンスで返すデータも検証してくれ、APIの多くの処理を受け持ってくれます。
DI(依存性注入)の定義
ID (Dependency Injection) とは
たとえば、リクエストのパラメーターのフルーツ名とフルーツの価格があるとして、フルーツの価格が0の場合は50円にする、という処理を書くときに
from fastapi import Depends, FastAPI
# Create a FastAPI app
app = FastAPI()
# Creating a sub-dependency for price
async def sub_dependency_price(price: int):
if price == 0:
return 50
else:
# Creating dependency of fruits
async def dependency_fruits(fruit: str, price: int = Depends(sub_dependency_price)):
return {"Fruit": fruit, "Price": price}
# Call FastAPI app using Pydantic
@app.get("/fruits")
async def fetch_authors(fruits_list: dict = Depends(dependency_fruits)):
return fruits_list
fetch_authorsのリクエストが来ると、Depends() で指定している関数 dependency_fruits() を呼び、戻り値を引数として fetch_authorsに渡します。
リクエストの処理と、リクエストで与えられるパラメーターのバリデーションがスマートに分離できています。
Depends() のメリット
リクエストのパラメーターの管理を疎結合(loose coupling)で実装
Depends()を使うことで、リクエストパラメーターを明示的に渡すこと無く処理できる
Depends()中の関数は毎回、リクエスト処理の前に実行され管理されているので、dbのハンドラの取得やログインセッションなどの管理で威力
という、管理面と楽さのメリットがあります。
また引数が多く複雑なときに、見通しが良くなるというメリットもあります。
async def common_parameters(q: Optional[str] = None, skip: int = 0, limit: int = 100):
return {"q": q, "skip": skip, "limit": limit}
@app.get("/items/")
async def read_items(
params: dict = Depends(get_item_param),
commons: dict = Depends(common_parameters),
db = Depends(get_db),
authUser= Depends( get_authUser )
):
...
...
return prams
のようなときに、リクエストの関数のメインの処理が動くときには、Depends() で必要な値を前処理して保証してくれるため、スッキリしたコードとなり、バグや仕様変更の対処も楽になります。
す、すばらしい!!
@Validator と Field
実際のサービスではリクエストのパラメーターを細かくチェックして、呼び出し側のご動作や不正アクセスに備えるためバリデーションがどんどん追加されるでしょう。
このため、更に明確にパラーメータのバリデーションを追加していくためFastAPI/Pydanticでは Filedと、Validator デコレータが用意されています。
Field を使った一般的なバリデーション
こんなやつです
class User(BaseModel):
age: int = Field(..., ge=0, le=100)
よくあるパターンに関して対応する事ができます。
SQLのORMフレームワークのバリデーションに近いかたちで実装ができます。(※ Field( … ) については最後におまけで追記しました)
初期値は
class User(BaseModel):
name: str = Field(default='John Doe')
動的な初期値は
class User(BaseModel):
now: datetime = Field(default_factory=datetime.now)
from uuid import uuid4
from pydantic import BaseModel, Field
class User(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
Annotated との組み合わせ ※Annotatedの解説は最後に書きました
from typing import Annotated
from pydantic import Field
from pydantic import BaseModel, Field
class User(BaseModel):
id: Annotated[str, Field(default_factory=lambda: uuid4().hex)]
◆数値制約キーワード
gt- より大きい
lt- 未満
ge- より大きいか等しい
le- 以下
multiple_of- 指定された数の倍数
allow_inf_nan'inf'- 、'-inf'、'nan'値を許可
◆文字列制約
文字列を制限するために使用できるフィールドがあります。
min_length: 文字列の最小長。
max_length: 文字列の最大長。
pattern: 文字列が一致する必要がある正規表現。
◆小数制約
小数点以下の桁数を制限するために使用できるフィールドがあります。
max_digits: 内の最大桁数Decimal。小数点の前のゼロや末尾の小数点のゼロは含まれません。
decimal_places: 許容される小数点以下の最大桁数。末尾の小数点以下のゼロは含まれません。
◆データクラスの制約
データクラスを制限するために使用できるフィールドがあります。
init__init__: フィールドをデータクラスに含めるかどうか。
init_var: フィールドをデータクラス内の初期化専用フィールドとして扱うかどうか。
kw_only: フィールドがデータクラスのコンストラクター内のキーワードのみの引数であるかどうか。
from pydantic import BaseModel, Field
from pydantic.dataclasses import dataclass
@dataclass
class Foo:
bar: str
baz: str = Field(init_var=True)
qux: str = Field(kw_only=True)
◆デフォルト値を検証する
validate_default使用して、フィールドのデフォルト値を検証するかどうかを制御できます。
age: int = Field(default='twelve', validate_default=True)
◆pydanticの型変換を抑止する方法
1)strict=True をつかう
from pydantic import BaseModel, Field
class User(BaseModel):
name: str = Field(strict=True)
age: int = Field(strict=False)
user = User(name='John', age='42')
print(user)
#> name='John' age=42
2)strict typeを使う
通常の int を使うと 1.23 などfloatが来たときに、pydanticが自動で「1」にしてしまいます。
idなど整数でないと都合が悪い、厳密性を要するところでは以下のstrict types を使用します。
全部これにしとけ!!って感じですが(汗
他にも複数用途のalias 、識別器、frozen、deprecatedなど、便利な機能がありますので詳しくは以下のドキュメントをご覧ください。
deprecatedは仕様変更で使わなくなったレコードを検出できて非常に便利です。ちなみにマニュアルによると、フィールドの引数はこの様になっております。あるなー(笑
@validatorを使ったバリデーション
( ⚠️ pydantic のV2では field_validator とmodel_validator 、 v1 ではそれぞれvalidator, root_validator となりますので適宜読み替えてください)
大枠のバリデーションはFiledで出来ますが、サービス固有の細かい制限については対応できません。そこで便利なのがValidatorです。
こちらもモデルクラスの中に記述します。
Depends()やFieldは避けて通れないですがValidatorデコレーター ではなくレスポンスのコードに書いている場合もあるかと思ます。
例1:'delete from' が文字列に含まれるか
from pydantic import BaseModel,Field, validator
class Blog(BaseModel):
title: str = Field(...,min_length=5)
is_active: bool
@validator("title")
def validate_no_sql_injection(cls, value):
if "delete from" in value:
raise ValueError("Our terms strictly prohobit SQLInjection Attacks")
return value
Blog(title="delete from",is_active=True)
# Output: ValidationError: 1 validation error for Blog title
# Our terms strictly prohobit SQLInjection Attacks
例2:root_validator で相互依存プロパティ
前述の例は validator ('title' )で1つのプロパティのチェックをしました。root_validatorを使うとすべてのプロパティにアクセスできる処理が書けます。以下は password と confirm_password が同じかどうかを検証しています。
from pydantic import BaseModel, root_validator
class CreateUser(BaseModel):
email : str
password :str
confirm_password :str
@root_validator()
def verify_password_match(cls,values):
password = values.get("password")
confirm_password = values.get("confirm_password")
if password != confirm_password:
raise ValueError("The two passwords did not match.")
return values
CreateUser(email="ping@fastapitutorial.com",password="1234",confirm_password="123")
# Output: ValidationError: 1 validation error for CreateUser __root__
# The two passwords did not match. (type=value_error)
例3:country_codeがコードリストに存在するか
@validator("country_code")
def check_country_code(cls, v):
if v not in valid_iso_3166_1_alpha2_values:
raise ValueError(f"Unknown country_code {v}")
return v
まとめ
Depends, Field, Validatorを使えば、リクエストのパラメーターが全て事前に検証された前提でレスポンス用コードを美しく書くことが出来きます。
FastAPI, Pydanticがますます好きになりました。
付録:typing.Annotated
久しぶりにPython触って出てくると「なんだっけ?」ってなるやつです(笑
それもそのはず、3.9から追加されました。
参照の型を宣言し、それに関連する追加情報を提供するための機能でコードコードの実行と直接関係ない「meta情報」を追加します。
たとえば、
name = Annotated[str, "first letter is capital"]
は str 型であること、 "1文字目が大文字になる文字列にしてね”というコメントを追加します。このメタデータをどう使うかはユーザーや利用するモジュール次第、というのがこの機能の位置づけです。
Pythonでの実際の動き
Annotated の処理の結果を見たほうがプログラマーには理解が早いかもしれません。
>>> from typing import Annotated
>>> X = Annotated[int, "very", "important", "metadata"]
>>> X
typing.Annotated[int, 'very', 'important', 'metadata']
>>> X.__metadata__
('very', 'important', 'metadata')
インスタンスからのアクセス例
from typing import Annotated
X: Annotated[int, "very", "important", "metadata"] = 0
annotations = globals().get('__annotations__', {})
x_annotation = annotations.get('X')
print(x_annotation.__matadata__)
# -> ('very', 'important', 'metadata')
print( x_annotation.__origin__)
# -> <class 'int'>
FastAPI のAnnotated処理
さて、FastAPIでAnnotedをどう利用しているかが本題ですが、調べると単にAnnotatedが、と主語が違う書き方をされて分かりづらいのです。正しくは「FastAPIの Annotatedを利用したバリデーション方法」とでもいいましょうか。
冒頭の例では
from typing import Annotated
from pydantic import Field
from pydantic import BaseModel, Field
class User(BaseModel):
id: Annotated[str, Field(default_factory=lambda: uuid4().hex)]
Annotated[str, Field(default_factory=lambda: uuid4().hex)]
となっています。default_factoryは初期値を生成する関数(呼び出し可能オブジェクト)を指定するためのパラメータです)
ただし、 default はサポートされていないので
Annotated[ str, Field( default="abc") ] のようには書けないようです。(※マニュアルにAnnotatedで初期値指定は default_factoryを使うように書かれています。)
へんなの。
このあたりはバグがあったみたいで、当面は無理してAnnotated を使わずにFiledで済ませたほうが楽かもしれません。
Annotatedを実際にFastAPIで使用すると、どのように働くかは上記の「単なるメモ」をどのように利用して実行するのか、別の話になってきます。(これがAnnotedのややこしく感じさせるところです)。
見方を変えれば理系あるあるで説明の言葉が雑だったり文言が足りない(頭が追いつかなくなってる)からじゃないかと思います。
それだけ複雑なことをしているというのは、書くときも読むときにも自覚して余裕を持って(単語足りてないかも?)という想定がコツと思います。
Field(...) は一体?
冒頭で出てきた … の記述の解説です。
str = Field(..., description="Hello")
いやー、気持ち悪いですね(笑
"…" はPythonで省略記号とか epllisise (エリプシス)と呼ばれるオブジェクトです。
>>> ...
Ellipsis
機能は4つあり
不完全なコードのプレースホルダー
NumPy での高度なスライス
可変長パラメータの型ヒント
カスタムコンテナタイプ
1.はコードのない関数を作ったときに仮に”…”と書くことが出来ます。
def Hello():
...
3.可変長パラメータの型ヒントは、アノテーション内の任意の引数の型を示します。
FuncType = Callable [..., int ] # 任意の引数を持ち、intを返す関数
Pydanticでは
from pydantic import Field
from fast_depends import inject
@inject
def func(a: str = Field(..., max_length=32)):
...
default: (位置引数) フィールドのデフォルト値。 はFieldフィールドのデフォルトを置き換えるため、この最初の引数を使用してデフォルトを設定できます。省略記号 ( ...) を使用して、フィールドが必須であることを示します。
とあります。
前提として、Pydantic のデータ検証は 例えば modelの場合
# case1
class User(BaseModel):
age: int # デフォルト値がない=必須 (値を指定しないとエラー)
# case2
class User(BaseModel):
age: int = 99 # 指定がなければデフォルト値=99で作成
# case3
class User(BaseModel):
age: Optional[ int ] = 99 # Optional : Noneでもエラーにしない,初期値=99
のように生成時の初期値ルールを設定できます。
これはFastAPIでは例えばリクエストのパラメータに値がないときの動作となります。
Field(...
の … ではバリデーションで「初期値無し」エラーとという設定の意味として働くようです。( … なので、何でも夜から初期値がある前提)
型がないスクリプト言語のマッチポンプ
なんでこんな面倒なことになるかと言うと、それはPythonが型を指定しないでも動く「動的型付け言語」だからでしょう。現在は様々な「後付け」機能で型の明示やスコープも設定できるようになって来ましたが根本が異なります。
自分はアセンブラからのC,C++で仕事をした期間が長く、静的型付け言語で「必ず型が確定している」有り難さ(ない恐ろしさ)を痛感していますので、「序盤は作業効率上がったようで、やっぱダメだった」「場当たり的に頑張る」というのを見ると微妙なキモチになります。
(静的型付け言語はスコープも明示的な仕様なのでプログラム構造が乱れにくい利点もあります)
現在はAIサービスの親和性などの事情があってPythonを使っていますが、フルスタックとか怪しいジョブクラスを設定してる会社やプロジェクトではなく予算が常識的に設定されてた専任のバックエンドがいる場合にはjava、go、rust、C++などのフレームワークのほうが結局は効率が良いのでは…と考えています。過去には色々あったJavaが根強い人気があるのは昔よりハード構成がリッチになっただけでなく、生産性と堅牢性があるからなのでしょう。