コンテンツにスキップ

ネスト構造のレスポンス#

2024 iThome鉄人レース

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

API開発において、関連モデル間のデータを同時に返す必要がある状況によく遭遇します。

特に「1対1」や「1対多」の関連を処理する場合、多層構造になるのが常です。

私たちはネスト構造Nested Objects)の方法でデータを返すことを望みます。これにより、APIのユーザーは複数回のリクエストを行うことなく、必要な情報を一度に取得できるようになります。

この記事では、「単一記事情報の取得」APIの例を引き続き使用・拡張し、Django Ninjaでネスト構造のレスポンスを実装し、APIレスポンスをより豊かで体系的なものにする方法を説明します。

この記事のすべてのコードの変更は、こちらのPRを参考にしてください。

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

👉 Django-Ninja-Tutorial


1. 問題の背景#

以前のAPI設計では、「単一記事情報の取得」のレスポンスには記事情報と作者のidが含まれていました:

class PostResponse(Schema):
    id: int
    title: str
    content: str
    author_id: int
    created_at: datetime
    updated_at: datetime

経験豊富な開発者であれば、idであれauthor_idであれ、通常はサービスのユーザーが見るためのものではなく、フロントエンド担当者が柔軟に活用するためのものであることを知っています。

例えばシステム画面において、記事に作者の個人情報へのリンクが含まれており、クリックすると作者情報が見られるかもしれません。この時フロントエンドは、idを通じてさらに別のAPI「ユーザー情報の取得」を呼び出し、追加のコンテンツを取得する必要があります。

追加情報が多い場合、このような「デカップリング」設計は非常に合理的です。しかし、作者の「必要情報」を一括で提示したい場合、複数回に分けて呼び出す設計はやや冗長に感じられます。

そのため、ネスト構造が必要なのです!

APIはレスポンス内に作者の「必要情報」を直接埋め込むことができるため、ユーザーは何度もリクエストを行う必要がなくなります。ここでは、作者の「名前」と「email」を一緒に表示する例を用います。


2. APIの改善:スキーマの再定義#

1つのことを行うだけで、レスポンスの内容と構造を変えることができます——PostResponseを再定義するのです:

from ninja import Schema
from datetime import datetime

class _AuthorInfo(Schema):
    id: int
    username: str
    email: str

class PostResponse(Schema):
    id: int
    title: str
    content: str
    author: _AuthorInfo  # ネスト構造、作者情報を含む
    created_at: datetime
    updated_at: datetime

_AuthorInfoは作者のidnameemailを含み、この構造をPostResponseauthorフィールド(情報の意味が変わったため、author_idから名前を変更)に埋め込みます。

注意していただきたいのは、変更したのはPostResponseのみであり、view関数は以前と同じで一切変更がないことです:

@router.get(path='/posts/{int:post_id}/', response=PostResponse)
def get_post(request: HttpRequest, post_id: int) -> Post:
    """
    単一記事の取得
    """
    post = Post.objects.get(id=post_id)
    return post

これにより、記事と作者の必要情報を同時に取得することができます。

余談:命名に関するアドバイス#

私が_AuthorInfoで「アンダースコア始まり」の命名原則を使用していることにお気づきかもしれません。Pythonにおいて、これはこの属性、関数、クラスが主に内部で使用されることを示す慣例です。

いわゆる「内部」にはさまざまな解釈があり得ますが、ここでの私の意図は:それはある1つまたは複数のスキーマの一部に過ぎず、view関数から直接呼び出されるものではないということです。

この命名の細部を軽視しないでください。スキーマの数が増えるにつれ、新しいAPIを開発する際、既存のものを再定義するか再利用するかを決定するために、常に既存のスキーマを閲覧する必要があります。

この時、このような命名の区別があると非常に「親切」だと感じられます——大小さまざまなスキーマをあちこち探して、目が疲れる必要がありません。

実務において、ネストされたスキーマを書く機会は少なくないため、このような良い習慣を身につける価値があると考えています。

更新後のレスポンス:Nested Response#

APIのレスポンスを見てみましょう:

// http://127.0.0.1:8000/posts/2/
{
    "id": 2,
    "title": "Alice's Django Ninja Post 1",
    "content": "Alice's Django Ninja Post 1 content",
    "author": {
        "id": 1,
        "username": "Alice",
        "email": "alice@example.com"
    },
    "created_at": "2024-09-12T02:28:16.801Z",
    "updated_at": "2024-09-12T02:28:16.801Z"
}

新しいauthorフィールドの内容を見てください、ネスト構造になっており、非常に完璧です!

