Universal Data Fetch ライブラリ、 Specter の紹介

この記事は Recruit Engineers Advent Calendar 2019 の 16日目の記事です。

adventar.org

最近僕が作っている OSS である Specter の話をします。

github.com

Specter とは

Client から Backend と BFF から Backend への Universal なデータフェッチを提供してくれるためのツールです。以下の特徴を持ちます。

  • 軽量
  • TypeScript Freindly な型付け機能
  • 投機的先読み

2年ほど前に報告されたCPUの投機的実行に基づく脆弱性である、 Spectre から来ています。と言っても、脆弱性の名前ではありません。投機的先読みを機能として持っているため、この名前をつけています(不吉な名前ではありますが・・・)。

まずは、 GraphQL でも grpc-web でもなく、なぜこいつが産まれたのか、その背景から説明します。

背景

リクルートでも GraphQL を使うケースは徐々に見え始めていますが、まだまだ少数派です。よくあるケースは REST-ish な API サーバがすでに Backend に存在し、それを基に BFF レイヤから API Aggregation をするようなケースです。

f:id:yosuke_furukawa:20191216002138p:plain
BFF が Backend API を Aggregation するケース

Specter 以前はこの部分に Fetchr と呼ばれる OSS を使っていました。しかしながら、 Fetchr は TypeScript などの昨今のフロントエンドのトレンドにマッチしておらず、 非同期呼び出しも callback API を中心として Promise 化されたラッパーが存在する程度でした(そこに以前は 不具合があり、一撃 DoS を直したり してました)。

次の候補として、 Client と BFF 間の通信で、 GraphQL を使うという話もありましたが、最近は AMP で amp-list を使うなど、 Response の戻り値の型が決まっているようなケースも有り、諸々のユースケースに対応するため、独自で開発するに至ったという経緯です。今回は特徴を解説しつつ、なぜこのような設計なのかの Why について解説します。 最後に How についても少しだけ解説します。

軽量

Specter 自身のファイルサイズは BundlePhobia によると、 minify, gzip 付きで 2.2kB 程度です。

bundlephobia.com

ちなみに他のよくある data fetch ライブラリと比較すると以下のとおりです。他のライブラリとは機能が違うので単純比較は難しいですが、同等の機能を提供している Fetchr よりも10kB以上削減されています

f:id:yosuke_furukawa:20191216010313p:plain
各種 bundle size まとめ lower is better

Specter は内部的に fetch を使っています。これまでの data fetch ライブラリは基本的に XHR をラップする形で Promise を提供しています。IE11等、 fetch をサポートしていないブラウザ用にはこのアプローチが必要なものの、最近では fetch をサポートしていないブラウザのが珍しいので、こうしています。

一方で、 IE11 用に unfetch と呼ばれる軽量な polyfill を用意しています。これにより、 IE11 のような fetch 未サポートの環境でも Specter は動作します。

TypeScript friendly

Specter は TypeScript で型をつけられるようになっています。一方で、 TypeScript じゃなくても普通の JavaScript でも呼べるようにAPIは作られています。 TypeScript にどっぷり浸かるような TypeScript specific なライブラリではなく、あくまで TypeScript friendly としているのは、そのためです。

// TypeScript で使えば普通に型をはめて使える(ただし、TypeScript specific ではないので、あくまで型を自称しているのみ。GraphQLなどのアプローチとは異なる)
const request = new Request<RequestHeader, RequestQuery, RequestBody>("counter", {
    headers: {},
    query: {},
    body: {
      count: (count + 1),
    }
  });
const data = await client.update<CounterResponse>(request);
// Generics をやめてしまえば、 JS から普通に使える
const request = new Request("counter", {
    headers: {},
    query: {},
    body: {
      count: (count + 1),
    }
  });
const data = await client.update(request);

既存のシステムがまだまだ JavaScript で動いているものも多い状況で派手に migration をするのではなく、段階的に移行できるようにするためにこうしています。特に Fetchr で動いているものを徐々に TypeScript にしているような状況では、 Specter のように段階的な移行ができると移行の計画が立てやすいという考えでこうしています。

投機的先読み (Prefetching)

Prefetch の方法はいくつかあります。Prefetch もパフォーマンスチューニング分野ではよく研究されているものの1つです。いくつか Prefetch の方法を紹介します。

Prefetch で、一般的なのは dev.to でも実装されている、マウスオーバーしたら prefetch するという方法でしょうか。この方法では ユーザーが押そうとしたリンク を先読みするという効果があります。ただし、この方法はマウスを使わない端末、つまりスマートフォンタブレットでは使えません。

