😎

ダウンロード欄にファイル項目追加されたことをcookieで検出する方法

に公開

記事で書かれているノウハウは、なんの話なのか?

formのsubmitでバックエンド側でZipファイルなどを作成し、responseに乗せてダウンロード
させる場合に、それなりにresponseがブラウザに返ってくるまで時間がかかる処理の場合、
画面全体表示のスピナーを表示させたいことがあるでしょう。
無反応な時間が数秒続くと、うまくボタンを押せてないと勘違いされた何度も
押してしまうかもしれません。

ただし、通常のformのsubmitの処理と異なり、responseが返ってきても
ブラウザの右上のダウンロードにファイルの項目が追加されますが、
画面全体の再表示とはならないため、
画面全体表示のスピナーが、消えてくれません。

通常のformのsubmitの処理では、画面全体の再表示なので勝手に消えてくれますので、
消す処理のことを考えずに、気軽に、画面全体表示のスピナーを表示を使えます。
それと調子で、ダウンロードの際はできない。
レスポンスが返ってきて、右上のダウンロード欄にファイル項目が、
追加された後も、画面全体のリロードがかからず、
画面全体表示のスピナーは表示されっぱなしになってしまいます。

もし、fetch()を使ったajax系の処理でblobをダウンロードするように実装していれば、
比較的、簡単に、全体スピナーの表示/非表示を差し込める

しかし、formのsubmitや、aタグのhrefでバックエンドの
ダウンロード処理を実行してるケースでは、
responseが返ってきて、ブラウザの右上のダウンロードの欄にファイルが追加される
タイミングで、画面全体表示のスピナーを消したいが、
そのタイミングをとらえるためののイベントが取得できない。

それをどうするか?

という話題を当記事に書いているのである。

★★★★★★★★★★★★★★★★
formのsubmitや、aタグのhrefでバックエンドのダウンロード処理を実行
の構造維持のままで、できるノウハウです。

その構造は維持したままでよい。
つまり、
fetch()を使ったajax系の処理でblobをダウンロードするように実装に
変更しなくても、可能なノウハウです。
★★★★★★★★★★★★★★★★

比較的簡単な方法がありましたので、それを当記事に書いてます。

ぜんぜん、大したことないやり方なのですが、
★★★★★★★★★★★★★★★★
フロントエンドからdl_tokenの名前でランダム値を送り込んで
バックエンドでは、そのランダム値をキー、"1"をバリューとした
cookieを返して、フロントエンドはそれが送り込まれてくるまでポーリングすればよい
★★★★★★★★★★★★★★★★
そのノウハウが当記事に書かれています。

この記事を書くに至った経緯

この経緯は長いため、当記事の一番後ろに、書きました。

バックエンド側

今回の例ではdjangoですが、別の他の言語やフレームワークに置き換えて
似たような対応すればよろしいかと思います。

# views.py
import uuid
from django.http import StreamingHttpResponse, FileResponse

def download_view(request):
    dl_token = request.POST.get("dl_token")   # フォームから受け取る
    file_iter = my_slow_generator()        # 時間のかかるストリーム
    response = StreamingHttpResponse(file_iter, content_type="application/zip")
    response["Content-Disposition"] = 'attachment; filename="bigdata.zip"'

    # キーがフロントエンドから送り込まれたトークン、バリューが"1"
    # のcookie情報を指定しておく
    # header がブラウザに届いた瞬間、フロントエンド側の実装が認識できるようにする。
    response.set_cookie(
       dl_token, "1",
       max_age=600,
       httponly=False,
       #*** secure=True,
       secure=not settings.DEBUG,
    )
    return response

のようにする
response.set_cookie(
dl_token, "1",
max_age=600,
httponly=False,
#*** secure=True,
secure=not settings.DEBUG,
)
secure=Trueは、httpsの時だけ、送るという意味らしいです
ですが、ローカルで開発時は、http://127.0.0.1:8000などでしてるでしょうから具合悪いです

settings.DEBUGは、djangoでローカル開発時は、値をTrue、それ以外は、False
にしとくようなことをお作法的にしておくようです。
各Webアプリの実装によりけりですが、なんらかの実装や仕組みで
そうしておくようです。

ですので、これを利用し、secure=not settings.DEBUG,と指定しているわけです。
max_age=600,は、このcookieのエントリーは、もし、消されずに残っても
600秒経過した時点で自然に消えるようにする設定のようです。
httponly=False,は、javascriptのdocument.cookieで読めるようにする設定のようです。

フロントエンド側

