ClojureScript x XhrIo x 非同期通信 でバイナリファイルをダウンロード

ClojurescriptでSPAを書いていたのですが、サーバーサイドで生成したPDFファイルをダウンロードしたい要件があって、それを試していました。情報があまりなくて1週間くらいハマっていましたが、解決したので経緯をまとめておきます。

## 先に結論

XhrIo#setResponseType を使いましょう。

## やりたいこと

ClojureScriptで、非同期リクエストを叩いて、ファイルダウンロードさせる。

## 使用する技術

### サーバーサイド

* [duct-framework](https://github.com/duct-framework/duct)
* [liberator](https://github.com/clojure-liberator/liberator)

### クライアントサイド

* [Google closure library](https://developers.google.com/closure/library/)

## サーバーサイドの実装

[duct](https://github.com/duct-framework/duct)のうえに[liberator](https://github.com/clojure-liberator/liberator)を乗っけます。

簡単なduct用の設定を書いて

```clj:system.edn
{: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](https://github.com/blackawa/download-via-xhrio-example/blob/master/resources/download_via_xhrio_example/system.edn)*

上記設定ファイルに入ってるAPIエンドポイントを立てます。

```clj:api.clj
(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](https://github.com/blackawa/download-via-xhrio-example/blob/master/src/clj/download_via_xhrio_example/endpoint/api.clj#L33)*

で、[liberator](https://github.com/clojure-liberator/liberator)のresource公開をします。

```clj:api.clj
(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](https://github.com/blackawa/download-via-xhrio-example/blob/master/src/clj/download_via_xhrio_example/endpoint/api.clj#L15)*

[liberator](https://github.com/clojure-liberator/liberator)はアウトプットとしてFileオブジェクトを返却できる(see: [公式ドキュメント](https://clojure-liberator.github.io/liberator/doc/representations.html))ので、上記エンドポイントでは単純にクラスパスからファイルを検索してFileオブジェクトにして、それをレスポンスボディに設定しています。 `ring-response`でレスポンスヘッダのContent-Typeをちゃんと設定してあげないと実態に即していないレスポンスヘッダが返却されるので注意してください。

これだけで、curlでエンドポイントを叩いてレスポンスボディをファイルに吐くとちゃんとファイルが取れます。[liberator](https://github.com/clojure-liberator/liberator)、便利ですね！

## クライアントサイドの実装

クライアントサイドは、ClojureScriptに同梱されている[Google closure library](https://developers.google.com/closure/library/)の機能のひとつ、[XhrIo](https://google.github.io/closure-library/api/goog.net.XhrIo.html)を使用して非同期通信を実現します。

### EventListener追加

まずはボタンをクリックした時に発火するイベントをClojureScriptから登録しましょう。今回はサンプルアプリのためSPAにはしていないので、 DOMに直接`#addEventListener`してあげなければいけません。以下のようにやればできます。

```clj:core.cljs
(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](https://github.com/blackawa/download-via-xhrio-example/blob/master/src/cljs/download_via_xhrio_example/core.cljs#L6)*

```clj:core.cljs
(add-event-listener "image" download/image)
```

*from [src/cljs/download_via_xhrio_example/core.cljs](https://github.com/blackawa/download-via-xhrio-example/blob/master/src/cljs/download_via_xhrio_example/core.cljs#L17)*

この時、figwheel の [jsリロード前に実行されるhookであるbefore-js-reload](https://github.com/bhauman/lein-figwheel/issues/337)を定義してあげます。これを忘れると、コードを編集して EventListener の定義を変更すると前の EventListner が残って変な挙動のモトになります。

```clj:core.cljs
(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](https://github.com/blackawa/download-via-xhrio-example/blob/master/src/cljs/download_via_xhrio_example/core.cljs#L21)*

また、`#addEventListener`の引数に渡すのは関数なので、 `download/image` というように、関数への参照自体を渡しましょう。`(download/image)` みたいに渡してしまうと、 `(add-event-listener)` が実行された時に評価されてしまいます。
関数型脳の発達した方には当たり前のことだと思うのですが、ここでも1時間くらいハマりました。精進せねば...。

### 非同期リクエストの送信

これでボタンをクリックした時に関数が実行できるようになりました。肝心のその中身である、非同期ファイルダウンロードの仕組みを作っていきましょう。

#### ClojureScriptからXhrIoを呼ぶ

非同期リクエストの送信にはJavaScriptライブラリであるGoogle closure libraryを使用するので、ClojureScriptから適宜JavaScriptを呼びつつ非同期リクエストを送信します。

```clj:xhrio.xlj
(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](https://github.com/blackawa/download-via-xhrio-example/blob/master/src/cljs/download_via_xhrio_example/xhrio.cljs#L7)*

```clj:download.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](https://github.com/blackawa/download-via-xhrio-example/blob/master/src/cljs/download_via_xhrio_example/download.cljs#L20)*

ここに本投稿で一番重要なポイントが入っているのですが、**XhrIoのレスポンスとしてバイナリファイルを受け取りたい時は、 `#setResponseType` して、 `#getResponse`でレスポンスを受け取りましょう**。[Google closure libraryのXhrIo公式ガイド](https://developers.google.com/closure/library/docs/xhrio)を通りに実装すると、特にレスポンスタイプを指定せずにリクエストを投げて、戻り値を `#getResponseText` で受け取るような実装をすると思いますが、これだとバイナリがテキストに変換されて、データが壊れます。結果、たとえダウンロードしても全く開けないファイルが出来上がります。

### レスポンスの受け取り

正しい形でファイルの情報が受け取れたら、それをブラウザにダウンロードさせてあげましょう。

#### ファイルダウンロードの仕組み

そもそも、ブラウザでのファイルダウンロードというのはどうやって実現されているのでしょうか？
一番シンプルなファイルダウンロードの仕組みは、`<a>`タグにファイルへのリンクを貼ることです。

```html
<a href="hoge.png">Download image file here!</a>
```

これがブラウザ <===> HTML間の、ファイルダウンロードの基本的なインターフェースです。またHTML5ならファイル名の指定に `download`属性を使用することができます。

```html
<a href="hoge.png" download="ほげ.png">Download image file here!</a>
```

しかし、最終的にブラウザにファイルをダウンロードさせるためのインターフェースがこれで良いことはわかりましたが、これでは

* GETリクエストで
* かつ、URLを叩けば良い

場合にしかダウンロードができません。POSTした結果をダウンロードさせたい場合や、サーバーサイドにアクセスせずにファイルを生成して、それをダウンロードさせたい時はどうしたら良いでしょうか？
その時は、ブラウザのローカルシステム内に生成したファイルを保存して、そのファイルへのリンクを埋め込んだ`<a>`タグを生成し、クリックしてしまいましょう。つまり、上記のインターフェースとなる`<a>`タグを動的に生成するのです。そしてそのリンク先には、JavaScriptで生成したブラウザの一時ファイルへのリンクを設定します。
これなら、基本インターフェースに加えてブラウザのローカルシステムにファイルを生成する方法がわかれば良さそうです。

ブラウザで動くJavaScriptには[Blob](https://developer.mozilla.org/ja/docs/Web/API/Blob)というオブジェクトが用意されています。これを利用することでオブジェクトをファイルのように扱うことができるので、それを使ってファイルとそのリンクを生成 => ダウンロードリンクを作ってダウンロードイベント発火 の流れで動的に生成したファイルやAPIレスポンスをダウンロードできそうです。

#### レスポンスをファイルとしてダウンロードさせる

上記のような

1. ファイルに書き出したい情報をBlobに渡す
2. Blobへのリンクを貼った`<a>`タグを生成してクリックさせる

の流れをClojureScriptで書いていきます。

```clj:download.cljs
(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](https://github.com/blackawa/download-via-xhrio-example/blob/master/src/cljs/download_via_xhrio_example/download.cljs#L5)*

## まとめ

レスポンスタイプを設定するだけなのに１週間以上ハマりました。ツラカタヨ。