esm アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

GraphQL でファイルをアップロードしたい

こんにちは、hibariya です。さいきん GraphQL でのファイルアップロード方法について知りたいなと思いちょっと検索してみたところ、すぐにはこれといった方法に辿りつけなかったので気になって調べました。手元で試した感じだと GraphQL multipart request specification という仕様が良さそうでしたので、今日はそのことについて紹介したいと思います。

GraphQL でのファイルアップロードはめんどう

現在のところ、GraphQL の仕様ではファイルアップロード方法については特に規定されていないため、自分たちで方法を決めて実装する必要があります。が、これは少し面倒です。HTTP で GraphQL を使う場合、サーバに渡す値はたいてい JSON のようなフォーマットでシリアライズしますが、FileオブジェクトはそのままJSONにはエンコードできません。

{
  "query": "mutation CreateMessage($input: CreateMessageInput!) { ... }",
  "variables": {"input": {"message": "hi", "file": "<アップロードしたい File をここに...??>"}}
}

ですから、GraphQL でファイルをアップロードしようというときには少し工夫が必要になります。方法としてよく挙げられているのは以下の3つでしょうか。

  1. ファイルを Base64 エンコードして JSON に含める
  2. ファイルのアップロードには GraphQL とは別の API を使う
  3. リクエストを multipart/form-data として送る

最初の方法1は、そのままではシリアライズできないファイルを Base64 エンコードすることで JSON として扱えるようにする方法です。クライアントとサーバ間の通信方法を大きく変える必要がなさそうで、シンプルそうに見えます。ただ、巨大なファイルを扱う場合には、あまり考えずにサーバ側のアプリケーションを実装すると巨大な JSON をオンメモリで扱うことになって面倒そうな気がします *1。あとはファイルサイズが若干増えますし、ファイル名のようなメタ情報を自分で渡さないといけないのも少し面倒ですね。

次の2はファイルアップロードを GraphQL API では扱わず、別途用意した REST API などを併用するという方法です。確かに方法のひとつとしては考えられますが、使い方によっては、元々ひとつだったトランザクションを分ける必要が出てくるなど用途を選びそうです。

最後の3は GraphQL のリクエストを multipart/form-data として送る方法です。ここで紹介する GraphQL multipart request specification は、この方法を採用しています。

GraphQL multipart request specification

リクエストに multipart/form-data を使う方法であれば、巨大なファイルは (例えば Ruby なら) Rack のレイヤで少しずつファイルに書き出される *2 ため、サーバサイドのメモリ使用量についての心配はひとまず置いておけます。また、この方法にはいくらか認知されている仕様として、GraphQL multipart request specification というものがあります。

仕様が定めているのは、multipart でどうやって GraphQL リクエストを送るのか、特に GraphQL の変数の値としてとしてどうやってファイルをマッピングするのかというところです。仕様そのものは単純なので、例をまじえて説明します。 README とほぼ同じですが、冒頭の例で説明するとクライアントは以下のような感じの multipart message をサーバに送ることになります。

--------------------------cec8e8123c05ba25
Content-Disposition: form-data; name="operations"

{
  "query": "mutation CreateMessage($input: CreateMessageInput!) { ... }",
  "variables": {"input": {"message": "hi", "file": null}}
}

--------------------------cec8e8123c05ba25
Content-Disposition: form-data; name="map"

{ "0": ["variables.file"] }

--------------------------cec8e8123c05ba25
Content-Disposition: form-data; name="0"; filename="hi.png"
Content-Type: image/png
Content-Transfer-Encoding: base64

(Base64 エンコード済みのファイル)

--------------------------cec8e8123c05ba25--

最初の part を見ると、operations というパラメータの中に GraphQL のクエリや変数が格納されています。ここは相変わらず JSON ですね。ところで肝心の変数 file (variables.input.file) が null となっています。これはtypo ではなくそういうもので、ファイル本体は別の part となっているため、サーバ側で適切にマッピングして、この null を上書きする必要があります。この例では、ファイルは最後の part にある hi.png です。真ん中には map というパラメータの part があります。これは、最初に登場した null と実際のファイルをマッピングするための情報です。この例の { "0": ["variables.file"] } でいうと「0 というパラメータは、variables.file の中身ですよ」といった意味になります。

サーバ側は、この仕様の実装がアプリケーションより下の層にあれば、アプリケーションを大きく変更することなくファイルのアップロードを実現できます。Ruby なら Rack のミドルウェアとして実装して、ファイルを params[:operations][:variables][:input][:file] で参照できるようにする、という具合です。

クライアント側も、変更が必要なのは主にネットワークレイヤになります。実装のひとつに Apollo Link のミドルウェアとしてこの機能を提供しているものがあるので、もし Apollo を使っているならそれを導入することで対応できるでしょう。残念ながら現時点では Relay (Modern) の実装として公開されているものは見当らないのですが、Relay の Network Layer をカスタマイズすることでなんとかなりそうです。Relay の方はちゃんと試してはいないので、知見をお持ちの方がいれば教えてください。

良さと注意点

現時点だと GraphQL でファイルをアップロードする方法としてはこの GraphQL multipart request specification が良さそうだと感じています。理由としては、まず仕様として公開されていて、既にいくつか実装があるということ。また、それらの実装を使うことで既存のアプリケーションの実装を大きく変えなくてもよいというのも良いですね。様々なクライアントに対応できるのが GraphQL の良さのひとつですので、Apollo/Relay どちらからでも使えるというのも重要なポイントだと思います。それから、私はよく Ruby を使うので Ruby の実装があるのも嬉しい。

注意が必要な点としては、あくまで multipart/form-data を前提にしているので、それに対応できないサーバやクライアントでは採用できません。

Ruby でのサーバ側実装

サーバサイドではどんな感じで導入すれば良いのでしょう。最後に GraphQL multipart request specification の Ruby のサーバサイド実装として jetruby/apollo_upload_server-ruby を紹介します。この gem が提供するのは次の2つです。

  • アップロードファイルを表現する Upload というスカラ型
  • GraphQL multipart request specification を実装した Rack middleware

Gemfile に足すと middleware が挿入されるので、基本的にはそれで導入は完了です。ただ、ひとつ注意点があります。このミドルウェアはファイルを ApolloUploadServer::Wrappers::UploadedFile として params に入れてくれるのですが、これは ActionDispatch::Http::UploadedFileDelegateClass でラップしたオブジェクトです。そのため、Active Storage にそのまま渡せないという問題があります (jetruby/apollo_upload_server-ruby#10)。私が手元で検証した際は、workaround として この comment のように IO とファイル名をひとつずつ渡しました。

おわりに

GraphQL でのファイルアップロードについて現状の私の理解を書きました。ここで紹介した GraphQL multipart request specification は、良さそうとは言いつつも、本格的な導入はまだ残念ながらできていません。知見をお持ちの方がいればぜひ教えてください。

もうすぐ大晦日ですね。よいお年を!

参考