🍺

Django Ninja に至るまでの検討の記録

2022/12/03に公開
1

こんにちは!LARPAS 株式会社でプロダクトマネージャーをしている Chanmoro ともうします🙋‍♂️

https://lapras.com/public/chanmoro

これは LAPRAS Advent Calendar 2022の3日目の記事です📖
https://qiita.com/advent-calendar/2022/lapras

最近は飲み歩き系 YouTuber を見るのが好きになり、その影響でこれまで全く興味がなかったもつ焼き屋さんで飲むのに突然ハマってしまいまして、近所のもつ焼き屋開拓にいそしんでおります。
もつ焼きとビールの組み合わせは最高ですね。 (糖質を気にして2杯目以降はハイボールにしています)
チェーン店のかぶら屋というところがとてもコスパが良いのでふとした瞬間に行きたくなってしまいます。
http://www.kaburaya.bz/

この記事を読んでくださった方でおすすめのもつ焼き屋さんがあればぜひコメントで教えてください!

ということで今日も張り切って書いていきたいと思います!


個人的に開発をお手伝いしているプロジェクトで新規に Web API を1人で開発することとなり、書き慣れているからという理由で Python で実装することにしました。
(最初は Typescript にしようかなとも思ったのですが、商用で動かすサービスのバックエンドを Typescript で書いた経験がほぼなく、さすがにお仕事だしそこまでチャレンジするのはリスクすぎると思ってやめておきました。)

1から作るしせっかくなら使ったことがないライブラリを試してみようと思い色々と調べて試した結果、最終的に Django Ninja を使って実装しました。

https://django-ninja.rest-framework.com/

Django Ninja は使ってみて結構いいライブラリだと感じたのですが、ググっても使用例の情報があまりなく特に日本語の情報がほとんど無いようだったので、この記事では Django Ninja を選択した理由と実際に使ってみてどうかというのを書きます。

Django Ninja に至るまでの検討の記録

Django Ninja を使おうと決めるまでにいくつかのライブラリを試しましたのでそれぞれ所感を書きます。

前提として実際どのライブラリを選んだとしてもいい選択だと思います。
僕が Django が一番使い慣れているという理由でめちゃくちゃ Django に引きずられていますので、あくまで個人の趣向の問題ということで 「※個人の感想です」 と心の中で付け足して読んでいただけると幸いです💭

FastAPI + SQLAlchemy + Alembic

最初の候補として浮かんだのは FastAPI で、実は最初は FastAPI にするつもりで実装し始めてました。

https://fastapi.tiangolo.com/ja/

過去に副業でお手伝いしていた仕事で FastAPI を使って Web API を実装したことがありある程度の使い勝手を知ってはいたものの、その時は簡単な CRUD を実装したくらいだったのでもっとガッツリ使ってみたいという理由で手をつけてみました。
FastAPI の特徴は様々ありますが、個人的に一番メリットを感じるのは Python の型ヒントを利用して Web API のスキーマを定義でき、バリデーションも実行できるところだと思っています。

Fast API はいわゆるマイクロフレームワークの部類で、主にルーティングと API の入出力に特化しており ORM の機能は入っていないため別のライブラリと組みわせて使うことになります。
今回は ORM に SQLAlchemy を使った組み合わせを検討しました。

https://www.sqlalchemy.org/

FastAPI のドキュメントに SQLAlchemy と組み合わせて使う場合のチュートリアル があるので概ねこれに沿って実装できます。

僕は Django を使い慣れているため、 Django の model 定義から DB マイグレーションを自動生成する機能がめちゃくちゃ便利でそれに完全に慣れきってしまっています。そのため同じことを SQLAlchemy でも実現するために Alembic も組み合わせました。

https://alembic.sqlalchemy.org/en/latest/

Tortoise ORM という選択肢もある

調べている過程で知ったのですが Tortoise ORM という ORM もあり、これは Djang の ORM にインターフェースがとても近いので、Django を使い慣れている人であればこちらの方がより使いやすいかもしれません。

https://tortoise-orm.readthedocs.io/en/latest/

FastAPI を使わなかった理由

FastAPI + SQLAlchemy + Alembic の組み合わせでアプリケーションで欲しい機能としては全く問題なく揃いますが、さらにこれらの機能が欲しいと思ってしまいました。

アプリケーションと同じ環境で動く REPL が欲しい

