コンテンツにスキップ

グローバルエラー処理#


2024 iThome鉄人レース

これはDjango Ninjaシリーズチュートリアルの第22回です。

前回の記事では、HttpErrorの操作方法を学び、それをview関数内でのみ使用することを推奨しました。

しかし、それだけではプロジェクトのAPIのエラー処理としてはまだまだ不十分であり、少なくとも解決すべき3つの一般的な問題があります:

  1. スキーマの検証メソッド内で、raise HttpErrorをしたくない場合、どうすればよいか?
  2. データベース操作のエラーなど、他の種類のエラーをどのように処理すべきか?
  3. 異なるAPIのエラー間でレスポンスのフォーマットが一致することをどのように保証するか?

これらの問題はすべて一つのより大きなニーズを指し示しています。つまり、私たちは包括的なエラー処理メカニズムを必要としているということです。

この記事では、これらの問題に答えていきます。すべてのコード変更は、このPRを参照してください。

GitHubサンプルプロジェクト#

👉 Django-Ninja-Tutorial


Django ValidationErrorへの変更#

検証メソッドの中で、最も原始的なバージョンはValueErrorをスローしていたことを覚えていますか?

ValueErrorはDjango Ninjaによって自動的に捕捉され422レスポンスが返されます。これは良いことですが、私たちのカスタム要件には合致しません。

そのため、後から私たちはHttpErrorを採用しました。これも捕捉されますが、レスポンスのフォーマットと内容が比較的簡潔であり、さらにエラーメッセージに加えてステータスコードをカスタマイズできるからです。

しかし、前回の記事で述べたように、そうすることは簡単ですが、適切ではありません。

では、一体どのエラーをスローすればよいのでしょうか?

PydanticやDjango Ninjaが提供するエラーの使用を避ける#

前回の記事でも言及しましたが、PydanticにもDjango Ninjaにも、それぞれ組み込みのValidationErrorがあります。

しかし、それらは主にフレームワーク内部で使用されるものであり、返されるエラーフォーマットが詳細すぎ、初期化方法も面倒です。例えば、Django Ninjaの検証エラーは次のように初期化する必要があります:

raise ValidationError(
    [{'loc': ('confirm_password',),
      'msg': 'パスワードと確認用パスワードは一致する必要があります',
      'type': 'value_error'}])

これは私たちが慣れ親しんだ「エラーメッセージの文字列を一つ突っ込む」というだけのものとは違います。

したがって、私は検証ロジックの中でこれらのエラーの型を直接使用することは推奨しません

DjangoのValidationErrorを使う#

スキーマの検証ロジックでは、Djangoに組み込まれているValidationErrorを使用すべきです。

その設計はすでに開発者のニーズを十分に考慮しており、初期化方法はシンプルにも(単一の文字列を使用)複雑にもlistdictを使用)でき、大多数のシナリオに適しています。

ここでは文字列を使用して初期化するだけで十分です。コードは以下のように修正されます:

from django.core.exceptions import ValidationError

class CreateUserRequest(Schema):
    password: str
    confirm_password: str

    @model_validator(mode='after')
    def check_passwords_match(self):
        if self.password != self.confirm_password:
            raise ValidationError('パスワードと確認用パスワードは一致する必要があります')

元のHttpErrorをDjangoのValidationErrorに変更しました。

そして「エラーメッセージの文字列」を初期化方法とし、元の第1パラメータであった「ステータスコード」がなくなりました。


Django Ninjaはこれらのエラーを自動的に処理しない#

送出するエラーの型をDjangoのValidationErrorに変更すると、1つ問題が生じます。Django Ninjaはこのエラーを自動的に捕捉しません

つまり、私たちがValidationErrorをスローした時、Django NinjaはHttpErrorを処理した時のように自動的にフォーマットして422エラーレスポンスを返すことはなく、——直接500エラーになります。

この部分については、〈第20回:データ検証(下)Pydantic複数フィールド間の検証〉の終わりで触れました。

現在、具体的な解決策——exception_handlerを紹介します。

