🪚

Next.jsでDataLoaderを使ってコンポーネントの責務を明確にする

2024/05/20に公開

はじめに

Next.jsや他Webフレームワークでは,データフェッチの設計を適切に行わないとN+1問題が起きてしまいます.
このN+1問題を解決するために,GraphQLのバックエンド実装でよく利用されているDataLoaderライブラリを使用するアイデアを紹介します.

準備

この章では,今回解決するフロントエンドにおけるN+1問題と,利用するライブラリであるDataLoaderについて紹介します

コンポーネントの責務とN+1問題

N+1問題って?

N+1問題とは,あるデータがN個の子データを持つような場合に,不適切な設計を行ってしまうと,その名の通りN+1回のデータ取得が行われてしまうことを指します.

コンポーネント設計の誤りによるN+1問題の例

ある著者について,複数の本が紐づいている状況を考えます.
データベースのテーブルとして表します.

id name
1 村上春樹
2 東野圭吾
author_id id name
1 1 1Q84
1 2 海辺のカフカ
1 3 ノルウェイの森
2 4 容疑者Xの献身

責務を分離したコンポーネント設計

本の一覧に著者名を表示したいとき,どのようなコンポーネント設計を考えるでしょうか?

以下のような設計が考えられます.

export default async function BookList(){
  // バックエンドサーバー等から本のIDの配列を取得する
  const books = await getBooks()

  return (
    <div>
      <ul>
        {books.map(book => (
          <BookItem key={book.id} book={book} />
        ))}
      </ul>
    </div>
  )
}

async function BookItem({ book }) {
  // 著者の情報を取得する
  const author = await getAuthor({ authorId: book.authorId })
  
  return (
    <li>{book.name}: {author.name}</li>
  )
}
  

このようなコードは,以下のように責務が分離されています.

  • BookListコンポーネント
    • 本の一覧を取得する
    • それぞれのBookItemがどんな情報を表示するかを知らなくても良い
  • BookItemコンポーネント
    • 本の詳細を表示する
    • 著者の情報も表示したいので,著者の情報を取得する
    • 自分で著者の情報を取得するので,親コンポーネントからはauthorを受け取らなくて良い

これによって,例えばBookItemコンポーネントに出版社の情報を表示したくなったときも,BookItemコンポーネントを修正するだけで済み,BookListコンポーネントはそのまま使うことができます.
また,BookItemで著者の情報が必要なくなったときも,同じくBookListコンポーネントに修正は必要ありません.

引き起こされるN+1問題

このBookListコンポーネントでは,何回のデータフェッチが発生するでしょうか?
getBooksgetAuthorが呼ばれる回数を数えてみます.

  • getBooks
    • BookListコンポーネントで1回.
  • getAuthor
    • 紐づいてる本がN冊のとき,N個のBookItemコンポーネントでそれぞれ取得されるため,N回.

合計N+1回のデータフェッチが発生してしまいました.
例えば本が100件(N=100)だった場合,バックエンドサーバーやデータベースへのアクセスが101回も発生してしまいます.これはパフォーマンス上の問題を引き起こすには十分な数字です.

N+1問題を引き起こさないコンポーネント設計

N+1問題を回避するために,以下のようにコンポーネントを変更します.

export default async function BookList(){
  // バックエンドサーバー等から本の詳細と著者の情報を含む配列を一度に取得する
  const booksWithAuthor = await getBooksWithAuthor()

  return (
    <div>
      <h1>{name}</h1>
      <ul>
        {books.map(book => (
          <BookItem key={book.id} book={book} author={book.author} />
        ))}
      </ul>
    </div>
  )
}

function BookItem({ book, author }) {
  // 本の詳細情報を表示する
  return (
    <li>{book.name}: {author.name}</li>
  )
}

これにより,getBooksWithAuthor関数を1回呼び出すだけで,本のリストをレンダリングできるようになりました.
ただし,このコンポーネントでは,著者の情報について,データフェッチと表示に関する責務が分離されていません.

例えば,本に出版社の情報を足すときには,以下の部分の改修が必要になります.

  • BookListコンポーネントの情報取得部分
  • BookItemコンポーネントの表示部分

このように,コンポーネントの責務を分割するとN+1問題が引き起こされ,回避するためにはデータフェッチを親のコンポーネントで行う(=データフェッチの責務が分散する)必要があることがわかります.

DataLoaderの紹介

