クエリ型のサーバAPIとHTTP2 Server-Pushの組合せについての考察


はじめに

http2 Advent Calendar 2016 の15日目の記事です。私情で時間がとれなくなってしまい、10日の遅刻です・・・。ご迷惑おかけしました。

本記事では、GraphQLに代表されるようなクエリ型のサーバAPIと、Server Pushを組み合わせた使い道について考察しています。

HTTP/2のServer Pushの使い道としては、静的ファイルに関しての例が多く挙げられ、アプリケーションサーバからの利用についてはあまり語られていないように感じています(知らないだけだったらすみません)。こんな使い方もできるのではないか? という提案になっていれば嬉しいです。

本編

クエリ型のサーバAPIについて

GraphQLに代表されるクエリ型のサーバAPIとその意義について、簡単に説明されているものとして、以下の記事が参考になります。

RESTの次のパラダイムはGraphQLか - Qiita

本記事では、次のように定義しておきます。

クエリ型のサーバAPIでは、 1リクエストで 以下のようなことができるのが特徴です。

  • 複数のリソースを取得できる。
  • あるリソースから、それに関連したリソースを取得できる。

GraphQLの例

上のことを実現しているものの一つがGraphQLです。

クエリの例を書くとこんな感じです。

{
  user1: user(id:10) { # Userリソースを取得
    id
    name
    parent_id
    parent { # 関連するUserリソースをまとめて取得
      id
      name
    }
  }
  user2: user(id:20) { # 別のUserリソースを取得
    id
    name
  }
}

帰ってくるレスポンスはこんな感じです。

{
  "data": {
    "user1": {
      "id": 10,
      "name": "tadpole",
      "parent_id": 1,
      "parent": {
        "id": 1,
        "name": "frog"
      } 
    },
    "user2": {
      "id": 20,
      "name": "qsona"
    }
  }
}

※ なお、 例を考えるのが面倒簡単のため、今回は全てUserリソースを登場させていますが、以下の話を考える上で、これらが同種のリソースである必要はありません。

クエリ型リクエストの、メリットとデメリット

上のことは、複数リクエストを許容できるのであれば、Userをidで引けるAPIが1つあれば事足ります。すなわち、RESTで言えば以下のようにすれば良いです。

  • 以下を並列で取得する
    • users/10
    • users/20
  • users/10 のparent_idを見ると1なので、 users/1 を取得する

それに対して、クエリ言語を利用すると、 1リクエストにまとめる ことができるのが特徴でした。

そのことのメリットとデメリットを考えます。

メリット

まずHTTP1時代であれば、これを1リクエストにまとめられることは、単純にコネクションが減るというパフォーマンス上のメリットがあります。一方でHTTP2であればKeep-Aliveがあるので、この利点は考慮する必要がありません。

その上でもまだ残るメリットがあります。1リクエストにまとまっていることで、 サーバサイドで処理を最適化できる余地がある ことです。

あるリソースに関連するリソースを取る際は、JOINを使って1つのSQLで取ることができる可能性があります。

また、別々のリソースについても、今回user10とuser20は同じテーブルなので、やはり1つのSQLで取れる、のような判断をすることが可能です。(※こっちはGraphQLの一般的なサーバサイド実装でそれができるかは微妙ですがそれはおいておく。)

デメリット

デメリットは、 1リクエストに縛られる ということです。

クライアント都合で、1つの画面で欲しいデータは複数あるものの同期的に表示する必要はなく、むしろ準備できたデータから順に帰ってきて欲しい、という時があります。

たとえばuser10はキャッシュヒットしたので速攻返せるが、user20は時間がかかる、のような場合に、user10だけでもいいから早くくれ、という要求です。

クエリでの取得とServer Pushを組み合わせる

上記のデメリットは、

「1リクエストで送って、必要ならバラバラにリソースを返す」

という機構があれば、解消することができます。

これには、Server Pushを利用できそうです。

すなわち、

  1. サーバはリクエストを受け取ったら初めに、リソースを返す単位を分割する。
  2. まずPUSH_PROMISEフレームを返し、上記の分割した単位でのリソースのpushを予約する。
  3. 後からリソースを1つずつpushしていく。

という方法で実現できそうです。

実装方針?

