dataloader を使ってリクエストを取りまとめる React デモを作る
以前 GraphQL を利用していたときに、サーバサイドのSQLリクエストを減らすために使おうとしていたパッケージに dataloader というものがあります。
現在は GraphQL ではなく gRPC-Web を利用したアプリを書いているのですが、dataloader
は別に GraphQL に限ったものではなく、id 単体を指定するような複数のリクエストを、複数の id
を指定する単一のリクエストにまとめて実行するということをやってくれます*1。つまり dataloader
は
get****ById(id1)
x n → get****ByIds(id1, id2, ...)
という処理を行ってくれる中間層として機能してくれるものになります。
デフォルトではレスポンスに含まれる id
を利用して、リクエストの取りまとめや重複するキーへのリクエストの dedupe 処理を行ってくれます。
GraphQL のサーバサイドではスキーマの patching を行っていたのですが、GraphQL はレスポンスに id
を含めるかどうかが叩く側次第なためこの辺りの処理ががうまくいかずに頓挫しました。
話を戻して、今回は GraphQL ではなく、一般化したリモートリクエストをWebから行うようなAPIクライアントを対象にして、dataloader
の動作を以下のような単純なアプリを作って試していきたいと思います。
動作の詳しい仕組みは理解していませんが、とりあえず使ってみるところから始めます。
この記事で説明しているコードの、実際に動作するプロジェクトは以下の til リポジトリに置いてあります。
下準備
API クライアントのモックを定義する
今回デモで利用するAPIは以下のようなものです。
// api.ts // Data Types // ---------- export type IdType = number export interface User { id: IdType name: string favoriteThingIds: IdType[] } export interface Thing { id: IdType name: string } // Data Mock // --------- const thingList: Thing[] = [ { id: 1, name: "Raindrops" }, { id: 2, name: "Kittens" }, { id: 3, name: "Kettles" }, { id: 4, name: "Mittens" }, { id: 5, name: "Packages" } ] const userList: User[] = [ { id: 1, name: "Alice", favoriteThingIds: [1, 2, 3, 4, 5] }, { id: 2, name: "Bob", favoriteThingIds: [2, 4] }, { id: 3, name: "Carol", favoriteThingIds: [1, 3] } ] // API Mock // -------- const emulateNetworkDelay = <V>(value: V): Promise<V> => { return new Promise(resolve => setTimeout(() => resolve(value), 1000)) } export default { getUserById(id: IdType) { console.log(`API called: getUserById(${id})`) const user = userList.find(u => u.id === id) return emulateNetworkDelay(user) }, getUsersByIds(ids: IdType[]) { console.log(`API called: getUsersByIds(${ids})`) const users = ids.map(id => userList.find(u => u.id === id)) return emulateNetworkDelay(users) }, getThingById(id: IdType) { console.log(`API called: getThingById(${id})`) const thing = thingList.find(t => t.id === id) return emulateNetworkDelay(thing) }, getThingsByIds(ids: IdType[]) { console.log(`API called: getThingsByIds(${ids})`) const things = ids.map(id => thingList.find(t => t.id === id)) return emulateNetworkDelay(things) } }
データ型としては User
が Thing
への参照を id
のみで持っており、Thing
の内容を見たい場合は更にAPIを叩く必要がある、というAPI設計です。
ByIds
のクエリにより id
複数指定でデータを取得することができるという想定です。dataloader
の利用にはこういったAPIが存在することが前提となります。
しかしこの ByIds
系のクエリですが、実際に React アプリを書いた経験から言うと、意外と活かしづらいです。私が React を使う場合、コンポーネントがマウントされたタイミングでそのコンポーネントが必要とするリクエストを行う、という方針でコンポーネントを切り分け・実装することが多いです。なのでリクエストの効率化のために ByIds
でデータ取得の取りまとめを行おうとすると、ちょっと面倒くさく感じる場面が多いですね。
この辺の処理を React のデータフローだけで組もうとすると、実際にデータが必要なコンポーネントのより親階層の方で子コンポーネントに必要なリクエストを取りまとめて行う必要がありそうですが、それをやろうとすると描画コンポーネントと、データを取得するコンテナコンポーネントの間の仕様的な結合が強くなり、使い回しをしづらいコンポーネントになってしまうのではないかと思っています。
という事情もあり ByIds
的なAPI側が用意されているにも関わらず、実際には React での組みやすさによる制約が働いて「複数 id
指定使ってないです、すみません...」となることが今までよくありました。
APIクライアントに対して dataloader を噛ませたラッパーを用意する
さて、前置きが長くなりましたが、上記のAPIクライアントに対して、以下のような dataloader
を定義してAPIクライアントのラッパーを定義します。
// api-with-dataloader.ts import DataLoader from "dataloader" import api, { IdType } from "./api" // DataLoaders // ----------- const userLoader = new DataLoader((keys: IdType[]) => api.getUsersByIds(keys)) const thingLoader = new DataLoader((keys: IdType[]) => api.getThingsByIds(keys)) // Batching API Mock // ----------------- export default { getUserById(id: IdType) { return userLoader.load(id) }, getThingById(id: IdType) { return thingLoader.load(id) } }
User
と Thing
のそれぞれに dataloader
を仕込んだAPIクライアントのラッパーを定義します。ByIds
分の定義も loadMany
を利用すればできそうですが、今回のデモでは利用しないので省略します。
React コンポーネントを定義する
これらのAPIクライアントを利用するコンポーネントを以下のように組みます。
import React, { Component, useEffect, useState } from "react" import logo from "./logo.svg" import "./App.css" import api, { IdType, User, Thing } from "./api" import batchApi from "./api-with-dataloader" // ThingSummary // ------------ interface ThingSummaryProps { id: IdType batch?: boolean } function ThingSummary(props: ThingSummaryProps) { const { id, batch } = props const [thing, setThing] = useState<Thing | undefined>(undefined) useEffect(() => { if (batch) { batchApi.getThingById(id).then(setThing) } else { api.getThingById(id).then(setThing) } }, []) if (!thing) return <div>loading...</div> return ( <div> #{thing.id}: {thing.name} {batch && " (batched!)"} </div> ) } // UserSummary // ----------- interface UserSummaryProps { id: IdType batch?: boolean } function UserSummary(props: UserSummaryProps) { const { id, batch } = props const [user, setUser] = useState<User | undefined>(undefined) useEffect(() => { if (batch) { batchApi.getUserById(id).then(setUser) } else { api.getUserById(id).then(setUser) } }, []) if (!user) return <div>loading...</div> return ( <div> <h3> #{user.id}: {user.name}'s Favorite Things {batch && " (batched!)"} </h3> <ul> {user.favoriteThingIds.map(id => ( <ThingSummary key={id} id={id} batch={batch} /> ))} </ul> </div> ) } // Main Component // -------------- export default class App extends Component { render() { return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <div> <UserSummary id={1} batch /> <UserSummary id={2} batch /> <UserSummary id={3} /> </div> </header> </div> ) } }
User
と Thing
それぞれに Summary
コンポーネントを定義して、それぞれのコンポーネントは必要なデータを自身がマウントされた際にリクエストを飛ばして取得する、という設計です。こういう処理が簡単に書けるので React Hook 便利ですね。
それぞれの Summary
コンポーネントには batch
オプションをプロパティとして定義しています。有効化された場合には素の api
の代わりに dataloader
を仕込んだ batchApi
が利用されます。batch
が指定された UserSummary
は自身のリクエストの他に子コンポーネントの ThingSummary
の batch
オプションも有効化するようにしています。
全体としては、id
が 1
と 2
のユーザ対しては batch
リクエストとして dataloader
を利用したAPIクライアントの方を利用し、3
のユーザに対しては元のAPIクライアントを利用する、という描画内容になっています。
実行する
実行結果をイメージするために、改めてデータの中身と描画部分のコードを載せておきます。
const thingList: Thing[] = [ { id: 1, name: "Raindrops" }, { id: 2, name: "Kittens" }, { id: 3, name: "Kettles" }, { id: 4, name: "Mittens" }, { id: 5, name: "Packages" } ] const userList: User[] = [ { id: 1, name: "Alice", favoriteThingIds: [1, 2, 3, 4, 5] }, { id: 2, name: "Bob", favoriteThingIds: [2, 4] }, { id: 3, name: "Carol", favoriteThingIds: [1, 3] } ]
<UserSummary id={1} batch /> <UserSummary id={2} batch /> <UserSummary id={3} />
もしこれらのデータが全て ById
によって取得された場合、叩かれるAPIの回数としては以下のようになりますね。
実際にアプリを実行してみると冒頭に貼った画像のように、以下のように描画されます。
「batched!」と描画されている箇所が batch
オプションを指定した箇所です。
問題なくデータは指定した id
の通りに取得されているようですね。
コンソールに出力されているのは dataloader
をかませていない、素のAPIクライアントの各メソッドが呼ばれたログです。
コンソールの行数だけ見ても、明らかにリクエスト数は減っていますね。実際、APIクライアントが叩かれた回数としては
getUserById
: 1 リクエスト( id:3
)getUsersByIds
: 1 リクエスト(id:1, 2
)- 直接コンポーネントからは呼んでいませんが
dataloader
経由で呼ばれていますね
- 直接コンポーネントからは呼んでいませんが
getThingById
: 2 リクエスト(id:1, 3
)getThingsByIds
: 1 リクエスト(id:1, 2, 3, 4, 5
)- こちらも同様です
となりました。確かに ById
リクエストが ByIds
リクエストにまとめられていることがわかります。
実際に動作することがつかめたので、使い慣れたあたりで、次は dataloader
の動作原理にも踏み込んでみたいですね。
今回は以上です。
*1:まとめられる単位はソースを軽く眺めたところ実行コンテキスト単位(?)な雰囲気を感じましたが、詳しくはまだ理解できていません