<script src="https://cdnjs.cloudflare.com/ajax/libs/uuid/8.2.0/uuid.min.js" integrity="sha384-vGylWQMMy7y+wsUm/E7BJwQ3zO7v80pjBlIEHZdA18j88CXceL71YR7pRrGu/XWI" crossorigin="anonymous"></script>

などを指定し、cryptoが使える状況で、

<form id="dl-form" action="/download/hoge" method="post">
  {% csrf_token %}
  <input type="hidden" name="dl_token" id="dl_token">
  <button id="downloadHogeButton" type="submit">hogeのダウンロード</button>
</form>

<script>
document.getElementById("dl-form").addEventListener("submit", e => {
  // ランダムトークンを発行
  const token = crypto.randomUUID();
  document.getElementById("dl_token").value = token;

  // スピナーを表示
  document.getElementById("spinner").hidden = false;

  // cookieの到着をポーリングで検知
  const timer = setInterval(() => {
    if (document.cookie.includes(`${token}=1`)) {
      clearInterval(timer);
      // cookieの削除
      document.cookie = `${token}=; Max-Age=0; path=/`;
      // スピナーを消す
      document.getElementById("spinner").hidden = true;
    }
  }, 300);
});
</script>

★補足事項★
downloadHogeButtonなどのbuttonに対するclickで、
document.getElementById("downloadHogeButton").addEventListener("click", function(event) {
にて、
先頭で、
event.preventDefault();
デフォルトのsubmitの処理を一旦、抑制し、
処理を差し込んだ後に、
最後に、
event.target.form.submit();
で、抑止していたデフォルトのsubmit動作を実行する

このやり方で、処理を差し込む方法もある

ただ、
EnterキーなどでsubmitされるようなUI構成だったとき、そのやり方でのsubmitや、
プログラムからsubmitを実行された時のsubmit
などの時に、処理の差し込み部分が動いてくれない
あくまで、該当のボタンが、押されたとき、clickの時の差し込みでしかない
EnterキーなどでsubmitされるようなUI構成だったとき、そのやり方でのsubmitや、
プログラムからsubmitを実行された時のsubmit
などの時も含め、
該当のformのsubmitの時には、いつでも、処理を差し込みたいという場合には、
コードで示した、
document.getElementById("dl-form").addEventListener("submit", e => {
のような方式の
formのsubmitに対して処理を差し込むやり方が、確実なのである。

★補足★

<script src="https://cdnjs.cloudflare.com/ajax/libs/uuid/8.2.0/uuid.min.js" integrity="sha384-vGylWQMMy7y+wsUm/E7BJwQ3zO7v80pjBlIEHZdA18j88CXceL71YR7pRrGu/XWI" crossorigin="anonymous"></script>


integrity="sha384なにがし
の値などをどうしてるかというと、

https://www.srihash.org/
にて、

https://cdnjs.cloudflare.com/ajax/libs/uuid/8.2.0/uuid.min.js

を入力すると出力されたものを使っています。

uuid.min.jsの中身をより、計算で出力されたのが、
integrityの部分の値です。

上記の方法であれば、だれでも、取得可能な情報なのですが、

なんの意味があるのかというと、
開発時点での参照していたCDNのソースコードと
1バイトでも、異なるものにCDNが変更された場合、
CDN側の元ソースが、悪意のある改ざんなど、変更されてた場合、
それを検知し、無視するような仕組みとのこと

つまり、開発時に動作確認して問題がなかったCDNの参照元と、1バイトも異なることがない
物を本番時も参照して動作することの保証。

そうじゃなかったら、無視しての動作なので、その部品に依存したところがエラーになったりするでしょう

CDNの参照元が改ざんされるような事が過去にあったようです。

その場合でも、セキュリティの担保のためにあるものとのこと

フロントエンド側(<aタグのhrefでダウンロードの場合)

<a id="downloadHoge" href="/foo/bar/downloadHoge"><button>hogeをダウンロード</button></a>

のような場合に、
全体スピナーの表示/非表示を差し込みたいケースは、

<script>
document.getElementById("downloadHoge").addEventListener("click", function(event) {
  event.preventDefault();

  // ランダムトークンを発行
  const token = crypto.randomUUID();
  document.getElementById("dl_token").value = token;

  // スピナー表示
  document.getElementById("spinner").style.display = "block";

  // 少し遅らせてからダウンロード開始(スピナーが描画される時間確保)
  setTimeout(() => {
    const a = document.createElement("a");
    // ?dl_token=xxxx を追記する形とする。
    a.href = `${this.href}?dl_token=${dl_token}`;
    a.style.display = "none";
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
  }, 100); // 少しだけ遅延

  // cookieの到着をポーリングで検知
  const timer = setInterval(() => {
    if (document.cookie.includes(`${token}=1`)) {
      clearInterval(timer);
      // cookieの削除
      document.cookie = `${token}=; Max-Age=0; path=/`;
      // スピナーを消す
      document.getElementById("spinner").hidden = true;
    }
  }, 300);
});

のような形とする。
バックエンド側でのdl_tokenの受け取りは、
dl_token = request.POST.get("dl_token") # フォームから受け取る
ではなく
dl_token = request.GET.get("dl_token") # フォームから受け取る

<aタグのclickを、event.preventDefault();で一旦、キャンセルし
差し込み処理を入れたときに、
その後、デフォルトの処理を実行しようにも効かない
かといって、location.href = this.href; での画面リロードも
ダウンロード処理にもあわないし、表示したスピナーがすぐ消えてしまう

仕方がないため、子要素として、<aタグを追加し、
href属性をthis.hrefから引き継ぐ
このときに、dl_tokenをリクエストパラメータで引き渡せるようにすればよい
そして、子要素の<aタグをclickして、すぐに削除すればよい。

こうすることで、<aタグで、バックエンドのダウンロード処理を起動するケースにおいても
全体スピナーの表示/非表示に対応できるのである。

補足
300msなどの短い間隔でのポーリングで、サーバーアクセスが発生するものは
厳禁だと思います。バグった時に、期間銃のようにサーバーアクセスし、ダウンします。
5分とか、10分に1回、なにかのステータス値が変わったかをポーリングで
サーバー側を見る程度の頻度ではありうる話ですが、
300msで機関銃のようにサーバー見に行くなど絶対にやっちゃいけないと思います。
今回の例は純粋にブラウザの中で完結した形でcookieの値の有無を見てるだけなので
仮に、バグってもダメージないです。
このポーリング方式で、画面全体のスピナーを消す対処は、バックエンドがエラーになったときに、
ブラウザの画面全体のスピナーが表示されっぱなしになってしまう
問題点があると思います。だから、本当は、fetch()などのajax通信で、
blobをダウンロードし、ダウンロード欄にファイル追加の項目を出す実装に変更し、
それであれば、エラーハンドリングできるでしょうから、そこで、画面全体のスピナーを消す
のが最もよろしいとは思いますが
そこまでしなくてもいいんでないかと思われるような場面で、
元がformのsubmitや、<aタグのhrefで
バックエンドのダウンロードを実行していた。
そこに、簡易的な方法で、そこまで手間かけずに、正常系だけうまくいっとけばよろしいか
みたいな感覚で、画面全体表示のスピナー表示/消す を差し込みたい場合には
有効な手法だとおもいます

それから、
fetch()などのajax通信で、
blobをダウンロードし、ダウンロード欄にファイル追加の項目を出す実装にも
問題点はあるみたいです。大きすぎるファイルのダウンロード時に、
blobのデータの全量をjavascript内で扱うため、重たくなったり
あまりにも大きすぎると動かないなどの潜在的な問題点あるみたいです
右上のダウンロード欄へのファイル追加も、ブラウザの種類による動作も大丈夫か
気になるです。
ですから、
★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★
なんでもかんでも、
fetch()などのajax通信で、
blobをダウンロードし、ダウンロード欄にファイル追加の項目を出す実装
の一択にしとけばよいというのも乱暴な思考かもしれません。
★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★★

大きすぎるファイルの場合は、formのsubmitや、<aタグのhrefで
バックエンドを動作させてダウンロードさせてたほうが安定するでしょう。
その代わり、今回のcookieのフラグでの検知でしか、
画面全体のスピナーを消す手段がなく
バックエンド側でエラー発生時に、画面全体のスピナーが表示されっぱなしになってしまう
問題点は残ります。
一長一短かと思います。

開発初期段階で、 blobをダウンロードし、ダウンロード欄にファイル追加の項目を出す実装
をうまく部品化して、その部品を適用する形で、
実装の統一化を図りながら ( ★部品化するから統一できる★ )
その部品の中でで、画面全体のスピナーの表示/非表示を行っておくことにする。
そして、もし、
ファイルサイズが大きすぎて問題が起きたダウンロード機能があったら、
それらだけ、formのsubmit形式で、ダウンロードさせて、
今回のcookieのポーリング方式で、画面全体のスピナーを消すをやるが、
その方式に変更した機能に関しては、バックエンド側でエラーが出た場合に
画面全体のスピナーを消すことができない件は、あきらめる
これが、ゼロベースでやるのであれば、一番よろしいやり方と個人的に思います。
ただ、これは、開発初期段階から、このように計画的にしていた場合の話です。

途中からの場合は、手間とか工数の問題もあるでしょうから適切なものを
個々の機能で選択すればよろしいかと思います。

この記事を書くに至った経緯

★★★★★
ここは、直接、ノウハウは書いてないので、興味が無かったら読まなくてよいかと思います
どういう状況で当記事のノウハウが必要になったかの経緯を書いてます。
であるため、気になる人は後で読めばよい。要らないなら読まなくてもよい。
★★★★★★

PythonのdjangoのWebアプリでバックエンド側の処理でZipファイルを作成し、
ブラウザの右上欄などでのダウンロードさせる機能の実装をしていた。
ところがとある業務パターンの時に、バックエンド側でZipファイルを作成するまでの処理に
時間がかかるものがあった。

頻繁に操作するものではなく、一部の管理者ユーザが、たまに行うため
時間がかかっても確実にできればよいとのことだが、
ダウンロードのその該当のリクエストについて、長時間、フロントエンドとバックエンドとで
無通信状態の時間ができてしまったため
(その間、バックエンド内でZip作成に必要な、DBからの情報収集や計算処理時間 )
Azure側でタイムアウトとなる現象があった。

バックエンド内の

  • HttpResponseでなくStreamingHttpResponseを使うように変更
  • ZipFileでなくZipStreamを使うように変更

することで、なるべく早期にブラウザまでレスポンスが返ってきて
その後、右上のダウンロード欄にて、ストリーミングダウンロードのような動きをしました
このことにより、Azure側でタイムアウトを一旦、回避できました。

しかし、なるべく早期にブラウザまでレスポンスが返ってくるにしても、
数秒程度は、時間がかかってしまいます。

頻繁に行う操作でなく、管理者ユーザがたまになので、まぁ、それもよろしいのですが。

その数秒間、無反応だと思って何度も、ボタンを押してしまうと不都合あるため、
画面全体を覆いつくすようなスピナーを表示することにしました。

type="submit"のボタンを押しまして、<form action="でのバックエンド処理が動きますので
通常ですと、ブラウザに返ってきたときは、画面がリロードされますので、
それと同時に、画面全体を覆いつくすようなスピナーは消えてくれます

しかし、ダウンロードさせる都合上、
バックエンド側のdjangoのコードで、

response["Content-Disposition"] = 'attachment; filename="foo.zip"'

のように、レスポンスに指定しているブラウザに制御が返ってきても、画面のリロードがかかりません。
代わりに、ダウンロード欄にファイルの項目追加されるだけです。

そのため、画面全体を覆いつくすようなスピナーを表示が消えませんでした。
( 画面リロードでDOMの再読み込み、再表示にならないからです )

そして、
ブラウザの右上にダウンロード欄にファイルの項目追加されたタイミングでの
イベントを取得することができない
( Webの仕様として、セキュリティの理由で、ブラウザのダウロード欄へのそのイベントを取得する仕組みは準備されていませんので、そのイベントをjavascriptで直接取得する手段はありません )
( そのイベント取得が、できるのであれば、そのタイミングで、画面全体を覆いつくすようなスピナーを消す
処理を実装すればよいのであるが、しかし、イベントの取得ができない。)

以前、別のシステムで似たようなことを経験してます。
そのときは、type="submit"をやめて、$ajaxや、fetch()での非同期通信で、
blob型を受け取るような実装で、自力で、ブラウザのダウンロード欄に
ファイルを追加するような実装をしてうまく動いた記憶がありますが
デバッグが大変だったと思います

また、その方法だと、ダウンロードしたファイルのバイナリのサイズが大きすぎたときや
ブラウザの種類で、うまくいかない懸念があったりしました。
その懸念事項を、つぶすため、何度もデバックして
裏どりしていく面倒な作業になってしまったと記憶してます。

だから、もっと簡単に
できないものかとおもってました。

type="submit"での<form action="に指定したバックエンド処理で
ダウンロードの実態を動かす構成は変更したくありません。
( ダウンロード系の処理は、その構成が最も簡単であるため、それを崩して、複雑化させたくない )

ちょっとしたコツみたいな実装を、「 ちょこっと 」するだけで、
比較的簡単に対応できる方法がないかと思ってたら

バックエンド側でcookieにフラグをたてて、フロントエンド側では、
ポーリングして、そのフラグがcookieに送り込まれたことを検知する方式をとれば
比較的、簡単に対処できる方法がありました。

同じようなこと、今後も、おきるでしょうから、
ここに、コードイメージをメモっておいて、その時は、
当記事からのコピペで対応しとけば、
よろしい状況にしときたいから、この記事書きました。

Discussion