開発中のデバッグや運用開始してからの本番環境での調査・データパッチの実行のために、個人的にはどうしてもアプケーションと同じ環境で動作する REPL が欲しいです。
もちろん Python の REPL は普通に使えるのですが、 FastAPI を使うケースでは DB のコネクションや依存するサービスを DI するのが一般的なようで、これに合わせると REPL を起動したときも同様のセットアップが必要になります。
これは自前で作っても恐らくそんなに複雑な処理ではないと思いますし最初に作ってしまえば手を入れることはほとんどない類のものですが、にしてもちょっと面倒だなと感じてしまいました。
(Django には ./manage.py shell のコマンドや django-extensions で使える shell_plus というコマンド があるので REPL から ORM を使うのがとても簡単です💭)

テスト用の DB の管理

テストの実行時はテスト用の DB を作って向き先を切り替えたり、テスト終了後にデータをクリーンアップするような機能も欲しいです。
これは以前 FastAPI を使った際に自前で実装したことがあるのでどうやればいいかはある程度想像がついているのと、こちらも最初に作ってしまえば手を入れることはほとんどない類のものですが、やっぱり改めて作るのは面倒だなあという気持ちになりました。
(Django の場合はテストを実行するとテスト用の DB を自動で作成して向き先を切り替えてくれる機能があるので、それを思うと「ここも作らないといけないのか・・・」という気持ちが募りました。)

泣く泣く諦めることに・・・

ということで色々試してみたものの 「今まずやらないといけないのって、足回りに必要な機能の再発明ではなくてアプリケーションのコードを早く書き始めることだし、これなら結局最初から足回りが用意されている Django をベースにした方がいいのでは・・・??」 という気持ちになり、泣く泣く FastAPI の利用を見送ることにしました。
(ちなみにこの時点で既に割とコードを書いてたのでここで引き返すか迷いましたが、これはサンクコストだと自分に言い聞かせて捨てました・・・😇)

実際に FastAPI + SQLAlchemy で本番運用されているケースもたくさんあると思いますし、見送ったのは僕の経験値不足による理由しかないので、これらのライブラリに非があるわけでは全くありませんので、念のため・・・🥲

Django REST Framework (DRF)

さて、 Django の使い心地が忘れられない・・・!となって結果 Django を使おうとなったわけですが、Django 自体はサーバーサイドで HTML を描画する Multiple Page Application のためのフレームワークなので、Web API を作ろうとすると細かい機能のサポートが足りないと思うところがあります。

そこでよく利用されるのが Django REST Framework (略称: DRF) です。

https://www.django-rest-framework.org/

DRF は LAPRAS 社のプロジェクトで使ったことがあり今も本番運用されています。こちらもある程度使った経験があるということで検討の選択肢に入りました。

DRF を使うと CRUD がとても簡単に実装できるのもありますし、ブラウザから Web API にアクセスすると GUI が表示されて管理画面っぽく使えるというところも好きです。
インターネットに公開する API だとこういうのは不要だと思いますが、外に出さずに内部で呼ばれる API の場合は動作確認が簡単にできて便利だったりします。
(いや普通に curl 使えばいいじゃん!はい、すみません。)

DRF を使わなかった理由

DRF は REST の Resource と Django の model が1対1だったり、シンプルな CRUD を実装するのがとても簡単なのですが、Resource と model が1対1ではない場合や、単純な入出力以外のロジックを入れたい場合にカスタマイズがやや面倒と感じることが実際に使ってみて多かったです。

これは利便性とのトレードオフでなのですが、密結合なほど実装をショートカットできる量が多い反面、ブラックボックスな範囲が増えたり DRF 独自のお作法を覚える必要があります。
またフレームワークが想定してるユースケースに沿わない使い方をすると、フレークワークを使わない時よりもかえって実装を難しくしてしまうという場合もあります。
(ユースケースに沿わないような複雑な仕様がそもそもアプリケーションに必要なのか??という論点ももちろんあります🙅‍♂️)

ということで、DRF よりは Django Ninja の方がより扱いやすそうと感じたため、結果的に DRF も見送ることにしました。
でももし Django Ninja がなかったら DRF を使っていたと思います🥲

Django + FastAPI

さて、そんな経緯を経て「Django でも FastAPI みたいな恩恵が受けられないのかな〜」と思って色々ググっていると、 Django と FastAPI を組み合わせて使うサンプルコードが見つかりました。

https://www.stavros.io/posts/fastapi-with-django/

https://qiita.com/kigawas/items/80e48ccce98a35f65fff

