こんにちは、RevComm の小島です。MiiTel Analytics のサーバーサイドの開発を主に担当しています。MiiTel Analytics は通話や会議の履歴・音声解析結果を集約し可視化する製品です。
MiiTel Analytics のサーバーサイドは Python で実装されており、Web フレームワークとして Django を利用しています。今回の記事では Django の ORM を利用している際のアンチパターンである first メソッドの乱用について解説します。
first メソッドの一般的なユースケース
まずは first メソッドの通常のユースケースを確認しましょう。first は Django の QuerySet の先頭のオブジェクトを返すメソッドです。例えば
# Post は記事を表すモデル Post.objects.order_by('published_at').first()
などとすれば公開日の一番新しい記事を取得することができます。
つまり複数の候補のうち最新のものや最も古いものなど、順番に意味がある時にこのメソッドは使われます。
アンチパターンの場合
一方、このメソッドを、順番に意味がない時であっても乱用しているコードを目にすることがあります。例えば、次のようなコードです。
# メールアドレスで User テーブルを検索 user = User.objects.filter(email=email).first() if not user: raise NotFound() # 以下 user を使った処理...
上記のコードはメールアドレスでユーザーを絞り込んで first メソッドを用いて先頭のモデルを取り出す、もしユーザーがなかったら例外を返すという挙動をするコード例です。こうした使い方は複数のプロジェクトで目にしたことがあり実際動くのですが、実はこのコードは潜在的な問題を抱えています。
通常、同じメールアドレスを持つユーザーが複数いることを許容しない場合が多いですし、許容したとして同一メールアドレスのユーザーの取得順には意味がありません。メールアドレスが同じユーザーが複数いたとしても、そのうちのどれでもいいからひとつを取得したいというケースは考えにくいです。
したがって、
User.objects.filter(email=email)
というコードは 2 つ以上のオブジェクトを返すことを想定していないと考えられます。
ところが、上記の絞り込みを実行してもデータがひとつだけ返ってきたのか、複数返ってきたのかについては何の情報もありません。そのため、first メソッドでユーザーを取得すると、意図せず複数のユーザーオブジェクトがデータベースに存在していても、不整合に気づけないのです。
想定されるトラブル例
このようなコードがトラブルを引き起こし得る具体的な状況としては、初期の実装では一意だったものが、要件や仕様の変更で一意性が担保されなくなるケースです。
例えば論理削除をあとで導入する場合が該当します。削除済みレコードと削除済みでないレコードを区別する必要があり、多くのクエリ(filter メソッド)において削除フラグを検証することが必要になります。
1箇所誤って削除フラグを検証してないクエリが残ってしまったとします。この時、メールアドレスが同じで論理削除済みのユーザーと削除されていないユーザーのどちらもテーブルにある場合、コードで削除済みユーザーと削除されていないユーザーのどちらを取得できるかは DB の実装依存になります。場合によっては本来は取得されるべきでないユーザーの情報を取得することになります。
さらに怖いことに、first メソッドはエラーを出さないため、本来取得すべきユーザーとは異なるまま処理が継続されます。これは不正な認証やデータの漏洩、意図しないデータの更新など大きなトラブルにつながりかねません。
アンチパターンが発生する理由
このように、場合によっては大きなトラブルを引き起こしかねない first メソッドですが、なぜ様々な場面で使われてしまうのでしょうか。
本来であれば、ただひとつのオブジェクトを取得するメソッドには get というメソッドがあり、こちらを使うのが一般的です。
user = User.objects.get(email=email)
ではなぜ get ではなく filter と first を使って書きたくなるのかと言うと、それは条件を満たすオブジェクトが存在しなかった時の挙動の違いにあります。
get は存在しなかった時に例外を送出するため、例外処理を書く必要があります。
try: user = User.objects.get(email=email) except User.DoesNotExist: user = None
一方で filter は、存在しなかった時は単に None を返します。
user = User.objects.filter(email=email).first()
比較すれば明らかですね。後者の方が簡潔なコードのように見えるのです。
どちらも条件に合うオブジェクトがただひとつ、もしくは存在しないのであれば同じ挙動をします。同じ挙動であれば簡潔なコードを書こうとする心理が働き、filter と first を使った書き方をする方が多いのだと思われます。
解決案
アプリケーションで対応できる最もシンプルな解決策は get メソッドを使うことです。 get が返しうる例外は 2 種類あり、ひとつは上記の例にも挙げた DoesNotExist で、もうひとつは MultipleObjectsReturned です。
MultipleObjectsReturned は 2 つ以上の条件を満たすオブジェクトが存在する場合に送出される例外です。よって、もし get を使えば意図せず 2 つ以上のデータがあった場合はシステムエラーとなり、データ不整合に気づくことができます。
しかし、単に get を使うようルール化するだけではアンチパターンを解消するには至りません。このアンチパターンが発生するのは fitler と first を使ったほうが簡潔に書けるという(一見もっともらしい)メリットがあるからです。であれば、単に get を使うというルール化をするだけでなく、コードの簡潔さを維持しつつ get を使えるようにしなければなりません。
その手段として、 MiiTel Analytics では Django の組み込みの QuerySet API をモデルの定義ファイル以外から直接呼ぶことを禁止し、次のようにモデルの定義ファイルに QuerySet に必要なメソッドを追加する形式をとっています。
# models.py from django.db import models class UserQuerySet(models.QuerySet): def get_by_email(self, email: str) -> Optional["User"]: try: return self.get(email=email) except User.DoesNotExist: return None class User(BaseModel): email = models.CharField(max_length=256) # other fields... objects: UserQuerySet = UserQuerySet.as_manager()
※Django のユーザーモデル (auth_user テーブル) を使っている場合には上記のコードはそのままでは使えません。
このようにすれば、メールアドレスで User を取得するコードは
user = User.objects.get_by_email(email=email)
となり、filter と first を使うのと同じように簡潔に書くことができます。それに加えて、データ不整合時にエラーが出るため、問題の早期発見につながり、アプリケーションが不正な状態で動き続けることを避けることができます。
まとめ
- オブジェクトがただひとつ欲しい場合に filter と first を利用するのはデータ不整合を見逃す可能性がある
- filter と first が使われてしまう理由は簡潔に書けるから
- ただひとつのオブジェクトを取得するには原則として get を使おう
- QuerySet に必要なメソッドを定義すれば filter と first を使った方法と同等に簡潔に同じ処理を実装できる
first はその名前の通り、あくまで QuerySet の先頭のオブジェクトをとるためのメソッドです。順番に重要性がない、もしくは複数のオブジェクトが返ってくる想定をしていない時の利用は想定されていません。
組み込みのシンプルなメソッドで簡潔に処理が書けるのはたしかに魅力的なことです。しかしこのケースのようにリスクがあるケースもあり、たとえ実装が煩雑になっても用途に合致したメソッドを採用することはとても重要だと思います。
アプリケーションのコードの品質を担保するために、こうした小さな努力の積み重ねを徹底することが非常に重要だと考えています。そして、こうした努力の結果として MiiTel というサービスの安定性や信頼の獲得に繋がるのだと信じています。