ユーザーは記事の作者の名前とemailを直接見ることができ、さらに詳しい作者情報を見たい場合は、引き続きidフィールドを通じてフロントエンドから別のAPIを呼び出すことができます。

これは理想的な妥協案です。


3. ネスト情報の「フラット化」#

前述の「妥協案」は確かに非常に理想的です。しかし、時に私たちの要件はもっとシンプルです。

例えば、「記事リストの取得」APIにおいて、作者の情報も表示する必要があるかもしれませんが——この時は名前だけで十分です。

作者idは不要ですし、emailも不要で、名前だけでよいのです。

では、なぜ「ネスト情報のフラット化」と呼ぶのでしょうか?それは、作者の名前がPostモデルの直接の属性ではなく、実際には関連モデルであるUserから来ているからです。

私たちは作者に関するネスト情報を簡略化する必要があります。

元々はこのようでした:

"author": {
    "id": 1,
    "username": "Alice",
    "email": "alice@example.com"
},

現在はこうなります:

"author_name": "Alice",

2層から1層に戻った(ただし作者idではなく名前になりました)ため、「フラット化」(flatten)と呼びます。

スキーマの疎結合化#

記事リストの取得」APIのレスポンス形式が、実は「単一記事情報の取得」と共有されていることを覚えていますか:

@router.get(path='/posts/', response=list[PostResponse])
def get_posts(...) -> QuerySet[Post]:
    """
    記事リストの取得
    """
    ...

どちらもPostResponseを使用しています。

この記事の前半で行った「単一記事情報の取得」レスポンスの変更は、「記事リストの取得」にも影響を与えます——これは通常、私たちが望む結果ではありません。

そのため、「記事リストの取得」APIのために独自のリスポンススキーマを作成し、前述の要件に従って情報を簡略化する必要があります!

私の計画は以下の通りです:

  1. 記事のコンテンツ(content)と更新時間(updated_at)の2つのフィールドを省略します。リストでは不要だからです。
  2. 作者の部分は「名前」だけを残します。

4. ネスト情報のフラット化の実装——@propertyの使用#

まず、新しいスキーマがどのように定義されるか見てみましょう:

class PostListResponse(Schema):
    id: int
    title: str
    created_at: datetime
    author_name: str

不思議に思うかもしれません、author_name属性はどこから来たのでしょうか?Postモデルにはないはずです。

その通りです!なぜなら、私たちが自分で定義したものだからです——@propertyを使用して:

# post/models.py
class Post(models.Model):
    ...

    @property
    def author_name(self) -> str:
        return self.author.username

これにより、Postモデルオブジェクトにauthor_nameという属性が追加されます。

ただし、この属性を呼び出すことは通常、2回目のクエリをトリガーする(それが関連モデル上の属性であるため)ことを意味するので、view関数ではDjango QuerySetメソッドのselect_relatedを組み合わせる必要があります:

posts.filter(title__icontains=title).select_related('author')

これはDjango ORMにおいてよく見られる「N+1」問題ですが、ここでは深くは立ち入りません。

より良いアプローチ#

この方法は少しエレガントではないと感じるかもしれません(少なくとも私が初めて見た時はそう思いました!)——特にDjango REST frameworkのアプローチと比較すると。

Django REST frameworkでは、シリアライザの中でこのように書きます:

author_name = serializers.CharField(source="author.name")
ずっと簡潔ですよね?

しかし、これは確かにDjango Ninjaの作者が初期に推奨していた方法です。

心配しないでください。第16回では、より良く、より現代的なアプローチを紹介します。しかし、@property特定の状況下では依然として非常に役立ちます。

フラット化後のレスポンス#

最後に、「記事リストの取得」APIの新しいレスポンスを見てみましょう:

// http://127.0.0.1:8000/posts/
[
    {
        "id": 1,
        "title": "Alice's Django Ninja Post 1",
        "created_at": "2024-09-12T02:28:16.801Z",
        "author_name": "Alice"  // フラット化後の作者名
    },
    {
        "id": 2,
        "title": "Alice's Django Ninja Post 2",
        // ...(以下省略)
    },
    // ...(以下省略)
]
見事です!


まとめ#

この記事では、Django Ninjaでスキーマを使用してネスト構造のレスポンスを実装する方法を示しました。

続いて、このネスト構造を「フラット化」し、元の作者idを名前フィールドに置き換える方法を紹介しました。

これらの方法により、APIレスポンスの柔軟性が大幅に向上します。

次の記事では、シリアライズとレスポンス構造の処理におけるDjango NinjaとDjango REST frameworkの設計理念の違いを議論し、両者の長所と短所を比較します。