私たちはスローされたこれらのエラーを自分で処理する必要があり、まさにここでexception_handlerがその役割を果たします。


グローバル例外ハンドラー#

(スキーマの検証メソッドに限定されない)異なる出所から来る同種のエラーを統一的に処理するために、Django Ninjaが提供する@api.exception_handlerデコレータを使用できます。

このデコレータにより、私たちは「特定の種類のエラー」に対して専用のレスポンスロジックを定義し、それをAPIの範囲全体に適用することができます。

exception_handlerの定義#

DjangoのValidationErrorに対してグローバルな例外ハンドラーを定義できます。これにより、このエラーがどこで送出されてもハンドラーが捕捉し、APIからカスタマイズした形式のレスポンスを返せます。

プロジェクトのapi.pyに、以下のコードを追加します:

# NinjaForum/api.py
from django.core.exceptions import ValidationError
from django.http import HttpRequest, HttpResponse
from ninja import NinjaAPI

api = NinjaAPI(...)

api.add_router(...)
api.add_router(...)

# 新しく追加したexception handler
@api.exception_handler(exc_class=ValidationError)
def django_validation_error_handler(
    request: HttpRequest, exception: ValidationError
) -> HttpResponse:
    """
    Django ValidationError例外を処理する
    """
    return api.create_response(
        request, {'detail': exception.message}, status=400
    )

私たちはexception handler関数を定義しました。DjangoのValidationErrorに遭遇した時、HTTP 400レスポンスを返し、そこにカスタムエラーメッセージを含めることで、レスポンスのフォーマットの一貫性を保つことができます。

コードは非常にシンプルですが、その中には多くの重要なポイントがあります。一つずつ解説していきましょう。


例外ハンドラーの重要なポイント#

まずは「プロジェクトの構成」というトピックから始めましょう。

1. 例外ハンドラー関数はどこに置くべきか?#

前述の通り、この例外ハンドラーの適用範囲はグローバルなので、プロジェクト内のどこにでも置けます。

ただし、それでも最も適切な場所に置くことをお勧めします。主に2つの選択肢があると思います:

  1. もしエラー処理関数が多くない場合は、プロジェクトの api.py 内に直接配置できます。私たちの例ではそのようにしています。これはプロジェクトのapi.pyグローバル管理の属性に合致しています。
  2. もしエラー処理が多い場合は、独立したPythonモジュールを作成して管理することをお勧めします。

2. 関数とパラメータの命名#

またしても私が大好きな「命名」の部分です☺️

Exception handlerは(デコレートされた)関数であり、理論的には「動詞で始まる関数の命名慣例に従うべきです。

しかし、私はdjango_validation_error_handlerのようなやや「名詞」的な命名を使用しました。

なぜなら、その本質は伝統的な意味での関数というよりも、一種の処理装置メカニズムに近いからです。

もちろん、これはどの観点を重視するかによります。「処理を行う関数なのだから、動詞で始まる名前にすべきだ」という考えにも、まったく同意します。

次にexceptionパラメータについてですが、Django Ninjaのドキュメントではすべてexcと命名されていますが、私は個人的にこれが全く好きではありません。exc全く直感的ではなく全く不要な略語だと思うからです。

百歩譲って、私は単一の文字eを使用する方を好みます。——Pydanticの検証メソッドにおけるvに似た感じです。

3. 関数のロジックの解析#

Exception handlerの関数ロジックは、長くも短くも、シンプルにも複雑にもできますが、やることは以下の2つに尽きます

  1. 特定のエラーの種類を受け取る
  2. 特定のHTTPレスポンスを返す

この例では、DjangoのValidationErrorを受け取り、「400 Bad Request」レスポンスを返します。さらに、エラーメッセージの内容はスローされたエラーから来ており——私たちが独自に定義したものです。

このように、エラー処理には十分な柔軟性があります。ValidationErrorlistdictで初期化する場合は、このハンドラーをもう少し複雑に実装する必要があります。


少し試してみましょう:404レスポンスの処理を例にして#