(この Qiita の記事を書いた方はこれを英訳した記事も書いていて素晴らしいなと思いました)

Django + FastAPI を使わなかった理由

こちらも記事を参考に試してみて問題なく動いたのでいけそうな雰囲気を感じました。
ただそれでもどの程度本番運用の実績があるかは分からず若干の不安を感じたので、結局これは選択しませんでした。

(後で分かったことですが、Django Ninja の作者も Django と FastAPI を組み合わせて使うのを試していたようで、DB コネクションの管理に問題があり解決が難しいと指摘しています。開発したモチベーションにこれについて書かれています。)

Django Ninja

さらにググった結果、Django Ninja というライブラリを見つけました🥷

https://django-ninja.rest-framework.com/

Django Ninja は Django と組み合わせて使うライブラリで、コントローラー層 (Django でいう view) をサポートしてくれるライブラリです。具体的にはリクエストのルーティングや入出力のスキーマの定義が簡単にできるものです。
そして、Python の型ヒントを利用して Web API のスキーマを定義でき、バリデーションも実行できるという、Fast API で僕がメリットに感じている機能が含まれています(!)
これは Django Ninja のドキュメントにも "Django Ninja is heavily inspired by FastAPI" と書かれているとおり、Django でも FastAPI の恩恵を受けたい!というモチベーションで開発されたためです。

「求めていたものはこれだよこれ!!!」という気持ちになりましたが、ググってもあんまり使用例の記事が出てこず、それでも GitHub リポジトリのスターが 3000 以上ついているし開発もアクティブそうなので若干の不安を抱えつつもとりあえずこれを使って実装してみることにしました。

その後、開発を進めて現時点で Web API のエンドポイントを30個ほど実装しましたが、特に大きな問題はなく使えているので今のところとても満足しております。

結局どういう機能を求めていたのか?

改めて振り返ると僕が最低限欲しいと思っていた機能はこれらだったと気づきました。

  • アプリケーションが動作するのと同じ環境で使える REPL (DB への接続も含む)
  • テスト用 DB の管理
  • model からの DB マイグレーションの自動生成

「新しいライブラリを使いたい!」という気持ちに反してとても地味だなあと思いますが、なるべく手を抜きたいという気持ちと、自分でメンテしていくのを考えるとやっぱり重要なんですよね。
これにプラスして FastAPI や Django Ninja のような、型ヒントにより Web API のスキーマ定義とバリデーションが使えると最高!だったということです。

Django Ninja の紹介

さて、ここからは Django Ninja について使用感がわかる例をいくつか紹介していきます。
もっとちゃんと知りたいんだ!という方はぜひ公式のチュートリアルをご参照ください👀

model の CRUD

例としてこんな感じの Item モデルを定義します。このモデルに対する CRUD の処理をどんな感じで書けるかを簡単に紹介します。

class Item(models.Model):
    name = models.CharField(max_length=128)
    description = models.TextField()

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

Create

Item モデルに対する Create はこんな感じのコードになります。

class ItemRequest(Schema):
    name: constr(strip_whitespace=True, min_length=1, max_length=128)
    description: str


class ItemResponse(ModelSchema):
    class Config:
        model = Item
        model_fields = [
            "id",
            "name",
            "description",
            "created_at",
            "updated_at",
        ]


@api.post("/items", response=ItemResponse)
def create_item(request, payload: ItemRequest):
    return Item.objects.create(**payload.dict())

便利なポイントは、リクエストのスキーマに pydantic で定義されている型の constr などを使うとこれに沿ってバリデーションを実行してくれて、違反するデータが含まれる場合にはエラーのレスポンスを返してくれます。

また ModelSchema を継承してレスポンスの型を定義すると ORM のモデルオブジェクトからレスポンスのスキーマに自動変換してくれます。

余談の pydantic

急に横道にそれるのですが pydantic めちゃくちゃいいんですよ。
特にお気に入りなのが Settings の機能で、環境変数から設定値を読み込むというのを自動でやってくれます。普通にやると os.environ を使うと思うのでそれもいいんですけど、値が全部文字列でくるので int や bool に値を変換するのを忘れやすかったりするんですよね。そういうのも Settings を使うとめっちゃ簡単にできます。

https://pydantic-docs.helpmanual.io/usage/settings/

Read

Get の処理はこんな感じで書けます。

@api.get("/items", response=list[ItemResponse])
def list_items(request):
    return Item.objects.all()


@api.get("/items/{item_id}", response=ItemResponse)
def get_item(request, item_id: conint(ge=1)):
    return get_object_or_404(Item, id=item_id)