GraphQLのバックエンド実装以外では馴染みのないライブラリだと思うので,DataLoaderについて軽く説明をします.

DataLoaderは,一言で言うと,複数の類似したデータフェッチを1つにまとめるライブラリです.(他にも機能はあるのですが,今回はDataLoaderのBatchingに注目します)
動作としては,あるリソースに対するアクセスがあったときにそれを貯めておき,一定期間後に全てのアクセスをまとめて行います.
Githubに載っているReadmeの例を引用して説明します.

例では,usernumber型のIDで取得します.
まず,userを取得するためのDataLoaderのインスタンスを取得します

const DataLoader = require('dataloader');

const userLoader = new DataLoader(keys => myBatchGetUsers(keys));

このmyBatchGetUsers関数は,以下のような型を持っています.

type MyBatchGetUsers = (keys: readonly number[]) => Promise<(User | Error)[]>

myBatchGetUsers関数は,配列でIDを受け取って,受け取ったIDと同じ順番で,userの配列を返します.実装は,APIアクセスでも,データベースアクセスでも構いません.

このuserLoaderインスタンスを介して,データフェッチを行えます

const user1 = await userLoader.load(1)

また,今回の話の肝になる点として,データフェッチを複数同時に行うことで,複数のデータフェッチを1つにまとめることができます.

const userIds = [1, 2, 3]

const users = await Promise.all(
  userIds.map(async (userId) => {
    return await userLoader.load(userId)
  })
)

users // [user1, user2, user3]

このコードの動作を図解すると,以下のようになります.

少々不思議ですが,DataLoaderが内部的にloadメソッドを待ち合わせ,複数のloadをバッチ処理してくれます.
これにより,本来3回必要であったデータフェッチが,1回のバッチ処理で行えます.
内部的な動作は複雑なので,興味のある方は調べてみてください.

フロントエンドのN+1問題をDataLoaderで解決する.

DataLoaderはGraphQLでよく使われるライブラリですが,DataLoaderのGithubのReadmeに

DataLoader is a generic utility to be used as part of your application's data fetching layer to provide a simplified and consistent API over various remote data sources such as databases or web services via batching and caching.

とあるように,汎用的なデータフェッチライブラリとして用いることができます.
このDataLoaderをフロントエンドで用いることで,コンポーネントの責務を分離しつつ,N+1問題を解決します.

先ほどN+1問題を引き起こす例として示したBookListコンポーネントとBookItemコンポーネントをDataLoaderを用いて改良します.


export default async function BookList(){
  // バックエンドサーバー等から本のIDの配列を取得する
  const books = await getBooks()

  return (
    <div>
      <ul>
        {books.map(book => (
          <BookItem key={book.id} book={book} />
        ))}
      </ul>
    </div>
  )
}


// authorを取得するためのDataLoaderインスタンスを用意する
const authorLoader = new DataLoader(keys => myBatchGetAuthors(keys));

async function BookItem({ book }) {
  // 著者の情報を取得する
  const author = await authorLoader.load(book.authorId)
  
  return (
    <li>{book.name}: {author.name}</li>
  )
}
  

このコンポーネントでのデータフェッチの回数を考えます.

  • BookListコンポーネント
    • getBooks関数で1回
  • BookItemコンポーネント
    • 本がN冊あるとき,authorLoader.loadはN回呼ばれる
    • DataLoaderがBatchingしてくれて,データフェッチとしてはmyBatchGetAuthorsが1回

本の冊数にかかわらず,2回のデータフェッチで済んでいる事がわかります.
これはgetBooksWithAuthorを使って本と著者の情報を同時に取得する場合に比べて,1回の増加になります.
データフェッチ回数は増加してしまいますが,コンポーネントの責務は分離され,変更に強いコンポーネント設計にすることができました.

Next.jsでの実践

この章では,実際にNext.jsでDataLoaderを用いてデータフェッチを行う方法を紹介します.

DataLoaderインスタンスに関する制約と注意点

先ほど示したBookListコンポーネントとBookItemコンポーネントは擬似コードであり,そのままNext.jsで利用することはできません.
実際に利用するには気をつけないといけない点があります.

同じインスタンスを使う

DataLoaderでは,そのインスタンスがloadメソッドの待ち合わせとバッチ処理を担っています.
つまり,以下のような状況では,バッチ処理が行われず,DataLoaderの意味が無くなってしまいます.

