きみはねこみたいなにゃんにゃんなまほう

ねこもスクリプトをかくなり

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 の動作を以下のような単純なアプリを作って試していきたいと思います。

f:id:lightbulbcat:20190322010347p:plain

動作の詳しい仕組みは理解していませんが、とりあえず使ってみるところから始めます。

この記事で説明しているコードの、実際に動作するプロジェクトは以下の 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)
  }
}

データ型としては UserThing への参照を 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)
  }
}

UserThing のそれぞれに 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>
    )
  }
}

UserThing それぞれに Summary コンポーネントを定義して、それぞれのコンポーネントは必要なデータを自身がマウントされた際にリクエストを飛ばして取得する、という設計です。こういう処理が簡単に書けるので React Hook 便利ですね。

それぞれの Summary コンポーネントには batch オプションをプロパティとして定義しています。有効化された場合には素の api の代わりに dataloader を仕込んだ batchApi が利用されます。batch が指定された UserSummary は自身のリクエストの他に子コンポーネントThingSummarybatch オプションも有効化するようにしています。

全体としては、id12 のユーザ対しては 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の回数としては以下のようになりますね。

  • getUserById: 3 リクエス
  • getThingById: 9 リクエス

実際にアプリを実行してみると冒頭に貼った画像のように、以下のように描画されます。 「batched!」と描画されている箇所が batch オプションを指定した箇所です。 問題なくデータは指定した id の通りに取得されているようですね。

コンソールに出力されているのは dataloader をかませていない、素のAPIクライアントの各メソッドが呼ばれたログです。

f:id:lightbulbcat:20190322010347p:plain

コンソールの行数だけ見ても、明らかにリクエスト数は減っていますね。実際、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:まとめられる単位はソースを軽く眺めたところ実行コンテキスト単位(?)な雰囲気を感じましたが、詳しくはまだ理解できていません