また、 Next.js や Nuxt.js には Link 先を 投機的に prefetch してくれる機能が存在します。この機能は Prefetching Pages と呼ばれており、静的なリンクでは viewport 内に Link が入ったら先読みします。 ただし、この方法は若干富豪的です。 さらにあまりにもリンクが多い場合は通信容量も増えてしまいます(所謂ギガが減る

https://nextjs.org/docs#prefetching-pages

最近はもう少しスマートな方法として、 Predictive Prefetching と呼ばれる方法が登場しています。これは Google Analytics などで計測している統計データに基づいて Prefetch する方法です。 GA の情報から次に行く可能性の高いリンクを予め Prefetch する方法です。

web.dev

Specter はまだ GA と組み込んで実装する機能は未実装ですが、ヒューリスティックに先読みする機能 (経験則に基づいて先読みする機能) 自体は存在します。例えば、検索実行時に、経験則に基づいて、検索結果の上位5件を予め Prefetch するなどのアプローチをすることが可能です。

// HackerNews の top story 一覧を取得する
const res = await fetch("https://hacker-news.firebaseio.com/v0/topstories.json", {
  method: "GET",
  headers: {
    "Content-Type": "application/json; charset=utf-8"
  }
});
const data: Array<number> = await res.json();

// 上位10件に関しては先読みするためのヒントとなる "次のリクエスト候補" に入れることができる
// もしもギガが気になるなら取らなくても可能
const nextReqs = data.slice(0, 10).map((id) => new ClientRequest<{},{ id: number },null>("hnitem", { 
  headers: {}, 
  query: { id: id }, 
  body: null,
}));
const resp = new Response(
  {},
  data,
);
resp.setNextReqs(...nextReqs);
return resp;

先読みする時は下記の要領で実行します。

const data = await client.read<HackerNewsListResponse>(request);
setTimeout(() => {
  // ここで先読みする、先読みしたデータは cache として残るため、次に取りに行く時には cache から fetch できる
  const reqs = data.getNextReqs();
  reqs?.forEach((req) => client.read(req));
}, 100);

実際HackerNews APIを基に先読みするケースと、先読みしないケースの両方を検証した所、先読みを行わないときに比べて時間が500msec以上減っています(先読み結果をcacheしているため)。

先読みしないとき: avg. 566ms
先読みしたとき: avg. 15ms

また、 Universal なライブラリとして作っているため、 Prefetch のタイミングが柔軟に変更できることが強みの1つです。SPAなどのリッチなアプリケーション の場合は Service Worker で行えば main thread を阻害しないで 先読みが可能です。また、そこまでしなくても requestIdleCallback などのブラウザが busy じゃないタイミングで Prefetch させることも可能です。逆に AMP 等、 クライアントで柔軟に Prefetch できない場合はサーバサイド (Node.js) で Prefetch した結果を Cache に入れておく事も可能です。

詳しくは HackersNews の example を確認してください。

github.com

使い方など

Next.js や React, Redux で利用する時の方法については詳しくは GitHub の examples を参考にしてください。

Next.js で使うとき: specter/examples/nextjs at master · recruit-tech/specter · GitHub React, Redux で使うとき: specter/examples/counter at master · recruit-tech/specter · GitHub

その他、何かあれば GitHub で気軽に issue 報告をお願いします。日本語でも問題ありません。

github.com

展望

まずはリクルート社内で実践をしながら、より細かなニーズに対応していこうと思っています。 @agreed からクライアントを自動生成する、などのニーズはたくさんあります。さらに、弊社では Google Analytics だけではなく、 Adobe Analytics や独自分析基盤などがあるため、それらともインテグレートしていく必要があります。

まだまだ実験的なデータフェッチライブラリなので、事業ニーズと一緒に考える必要は数多くありますが、ある程度まで動くものができた段階で徐々に広げていく予定です。

まとめ

Specter について解説しました。 Specter 自身は軽量で、 TypeScript friendly で、 投機的先読みが可能なデータフェッチライブラリです。それぞれの機能についての背景と利用した際にどれだけの利点があるかについても加えて解説しました。

実験的なアプローチですが、リクルート社内にはアプリケーションが多数あるので、それぞれのユースケースに対応していこうと思っています。今後は以下のようなロードマップを描いています。

  • example や 事例を増やす
  • ドキュメント拡充
  • Predictive Prefetch 実装
  • Analytics 基盤との連携

よかったら使ってみてください :) Feedback をお待ちしています。