const loader1 = new DataLoader(keys => myBatchFn(keys))
const loader2 = new DataLoader(keys => myBatchFn(keys))

const data = await Promise.all([
  loader1.load(1),
  loader2.load(2),
])

キャッシュ機能

DataLoaderにはキャッシュの機能もあり,一度loadされたデータはインスタンスにキャッシュされ,次回のloadではキャッシュされたデータが返却されます.

const loader = new DataLoader(keys => myBatchFn(keys))

const data = await loader.load(1) // ここでmyBatchFnが呼び出され,loaderにdataがキャッシュされる

const reloadedData = await loader.load(1) // さっきキャッシュされたデータが返却される

意図しないキャッシュは,バグの原因になるので注意する必要があります.
異なるユーザーで同じインスタンスを利用してしまうと,キャッシュによりアクセス制限をバイパスしてしまい,情報漏洩が起こる可能性があります.

以上2点から,適切にバッチ処理を行うためには,以下の点に注意する必要があります.

  • バッチ処理したい箇所で同じインスタンスを使う
    • loadする直前にnew DataLoaderするような使い方は❌️
  • キャッシュが残りすぎないように,インスタンスを適切に破棄する
    • 特に異なるユーザー間でインスタンスを共有しないように注意
    • 長期間インスタンスが生き残ってしまうと,意図しないデータがloadされる可能性がある

具体的な実装

DataLoaderインスタンスの取得

先程述べたDataLoaderインスタンスの制約と注意点から,具体的に以下の要件が定まります.

  • インスタンスはリクエストごとに生成・破棄される
    • 同じリクエストでは同じインスタンスを用いる
    • リクエストが終了したら,インスタンスを破棄する
      • 同じリクエストに異なるユーザーが関わることはないので,キャッシュによる権限の問題は発生しない

この要件を満たすインスタンスの置き場所として,React Cacheがあります.
React CacheはReact Server Componentで利用できるキャッシュ機構です.
利用方法の例を示します.

const cachedFn = cache(fn);

キャッシュしたい関数fncacheに渡すと,キャッシュ化された関数cachedFnを得られます.
fnはドキュメントに示されている通り,どのような引数や返り値を持つ関数でも構いません.

fn: The function you want to cache results for. fn can take any arguments and return any value.

このcachedFnは,初回呼び出し時にはfnを呼び出し,同じ引数で2回目以降呼び出されたときは,キャッシュされた結果を呼び出します.

const result = cachedFn(123); // fn(123)が呼び出され,その結果が返却される

const reloaded = cachedFn(123); // fnは呼び出されず,保存された結果が返却される

React Cacheはリクエストごとにキャッシュが破棄されます.

React will invalidate the cache for all memoized functions for each server request.

よって,以下のようなgetDataLoader関数を用意することで,適切にDataLoaderインスタンスを管理できます.

export const getDataLoader = cache((batchLoadFn) => new DataLoader(batchLoadFn))

このgetDataLoaderは,バッチ関数を受け取って,DataLoaderインスタンスを返却する関数になっています.
React Cacheを利用することでインスタンスは適切に生成・利用・破棄されます.

DataLoaderを使って,BookListBookItemコンポーネントを実装するとこうなります.

export default async function BookList(){
  // バックエンドサーバー等から本のIDの配列を取得する
  const books = await getBooks()

  return (
    <div>
      <ul>
        {books.map(book => (
          <BookItem key={book.id} book={book} />
        ))}
      </ul>
    </div>
  )
}


// authorを取得するためのバッチ関数
function myBatchGetAuthors(keys){
  // keysを使って,authorの配列を返却する
}

async function BookItem({ book }) {
  const authorLoader = getDataLoader(myBatchGetAuthors)
  // 著者の情報を取得する
  const author = await authorLoader.load(book.authorId)
  
  return (
    <li>{book.name}: {author.name}</li>
  )
}

DataLoaderを使って,コンポーネントの責務を分離しつつ,N+1問題を回避できました.

まとめ

GraphQLで知られるDataLoaderライブラリを調べていたときに,DataLoaderは汎用のデータフェッチライブラリである,という記述を目にしました.
確かにGraphQL以外にも使えそうだと思い,Next.jsでも使えそうなので試してみました.
DataLoaderを利用することで,かなり理想に近い形のコンポーネント設計・データフェッチができているのではないかと思います.

PrAha

Discussion