Next.jsでDataLoaderを使ってコンポーネントの責務を明確にする
はじめに
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
コンポーネントでは,何回のデータフェッチが発生するでしょうか?
getBooks
とgetAuthor
が呼ばれる回数を数えてみます.
-
getBooks
-
BookList
コンポーネントで1回.
-
-
getAuthor
- 紐づいてる本がN冊のとき,N個の
BookItem
コンポーネントでそれぞれ取得されるため,N回.
- 紐づいてる本がN冊のとき,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の例を引用して説明します.
例では,user
をnumber
型の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回
- 本がN冊あるとき,
本の冊数にかかわらず,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);
キャッシュしたい関数fn
をcache
に渡すと,キャッシュ化された関数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を使って,BookList
とBookItem
コンポーネントを実装するとこうなります.
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を利用することで,かなり理想に近い形のコンポーネント設計・データフェッチができているのではないかと思います.
Discussion