もう一つ別のexception handlerを実装し、よくある404を処理してみましょう。

「単一の記事情報を取得する」APIを例にします:

@router.get(...)
def get_post(request: HttpRequest, post_id: int) -> Post:
    """
    単一の記事情報を取得する
    """
    post = Post.objects.get(id=post_id)
    return post

現在、フロントエンドが入力した記事のidが存在しない場合、サーバーは直接500を返します:

raise self.model.DoesNotExist( post.models.Post.DoesNotExist: Post matching query does not exist.

しかも内部のメッセージを暴露してしまいます——これは本当にひどいです!🤣

なぜなら、Django ORMのQuerySetのgetメソッドは、クエリ結果が見つからない場合や、複数の結果が見つかった場合の両方で、エラーをスローするからです。そして、私たちはこれらのエラーを捕捉したり処理したりしていないため、サーバーが直接500になってしまったのです。

これら2つのエラーは異なるため——エラーメッセージも異なる必要があります。ここではまず1つ目の状況だけを処理します。

結果が見つからない時、404レスポンスを返す#

この2つの記事を通じて、404を返す2つの方法を学びました。

第一に、HttpErrorを直接使用する方法です:

try:
    post = Post.objects.get(id=post_id)
except Post.DoesNotExist:
    raise HttpError(404, '記事が存在しません')

これは前回の記事のやり方であり、こちらも非常にお勧めです。

第二に、exception handlerを使用する方法です:

# NinjaForum/api.py
from django.core.exceptions import ObjectDoesNotExist
...

@api.exception_handler(exc_class=ObjectDoesNotExist)
def object_does_not_exist_handler(
    request: HttpRequest, exception: ObjectDoesNotExist
) -> HttpResponse:
    """
    Django ObjectDoesNotExist例外を処理する
    """
    return api.create_response(
        request, {'detail': 'データが見つかりません'}, status=404)

この方法は第一の方法と比較して、長所と短所があります:

  • 長所:view関数の内容を変更する必要がなく書き方がより簡潔)、すべてのAPIがスローするObjectDoesNotExistエラーを捕捉できます。(これはPost.DoesNotExistの親クラスです)
  • 短所:「詳細な」エラーメッセージをカスタマイズできません——ObjectDoesNotExistがどのモデルオブジェクトをクエリした時に発生したのかが分からないからです。
    • もちろん、エラーの種類ごとにexception handlerを定義すれば実現できます。例えばPost.DoesNotExistだけを捕捉し、エラーメッセージを「記事が存在しません」とできます。
    • しかし、それは多くのexception handlersを定義する必要があり、少し面倒です!

1つ目の方法を選ぶか2つ目の方法を選ぶかは、状況に応じて決める必要があります。

404レスポンスの効果#

  1. HttpErrorを使用した場合:
// 404 Not Found
{
    "detail": "記事が存在しません"
}
  1. exception handlerを使用した場合:
// 404 Not Found
{
    "detail": "データが見つかりません"
}

第5章のまとめ#

第5章は正直に言って情報量がかなり多く、この4つの記事を書くのに長い時間がかかりました。しかも「リファクタリング」までしました!——元々は2つしかありませんでした。

私たちはまず、単一フィールドの検証と複数フィールド間の検証をどのようにカスタマイズするかについて議論しました。その後、段階的にAPIがスローするエラーの処理方法を学びました——よりエレガントに、より包括的に

第1回からここまで読み進めてきたなら、本当に自分を誇ってよいと思います。

次のステップ#

次は少し楽になるでしょうか?——そんなことはありません。

私たちはAPIの一般的な高度な機能を紹介する予定です。

これらの機能は、リクエストやレスポンスの処理と比較すると「必ずしも」なくてはならないものではありませんが、多くのAPIプロジェクトにとっては依然としてかなり重要です。

次の章では、これらの高度な機能を一つずつ探求し、Django Ninjaでどのように実現するかを学びます。引き続きAPI開発の世界を深く探求していきましょう!