ページネーション
ページネーションは、一見複雑そうに見えないものの、実は複雑なトピックです。思わぬ落とし穴に陥ったり、ベストプラクティスに従えなかったりしがちです。このページでは、ページネーションを「正しく」実装する方法を解説します。つまり、このページを読んで理解すれば、あなたのクライアントはより堅牢で将来性のあるものになり、将来的に作業が楽になるはずです。
このガイドから何かひとつだけ持ち帰るなら、それは ページネーションのURLを自分で組み立ててはいけないということです。
JSON:APIモジュールからのすべてのページ分割されたレスポンスには、コレクションの次のページへのリンクがすでに組み込まれています。そのリンクに従ってください。
このドキュメントの前半では、APIの重要な機能と、ページネーションを「正しく」実装する方法について説明します。ドキュメントの最後には、 よくある質問と落とし穴への回答があります。
どうすればよいか?
JSON:APIモジュールからのすべてのページ分割されたレスポンスには、ページネーションリンクが組み込まれています。小さな例を見てみましょう:
{
"data": [
{"type": "sample--type", "id": "abcd-uuid-here"},
{"type": "sample--type", "id": "efgh-uuid-here"}
],
"links": {
"self": "<collection_url>?page[offset]=3&page[limit]=3",
"next": "<collection_url>?page[offset]=6&page[limit]=3",
"prev": "<collection_url>?page[offset]=0&page[limit]=3"
}
}
いくつかの点に注目してください:
linksキーの下に3つのページネーションリンクがあります:self: これは現在のページのURLです。next: これは次のページのURLです。prev: これは前のページのURLです。
page[limit]は 3 ですが、リソースは 2 つしかありません (?!)
ページネーションリンクの有無が重要です。 次のことを理解しておく必要があります:
nextリンクが存在する場合、さらにページがあります。nextリンクが 存在しない 場合、最後のページにいます。prevリンクが存在する場合、最初のページではありません。nextリンクもprevリンクも存在しない場合、ページは1つだけです。
ページ制限が3でも、リソースは 2つしかありません! これは、セキュリティ上の理由から1つのエンティティが除外されたためです。next リンクが存在することから、レスポンスを埋めるだけのリソースが足りないからではないことがわかります。これについて詳しく知りたい場合は、 下で詳しく説明されています。
さて、重要な事実が確認できました。次に、クライアントをどう構築すべきか考えてみましょう。参考として、いくつかの擬似JavaScriptを見てみます。??
サイトの最新コンテンツの一覧を表示したいと想像してください。また、いくつかの「プレミアム」コンテンツがあるとします。プレミアムコンテンツを見ることができるのは、有料購読者だけにすべきです。「トップ5」コンポーネントも欲しいと考えていますが、さらにコンテンツが存在する場合、ユーザーが「次のページ」リンクをクリックして、次の新しいコンテンツ5件を見られるようにすべきです。
おそらく 素朴な実装 は、おおよそ次のようになるでしょう:
const baseUrl = 'http://example.com';
const path = '/jsonapi/node/content';
const pager = 'page[limit]=5';
const filter = `filter[field_premium][value]=${user.isSubscriber()}`;
fetch(`${baseUrl}${path}?${pager}&${filter}`)
.then(resp => {
return resp.ok ? resp.json() : Promise.reject(resp.statusText);
})
.then(document => listComponent.setContent(document.data))
.catch(console.log);
しかし、ひどいエラーハンドリングを無視したとしても、これはあまり堅牢な実装ではないことがすでにわかります。
上で、レスポンスに必ず5件のアイテムが含まれるとは限らないことを見ました。そのうちの2つのエンティティにアクセスできない場合(例えば未公開の場合)、「トップ5」コンポーネントには3件しか表示されません!
また、不要なフィルターがあります。サーバーはユーザーが閲覧を許可されていないコンテンツをすでに除外しているはずです。そうでなければ、悪意のあるユーザーがクエリを簡単に改変して「プレミアム」コンテンツを見られるため、アプリケーションにアクセスバイパスの脆弱性が生じる可能性があります。必ずサーバー側でアクセス制御を実施してください。クエリに頼ってそれを処理させてはいけません。
修正しましょう:
const listQuota = 5;
const content = [];
const baseUrl = 'http://example.com';
const path = '/jsonapi/node/content';
const pager = `page[limit]=${listQuota}`;
const getAndSetContent = (link) => {
fetch(link)
.then(resp => {
return resp.ok ? resp.json() : Promise.reject(resp.statusText);
})
.then(document => {
content.push(...document.data);
listContent.setContent(content.slice(0, listQuota));
const hasNextPage = document.links.hasOwnProperty("next");
if (content.length <= listQuota && hasNextPage) {
getAndSetContent(document.links.next);
}
if (content.length > listQuota || hasNextPage) {
const nextPageLink = hasNextPage
? document.links.next
: null;
listComponent.showNextPageLink(nextPageLink);
}
})
.catch(console.log);
}
getAndSetContent(`${baseUrl}${path}?${pager}`)
まず、filter がなくなっていることがわかります。これは、フィルターに頼るのではなく、アクセスチェックがサーバー側で実行されることを前提にしているからです。これが唯一の安全な解決策です。パフォーマンス最適化として追加し直すこともできますが、おそらく必要ありません。
次に、サーバーはユーザーがアクセスできないリソースを単に削除するため、レスポンスに 実際に いくつのリソースが含まれているかを 本当に 確認する必要があります。
「素朴な」実装では、すべてのレスポンスに5件のアイテムがあると想定していました。この例では、リソースの「クォータ」を5に設定しました。リクエストを行った後、クォータを満たしたかどうかを確認します。また、サーバーにさらにページがあるかどうかも確認します(サーバーに next リンクがあればそれがわかります。覚えていますよね?)。
クォータを満たしていない かつ 最後のページではない場合、ドキュメントから取得した next リンクを使って別のリクエストを行います。次のページの新しいURLを手作業で組み立てていないことに注目してください。JSON:APIサーバーがすでに作ってくれているので、車輪の再発明は不要です!
もうひとつ興味深いのは、fetch が非同期であるため、すべてのリクエストが完了する前でも、最初のリクエストのコンテンツをコンポーネントに追加できることです。2番目のリクエストが完了したら、新しく取得した結果を含めるようにコンポーネントを再度更新するだけです。
最後に、架空の listComponent に「次のページ」リンクを表示するかどうかを認識させます。追加のコンテンツがすでにある場合 または サーバーに追加のページがある場合にのみ、リンクを表示すべきです。
前者のケースは、最初のリクエストで4件しか受け取れず、2番目で5件受け取るものの next リンクが ない 場合に発生します。その場合、合計9件ありますが、 listComponent は最初の5件しか表示しません。そのため、コンポーネントには「次のページ」リンクを表示したい一方、コンポーネントが実際にそれ以上リクエストを送信することは 望みません。それを示すために、 nextPageLink を null に設定します。
後者のケース、つまり next リンクが ある 場合は、その次のページリンクをコンポーネントに渡し、後続のリクエストに使用させます。ユーザーが「次のページ」リンクをクリックしないのに、そのリクエストを送信したくはありませんよね?
この数段落で、非常に重要な概念が示されています... HTML内の「次のページ」リンクはAPIのページと対応させる必要はありません! 実際、対応させている場合、それはやり方が「間違っている」可能性を示しています。
なぜ ... ?
…ページ制限を50より大きく設定できないのですか?
まず、上記の例を読んでください。JSON:API は レスポンス内のすべてのエンティティに対して個別のアクセスチェックを実行しなければならない ことを理解してください。次に、JSON:APIモジュールが「ゼロ設定」を目指していることを理解してください。モジュールを使用するために何かをインストールしたり、変更したり、設定したりする必要はありません。
その理由は、アプリケーションをDDoS攻撃から保護するためです。悪意のあるAPIクライアントがページ制限を20万リソースに設定した場合、JSON:APIモジュールはそのすべてのエンティティに対してエンティティのアクセスチェックを実行する必要があります。これはすぐにメモリ不足エラーとレスポンスの遅延を引き起こします。サーバーは最大値を設定する 必要が あります。50という制限は、きりの良い数字としてやや恣意的に選ばれました。
この決定については多くの長い議論が行われてきたことをご理解ください。サポート負担、適切なデフォルト値、フロントエンドのパフォーマンスの間で妥協点を見つける必要がありました。JSON:APIモジュールのメンテナーも、これがすべてのユースケースに理想的ではないかもしれないことは理解していますが、クライアントがこれらのドキュメントの推奨事項に従っていれば、ほとんど影響はないと確信しています :)
それでもより高い制限が必要な場合は、 JSON:API Page Limit モジュール を使用できます。
…レスポンスにはX個のリソースがないのですか?
JSON:APIモジュールではページの 制限 を指定できますが、これはレスポンスに一定数のリソースが含まれることを保証するものだと誤解されることがよくあります。例えば、レスポンスを「満たす」のに十分なリソースが利用可能だとわかっていても、レスポンスには予想したほど多くのリソースが含まれない場合があります。
上記で説明したのと同じ理由から、JSON:APIは page[limit] クエリパラメータで指定された数のアイテムに対してのみデータベースクエリを実行します。それは単なる最大値です。クエリ結果の一部のリソースへのアクセスが許可されていない場合、それらのリソースはレスポンスから除外されます。その場合、予想したよりも少ないリソースになります。
これは、未公開の可能性があるエンティティ(ノードなど)をリクエストし、それらのエンティティが filter クエリパラメータで事前に除外されていない場合に、よく発生します。
記事:Drupal ドキュメンテーションより。