パスパラメーターを使う場合はパスの定義に {hoge} の形式で書くとその名前で引数として受け取れるようになっています。パスパラメーターの方にも conint などの pydantic の型を使うことができ、これもバリデーションを実行してくれます。

また get_object_or_404 のような Django で定義されている view のヘルパーも使えます。

CRUD の全容

そのほか Update, Delete も含めたコードはこんな感じです。

from django.shortcuts import get_object_or_404
from ninja import ModelSchema, NinjaAPI, Schema
from pydantic.types import conint, constr

from item.models import Item

api = NinjaAPI()


class ItemRequest(Schema):
    name: constr(strip_whitespace=True, min_length=1, max_length=128)
    description: str


class ItemResponse(ModelSchema):
    class Config:
        model = Item
        model_fields = [
            "id",
            "name",
            "description",
            "created_at",
            "updated_at",
        ]


@api.post("/items", response=ItemResponse)
def create_item(request, payload: ItemRequest):
    return Item.objects.create(**payload.dict())


@api.get("/items", response=list[ItemResponse])
def list_items(request):
    return Item.objects.all()


@api.get("/items/{item_id}", response=ItemResponse)
def get_item(request, item_id: conint(ge=1)):
    return get_object_or_404(Item, id=item_id)


@api.put("/items/{item_id}", response=ItemResponse)
def update_item(request, item_id: conint(ge=1), payload: ItemRequest):
    item = get_object_or_404(Item, id=item_id)
    item.name = payload.name
    item.description = payload.description
    item.save()
    return item


@api.delete("/items/{item_id}", response=None)
def delete_item(request, item_id: conint(ge=1)):
    item = get_object_or_404(Item, id=item_id)
    item.delete()

API ドキュメントの自動生成

デフォルトでこんな感じの Swagger の API ドキュメントが自動生成されて、ブラウザから API の呼び出しもできます。また OpenAPI に準拠した JSON のスキーマファイルも生成されます。

swagger

ちょい面倒 & いい対処法がわからなかったところ

共通のパスパラメーターを使うけど実装ファイルを分けたい場合

プロジェクトごとに item や member を管理するために、このようなエンドポイントを定義したい場合を考えます。

  • /projects/{project_id}/items
  • /projects/{project_id}/members

Django Ninja にはルーティングを分割する機能があるので基本はこれを使えばできそうなのですが、今回の例では共通部分の /projects/{project_id} にパスパラメーターが含まれているのが事を複雑にしています。

これの回避策が issue などを見てもよくわからなかったので、僕はこんな感じに実装して回避しています。

ディレクトリ構造

エンドポイントに対応した処理を見つけやすくするために、リクエストパスとディレクトリ構造を合わせるようにします。ここは絶対守りたいポイントでした🔥

api
├── __init__.py
└── projects
    ├── __init__.py
    ├── items.py
    └── members.py

トップレベルの定義

パスとディレクトリ構造を合わせるために /projects は別の router に切り出します。
ここまでは Django Ninja で想定されている範囲なので難しくありません。

api/__init__.py
api = NinjaAPI()
api.add_router("/projects", projects.router)

api/projects の定義

これより下の階層もパスとディレクトリ構造を合わせるために、router オブジェクトを下位の階層に渡します。
このために init_router という共通の名前の関数を定義することにしました。
パスパラメーターがなければこんな事をせず単純に router をネストさせればいいのですが、これより下位の階層でも {project_id} を参照するためにこんな形になっています。

api/projects/__init__.py
from ninja import Router

from . import items, members

router = Router()


items.init_router(router)
members.init_router(router)

api/projects/items の定義

ここでエンドポイントの処理を書きます。
/projects の名前はトップレベルの __init__.py で定義されているため、ここでは {project_id}/items のように {project_id}/ 以下のパスを定義することになります。

こうしないと list_items などのエンドポイントの関数の引数で project_id を受け取れないんですよね。

api/projects/items.py
from ninja import Router


def init_router(router: Router):
    @router.get("/{project_id}/items")
    def list_items(request, project_id: int):
        return f"list items. project_id: {project_id}"

    @router.get("/{project_id}/items/{item_id}")
    def get_items(request, project_id: int, item_id: int):
        return f"get project_id: {project_id}, item_id: {item_id}"

とりあえずはこれで解決できたものの、結構不恰好な感じがしているのでもうちょっといい感じに書けるやり方がわかる方はぜひ教えてください・・・🥲

