ClojurescriptでSPAを書いていたのですが、サーバーサイドで生成したPDFファイルをダウンロードしたい要件があって、それを試していました。情報があまりなくて1週間くらいハマっていましたが、解決したので経緯をまとめておきます。
先に結論
XhrIo#setResponseType を使いましょう。
やりたいこと
ClojureScriptで、非同期リクエストを叩いて、ファイルダウンロードさせる。
使用する技術
サーバーサイド
クライアントサイド
サーバーサイドの実装
簡単なduct用の設定を書いて
{:components {:app #var duct.component.handler/handler-component
:http #var ring.component.jetty/jetty-server}
:endpoints {;; 画面リソース(HTML/CSS)を返すエンドポイント
:site-endpoint #var download-via-xhrio-example.endpoint.site/site-endpoint
;; ファイルを返却するAPIエンドポイント
:api-endpoint #var download-via-xhrio-example.endpoint.api/api-endpoint}
:dependencies {:http [:app]
:app [:site-endpoint :api-endpoint]}
:config {:app {:middleware {:functions {:not-found #var duct.middleware.not-found/wrap-not-found
:resource #var ring.middleware.resource/wrap-resource}
:applied [:resource :not-found]
:arguments {:resource "download_via_xhrio_example/public"
:not-found "Resource not found."}}}
:http {:port http-port}}}
from resources/download_via_xhrio_example/system.edn
上記設定ファイルに入ってるAPIエンドポイントを立てます。
(defn api-endpoint [_]
(routes
(context "/api" _
(ANY "/download/plain-text" _ (plain-text-resource))
(ANY "/download/image" _ (image-resource))
(ANY "/download/pdf" _ (pdf-resource)))))
from src/clj/download_via_xhrio_example/endpoint/api.clj
で、liberatorのresource公開をします。
(defn- fetch-image []
(ring-response (io/file (io/resource "download_via_xhrio_example/download/image.png"))
{:headers {"Content-Type" "image/png"}}))
(defresource ^:private image-resource []
:allowed-methods [:get]
:available-media-types ["*/*"]
:handle-ok (fetch-image))
from src/clj/download_via_xhrio_example/endpoint/api.clj
liberatorはアウトプットとしてFileオブジェクトを返却できる(see: 公式ドキュメント)ので、上記エンドポイントでは単純にクラスパスからファイルを検索してFileオブジェクトにして、それをレスポンスボディに設定しています。 ring-response
でレスポンスヘッダのContent-Typeをちゃんと設定してあげないと実態に即していないレスポンスヘッダが返却されるので注意してください。
これだけで、curlでエンドポイントを叩いてレスポンスボディをファイルに吐くとちゃんとファイルが取れます。liberator、便利ですね!
クライアントサイドの実装
クライアントサイドは、ClojureScriptに同梱されているGoogle closure libraryの機能のひとつ、XhrIoを使用して非同期通信を実現します。
EventListener追加
まずはボタンをクリックした時に発火するイベントをClojureScriptから登録しましょう。今回はサンプルアプリのためSPAにはしていないので、 DOMに直接#addEventListener
してあげなければいけません。以下のようにやればできます。
(defn- add-event-listener [dom-id callback-fn]
(.addEventListener (js/document.getElementById dom-id)
"click"
callback-fn))
from src/cljs/download_via_xhrio_example/core.cljs
(add-event-listener "image" download/image)
from src/cljs/download_via_xhrio_example/core.cljs
この時、figwheel の jsリロード前に実行されるhookであるbefore-js-reloadを定義してあげます。これを忘れると、コードを編集して EventListener の定義を変更すると前の EventListner が残って変な挙動のモトになります。
(defn before-js-reload []
(remove-event-listener "plain-text" download/plain-text)
(remove-event-listener "image" download/image)
(remove-event-listener "pdf" download/pdf))
from src/cljs/download_via_xhrio_example/core.cljs
また、#addEventListener
の引数に渡すのは関数なので、 download/image
というように、関数への参照自体を渡しましょう。(download/image)
みたいに渡してしまうと、 (add-event-listener)
が実行された時に評価されてしまいます。
関数型脳の発達した方には当たり前のことだと思うのですが、ここでも1時間くらいハマりました。精進せねば...。
非同期リクエストの送信
これでボタンをクリックした時に関数が実行できるようになりました。肝心のその中身である、非同期ファイルダウンロードの仕組みを作っていきましょう。
ClojureScriptからXhrIoを呼ぶ
非同期リクエストの送信にはJavaScriptライブラリであるGoogle closure libraryを使用するので、ClojureScriptから適宜JavaScriptを呼びつつ非同期リクエストを送信します。
(defn request
"Send Asynchronous request via Xhrio"
[url method handler & {:keys [body headers]}]
(let [xhrio (net/xhr-connection)]
;; you have to set response type if you want to manage response as binary.
(.setResponseType xhrio ResponseType.ARRAY_BUFFER)
(event/listen xhrio
:success
(fn [event]
;; you have to use #getResponse when you set response type.
;; see: https://google.github.io/closure-library/api/goog.net.XhrIo.html
(handler (.getResponse (.-target event)))))
(net/transmit xhrio
url
(.toLowerCase (name method))
body
(clj->js (merge headers {})))))
from src/cljs/download_via_xhrio_example/xhrio.cljs
(defn image []
;; 上で定義したrequest関数を呼び出す
(request "http://localhost:3000/api/download/image"
:get
;; callback
(fn [res] (send-pdf-to-browser res "image.png"))))
from src/cljs/download_via_xhrio_example/download.cljs
ここに本投稿で一番重要なポイントが入っているのですが、XhrIoのレスポンスとしてバイナリファイルを受け取りたい時は、 #setResponseType
して、 #getResponse
でレスポンスを受け取りましょう。Google closure libraryのXhrIo公式ガイドを通りに実装すると、特にレスポンスタイプを指定せずにリクエストを投げて、戻り値を #getResponseText
で受け取るような実装をすると思いますが、これだとバイナリがテキストに変換されて、データが壊れます。結果、たとえダウンロードしても全く開けないファイルが出来上がります。
レスポンスの受け取り
正しい形でファイルの情報が受け取れたら、それをブラウザにダウンロードさせてあげましょう。
ファイルダウンロードの仕組み
そもそも、ブラウザでのファイルダウンロードというのはどうやって実現されているのでしょうか?
一番シンプルなファイルダウンロードの仕組みは、<a>
タグにファイルへのリンクを貼ることです。
<a href="hoge.png">Download image file here!</a>
これがブラウザ <===> HTML間の、ファイルダウンロードの基本的なインターフェースです。またHTML5ならファイル名の指定に download
属性を使用することができます。
<a href="hoge.png" download="ほげ.png">Download image file here!</a>
しかし、最終的にブラウザにファイルをダウンロードさせるためのインターフェースがこれで良いことはわかりましたが、これでは
- GETリクエストで
- かつ、URLを叩けば良い
場合にしかダウンロードができません。POSTした結果をダウンロードさせたい場合や、サーバーサイドにアクセスせずにファイルを生成して、それをダウンロードさせたい時はどうしたら良いでしょうか?
その時は、ブラウザのローカルシステム内に生成したファイルを保存して、そのファイルへのリンクを埋め込んだ<a>
タグを生成し、クリックしてしまいましょう。つまり、上記のインターフェースとなる<a>
タグを動的に生成するのです。そしてそのリンク先には、JavaScriptで生成したブラウザの一時ファイルへのリンクを設定します。
これなら、基本インターフェースに加えてブラウザのローカルシステムにファイルを生成する方法がわかれば良さそうです。
ブラウザで動くJavaScriptにはBlobというオブジェクトが用意されています。これを利用することでオブジェクトをファイルのように扱うことができるので、それを使ってファイルとそのリンクを生成 => ダウンロードリンクを作ってダウンロードイベント発火 の流れで動的に生成したファイルやAPIレスポンスをダウンロードできそうです。
レスポンスをファイルとしてダウンロードさせる
上記のような
- ファイルに書き出したい情報をBlobに渡す
- Blobへのリンクを貼った
<a>
タグを生成してクリックさせる
の流れをClojureScriptで書いていきます。
(defn- send-pdf-to-browser [binary file-name]
(let [blob (js/Blob. #js [binary]
#js {"type" "application/octet-binary"})
url (.createObjectURL (.-URL js/window) blob)
link (dom/createDom "a" #js {"href" url})]
(dom/appendChild (.-body js/document) link)
(set! (.-download link) file-name)
(.click link)
(dom/removeChildren link)))
from src/cljs/download_via_xhrio_example/download.cljs
まとめ
レスポンスタイプを設定するだけなのに1週間以上ハマりました。ツラカタヨ。