ここまでGraphQLを引き合いに出して書きましたが、GraphQLはレスポンスについての仕様が定まっており、少なくとも現在のGraphQL上では、上に書いたようなことはできません。(GraphQLの資産を使って、仕様から外れた実装をすることは可能ですが)

もし上のようなことをやりたければ、pushまで含めた枠組み・レスポンス形式の仕様を作り、その上でサーバ/クライアントそれぞれで、この仕様を高レベルで扱えるAPIを作る必要があります。

仕様

PUSH_PROMISEでの予約と、その後pushするデータの形式を、クライアントが正しく解釈できる形で決める必要がありますね。

先の例では、 user1, user1.parent, user2 の3つのリソースがあり、これをバラバラで返すとか、全部まとめて返すとか、user1とuser1.parentはくっつけて返すけどuser2は別で返す、とかいろいろ考えられるので、それに対応する形にする必要があります。

サーバの実装

クエリをパースして、実際にSQLなどを実行する前の段階では、ある程度なにをまとめて取れるのかがわかるはずなので、その段階でpush_promiseフレームを返すことになりそうです。

ただ、キャッシュヒットしたら早く返すのようなことを動的にやるのは結構難しそう。

クライアントの実装

クライアントのSDKをどのように作れるかを考えてみます。

pushされてくるリソースごとのデータを、順にmergeしていくようなイメージです。各リソースが取得できるのに伴い、適切にイベントが発火される仕組みにすれば、クライアント側はそれに応じて描画などをすることができそうです。

ここでは、あるリソースの1段階目が取得されるイベントを received('a'), 関連まで含めてリソースが取得されるイベントを fulfilled('a') と表します。

再掲すると、1レスポンスとして最終的に得られる形は以下です。

{
  "data": {
    "user1": {
      "id": 10,
      "name": "tadpole",
      "parent_id": 1,
      "parent": {
        "id": 1,
        "name": "frog"
      } 
    },
    "user2": {
      "id": 20,
      "name": "qsona"
    }
  }
}

上記の中では、 data user1 user1.parent user2 の4つリソースがあると考え、それぞれ received, fulfilled で 4*2=8種類 のイベントが最終的に発火されることになります。

これらのイベントが発火されれば、クライアント側は適切に描画などを進めることができそうです。もし、とりあえず全部待つ実装をしたければ、 fulfilled('data') だけを待ち構えていれば良いことになります。

具体的にどのようなイベントが発火されるかを書いてみます。まず、全部分かれてpushされてくるパターンを考えます。以下の3つが分割され、この順で届いたとします。

  • user1
  • user2
  • user1.parent

これらがpushされるのに伴い、受け取ったjsonの状態と、その時発火されるべきイベントを示します。

1. user1

{
  "data": {
    "user1": {
      "id": 10,
      "name": "tadpole",
      "parent_id": 1
    }
  }
}

発火イベント:

  • received('user1')

2. user2

{
  "data": {
    "user1": {
      "id": 10,
      "name": "tadpole",
      "parent_id": 1
    },
    "user2": {
      "id": 20,
      "name": "qsona"
    }
  }
}

発火イベント:

  • received('user2')
  • fulfilled('user2')
  • received('data')

3. user1.parent

{
  "data": {
    "user1": {
      "id": 10,
      "name": "tadpole",
      "parent_id": 1,
      "parent": {
        "id": 1,
        "name": "frog"
      } 
    },
    "user2": {
      "id": 20,
      "name": "qsona"
    }
  }
}

発火イベント:

  • received('user1.parent')
  • fulfilled('user1.parent')
  • fulfilled('user1')
  • fulfilled('data')

このように、受け取るごとに完了したもののイベントを発火していけば、それに応じた処理を行うことができそうです。

問題点

関連が深くなった場合など、サーバがクエリをパースした段階では判断できず、再帰的にpush_promiseを発行したくなると思うのですが、それは簡単にはいかなそうな感じです (参考: HTTP/2で再帰的にPUSH_PROMISEする場合の注意点 - ジンジャー研究室 )

おわりに

HTTP2 Server Pushの使い道として、クエリ型サーバAPIでリソースごとに分割してpushする、という案について考察してみました。

自分ではこの記事に近いような議論を目にしたことがないのですが、もしそういったあれば日本語/英語問わず読みたいので、コメントなどで紹介いただければ幸いです。