Pythonで堅牢な開発を!?(2025年1月版)
「出来らあっ!(多分)」
最近のPythonでは、PydanticやFastAPIを使えば、APIのリクエストの動的なバリデーションなどはもちろん、型ヒントを使って静的なチェックについてもかなり頑張れる。
B2Bの開発だとシステムが(仮に一部の機能であっても)動かなくなることはかなりクリティカルなので、実行時エラーが出せるというだけでなく、「間違ったコードや危険なコードを事前に弾ける」という形が望ましい。
最近だと初めからTypeScriptを使う方法もあるけど、弊社の場合だと既存のコードベースがPythonになっているのと、やはりFastAPIやPydanticが相当便利+それ以外のライブラリの充実度も考えて、Pythonを使った上でなるべく堅牢なシステム開発ができる形を模索している。
ということで以下、弊社がやっていることをまとめてみる。前提として、Python3.12を使っていて、フレームワークとしてはFastAPIやPydantic、SQLModel(+SQLAlchemy)を使っている。(クライアントはTypeScript+Reactだけど、ここでは触れない)
ベースになるツール群
フォーマットとLint、isort
フォーマッター兼Linterとしてはもうruffでいいと思う(isortの機能も兼ねてくれる)。宗教論争を避ける意味でも、デフォルトの設定で使って、それに慣れることにする。これでコードの書き方には意識を割かずに中身の話だけに集中できる。
型チェック
型チェッカーとしてはMypyとPyrightの併用が良いように思う。基本的な型チェックはMypyがかなり素直にやってくれる。またPyrightは型推論などが上手なので、例えばUnionを多用したケースでも、おかしなコードにはしっかり突っ込んでくれる。(Pyrightだと警告されないがMypyだと警告されるケースがあるので併用)
設定だけど、Mypyのstrictはさすがに厳しいが、できるだけ明示的な型指定を強要するようにしたいのと、VSCodeで書いているとPylanceの型推論は便利だが、元の意図とは異なる戻り値を書いてしまっていたら教えてほしい、ということで、少なくともFastAPIのrouterになるメソッドと、ドメインロジックに関するコードについては必ず明示的に戻り値の型も定義するようにしている。ユニットテストについては戻り値の型指定までは不要としている。
今のpyproject.tomlの一部をさらすと、以下のようになっている。たとえばmodel(ビジネスロジック)についてのみ制約を厳しくしているといった具合。
[tool.mypy]
allow_redefinition = false
allow_untyped_globals = false
check_untyped_defs = true
disallow_untyped_decorators = true
no_implicit_optional = true
warn_no_return = true
warn_unused_ignores = true
[[tool.mypy.overrides]]
module = "model.*"
disallow_incomplete_defs = true
disallow_untyped_calls = true
disallow_untyped_defs = true
VSCodeでのフォーマットとオートコンプリート
ruffを使っていれば、vscodeで保存時に自動フォーマットができるので、ChatGPTなどの生成AIによるコードを貼り付けながら作業する場合もすっきりしてよい。
また、型指定が厳密であれば、オートコンプリートがうまく機能するので、SQLModelのクエリの書き方などもすらすら書けるし、Ctrl+クリックで定義元に確実に飛べる。(Pylanceだけでも結構頑張るが、明示的に指定するのが一番確実)
たとえばモデルの定義については以下のような感じで書いているとする。
class User(SQLModel, table=True, frozen=True):
usergroups: list[UserGroup] = Relationship(
back_populates="users", link_model=UserUserGroupLink
)
class UserGroup(SQLModel, table=True, frozen=True):
users: list["User"] = Relationship(
# いろいろ
)
すると、以下のようなコードを書く場合も、「user.usergroup」の箇所であれば「user.」の時点で候補が出てくるし、groupの型もUserGroupであることが明示されているので、forの中で「group.」と打つとUserGroupの候補が出てきてくれる。もちろんN+1が困るケースであれば、select(User)~のところでeager loadが使えるので、その辺 りも含めて簡単に書ける。
with db_session() as session:
user = session.exec(select(User).where(User.id == 107)).one()
if user is not None:
for group in user.usergroup: # ここで候補が出てくる
print(group.name) # ここも候補が出てくる
pre-commitとCI
MypyとPyrightのチェックはpre-commitでも行なっていて、そもそも間違ったコードはコミットできないようにしている。もちろん、CI(弊社はオンプレのGitLabを使用)でも同様のチェックを実施しつつ、pytestも実行している。
型以外の規約的なもの
アーキテクチャ
DDDやクリーンアーキテクチャなどを参考に、各コンポーネントの責務を明確にする。詳しくは書かないけど、ビジネスロジックはSQLModelをベースとしたモデルになるべく集約し、それ以外についてはserviceに書く形。routerはリクエストの受け渡しに特化し、DBの更新はリポジトリに集約するという形を取っている。
副作用をなるべく起こせないようにする
PydanticのBaseModelではfrozen=Trueとするとイミュータブルになるので、すべてのBaseModel、SQLModelについてfrozen=Trueを指定している。値を変更するには新しいオブジェクトを作る必要があるが、Pydanticの引数updateが単なるdictであり型チェックができないので、BaseModelを引数に受け取るget_updated_copyというメソッドを別途定義して使用する。(このあたりもそうだが、面倒でも型チェックが効く方法を優先するようにしている)
TypeAliasとLiteralの多用
ビジネスロジック的に複数の値のいずれかを取る、といったコードはLiteralを使う。(TypeScriptでいうところの「"foo" | "bar"」みたいなことができる。文字列以外でも使える)特にビジネスロジックのコードではstrやintでなければいけないケースはちゃんと考えれば少ないはずなので、できるだけ多用していく。たとえば特定のオブジェクトへのアクセス権限が「なし」「読み取り」「編集」であれば、Pemission:TypeAlias = Literal["none", "read", "write"]といった形。Enumもあるが、コード補完や型チェックはきちんと機能する上に短く書けるので、特に理由がなければLiteralを使う。
routerでの戻り値
デコレータのreponse_modelではなく、メソッドの戻り値の型を使うことで、Pyrightなどの静的チェックが効くようにしている。routerの戻り値に使うBaseModel側で、ユーザー側に返すデータ構造を定義している。
戻り値はResult型を使う
たとえばデータの更新処理など、「失敗あるいは拒否される可能性がある操作」を試みた場合に、成否と合わせて詳細を返すというのを統一された形でやりたいので、RustなどでいうところのResult型を使っている。PythonでResult型を使うのであればreturnsというライブラリが便利なのでこれを使う。
セキュアなデータ更新を行なうためのクラス設計
Joel Spolsky氏の「間違ったコードは間違って見えるようにする」は大事なことなので、なるべくそういったことが実現できるクラス設計を行なっている。(翻訳はこちら)特にエンタープライズ向けのシステムだと、「あるユーザーが、あるデータを更新する」といった場合でも、いろいろな権限設定を見て判断する必要があるので、そのあたりのチェックを行なう場所を明確に集約しておきたいのと、権限チェックが甘いコードについては型エラーか一目で分かる形にしないといけない。
たとえばedenでレッスン(教材の一種)というデータを作成するときは以下のような流れになっている。
- ユーザーからの入力はInsecureLessonCreateRequestとして受け取る。(たとえばレッスンのタイトルや本文、あるいは作成先のフォルダIDなどはすべてInsecure~側で定義する)
- InsecureLessonCreateRequestを継承してSecureLessonCreateRequestクラスを定義する。
- SecureLessonCreateRequestクラスではプロパティを追加せず、継承するだけ。
- SecureLessonCreateRequestは唯一to_secure(operator:User)というクラスメソッドを持ち、その中でそのリクエストの検証を行う。(たとえば作成しようとしているフォルダに対する編集権限があるか?など)
- DB更新のメソッドはリポジトリで定義され、SecureLessonCreateRequestを受け取る。
こうしておくと、間違った書き方の場合は、型エラーになるか、「その行だけを見れば判断できる」という形になる。(コードの前後を見る必要がなくなる)
たとえば、以下のコードは型エラーにはならないが、routerでSecureなリクエストを受け取って いるのですぐに間違いだと分かる。(=app_requestの型はInsecureで始まっていないといけない)
@router.post(
"/lessons",
)
def create_lesson(
app_request: SecureLessonCreateRequest,
operator: User = Depends(get_current_user_with_usergroups),
) -> LessonPublic:
あるいは、たとえばユーザーの入力を検証せずにDB更新を行なおうとすると、その場で型エラーになる。
# upsert_lessonはリポジトリ側のメソッドで、DBの更新を行なう
upsert_lesson(session, insecure_request)
Result型でエラーを返すというのも含めて、レッスンを作成するAPIのコードは以下のようになる。
@router.post(
"/lessons",
)
def create_lesson(
app_request: InsecureLessonCreateRequest,
operator: User = Depends(get_current_user_with_usergroups),
) -> LessonPublic:
result = SecureLessonUpsertRequest.to_secure(
session, operator, app_request
)
if not is_successful(result):
# fastapi側でエラーの詳細をJSONで返す(たとえばタイトルが空欄とかアクセス権限がないだとか)
raise result.failure()
else:
lesson = upsert_lesson(session, result.unwrap())
# LessonPublicはAPIのレスポンスに必要な情報だけを持つBaseModel
return LessonPublic.from_lesson(operator, session, lesson)
設計という点において、「routerはリクエストを行なう」「リポジトリはDB更新を行なう」「単純なバリデーション(必須チェックや文字の長さ制限)はPydantic側で行う」「ビジネスロジックを含んだチェックはリクエストのモデルにあるto_secureメソッドで行なう」というような形で各コンポーネントの責務が明確になる。教材データだけでなく、ユーザー情報などの更新も同様のクラス設計で行なっている。
まとめ
型の定義を厳密にやっておくと、たとえばモデルのプロパティ名が変更されたりした場合は型エラーになってくれる、というかそもそもVSCodeでF2キーを使った変更をすれば必要な箇所がすべて自動的に変わっ てくれるので、TypeScriptにかなり近い書き味になる(どうしても検知できない場所のみpytestなどで検知する)。
また、規約についても上記のような形にしたことで、堅牢、セキュアな書き方ができているかどうかについても、コードを書く際に考えることがかなり減らせているように思う。