複数のエンドポイントに共通のチェックを入れたい場合

先程の例と合わせて以下のようなエンドポイントがあるとします。

  • /projects/{project_id}/items
  • /projects/{project_id}/members

ここで、ログイン中のユーザーが対象の project_id へのアクセス権限があるかのチェックをして権限がない場合は HTTP 403 のレスポンスを返すようなケースを考えます。

Django でこういう処理をやる場合は権限のチェックをする独自のデコレータを定義して、該当のチェックをする view に1つづつデコレーターをつけていくのがよくあるやり方です。

今回のケースでもデコレーターを1つづつ書いていけば同様のチェックは実現できるのですが、せっかく共通の router を定義しているので、そこでチェックを入れれたらデコレーターの書き忘れもないし楽なのになあと思いました。

残念ながら現状の Django Ninja ではそのような機能がないので、Django Ninja の認証の機能を使うことで暫定的な対処としました。

まずはトップレベルの定義のところで /projects のルートに対して auth で認証のための処理を指定します。

api.add_router("/projects", projects.router, auth=projects_auth)

projects_auth の処理はこうなっていて、ここではこれらのチェックをしています。

  • アクセスしてきたユーザーがログイン中かのチェック
  • ログイン中ユーザーが対象の project_id へのアクセス権限があるかのチェック
import re

from django.core.exceptions import PermissionDenied
from django.http import HttpRequest
from ninja.security import django_auth


def projects_auth(request: HttpRequest) -> bool | None:
    if not django_auth(request):
        return None

    if not check_membership(request):
        raise PermissionDenied()
    
    return True

def check_membership(request: HttpRequest) -> bool:
    # リクエストパスから project_id を取得する
    m = re.match(r"/api/projects/(?P<project_id>[^/]+)/?", request.path)
    if not m:
        return False
    if not m.group("project_id").isdecimal():
        return False

    if not """<project_id に対して request.user のアクセス権限があるかのチェックをするサムシング>""":
        return False

    return True
    @app.exception_handler(PermissionDenied)
    def permission_errors(request, exc):
        logger.exception(exc)
        return app.create_response(
            request,
            {"detail": "Permission Denied"},
            status=403,
        )

check_membership の処理、やばいですね。 「え、せっかく Django Ninja にはパスパラメーターを簡単に扱える機能があるのに突然の正規表現でパース!?正気!!!???」 となります。
PermissionDenied のやり方もまあいいのかもしれないけど、いや、うーむという気持ちになります。
auth に指定した関数が Falsy な値を返すと HTTP 401 が返されるのですが、check_membership でアクセス権限がないと判定された場合は HTTP 403 を返したいです。
これを実現するために check_membership の処理では PermissionDenied を投げて、これを拾う exception_handler を登録して HTTP 403 を返すようにしています。

でも仕方ないんです・・・・。こうするしかなかったんです・・・・・・。許してください・・・・。

これを解決するかもしれない Class Based Operations

これは今回僕が困ったようなケースに対応するために Class Based Operations という機能が提案されています。

https://django-ninja.rest-framework.com/proposals/cbv/

具体的にはこういった書き方ができるようにする機能の提案です。

@router.path('/project/{project_id}/tasks')
class Tasks:
    def __init__(self, request, project_id=int):
        user_projects = request.user.project_set
        self.project = get_object_or_404(user_projects, id=project_id))
        self.tasks = self.project.task_set.all()

    @router.get('/', response=List[TaskOut])
    def task_list(self, request):
        return self.tasks

これは僕も非常に欲しい機能なのですが、仕様について議論をしている issue を見ると結構長いこと時間が経っていて今のところ実装される気配はなさそうです🥲
https://github.com/vitalik/django-ninja/issues/15

issue のコメントをざっと見てみるとrouter に middleware を指定できるようにするアプローチでも十分かもなーと思ったりしました。

まとめ

さて、この記事では僕が Django Ninja を選択した理由と、実際使ってみてどんな感じかということについて書きました。

Django Ninja は Django 依存の僕としてはとてもいいライブラリだと思うので、Python で Web API を実装する際には検討してはいかがでしょうか。

今開発しているサービスはまだ本番運用を開始していないので、もしかすると今後運用する中で困ることが出てくるかもしれませんが、そんな知見が溜まればまた記事にしようと思います。

それでは皆さんもステキなもつ焼き呑みライフをお過ごしください🍺👋

もつ焼き飲み

Discussion