早速本題。
Blazor WebAssembly アプリに「ファイルをダウンロードする」機能(例えば Facebook では、投稿された写真をダウンロードできますが、ああいうやつ)を実装したい場合、どのように実装すればよいか? が、今回のお題です。
方法1 - 単純に <a>
タグに download
属性を付ける - しかし、期待通りにはいかない。
最初に思いつくのは、<a>
タグをファイルの URL にリンクさせて、そのタグに download
属性を追加するというシンプルな方法 (下記) です。
<a href="api/pictures/1" download="foo.jpeg">
download
</a>
残念ながら、この方法は期待通りには動作しません。
まず前提として、Blazor のランタイムはすべての URL ナビゲーションを捕捉し、一致するルート URL を持つコンポーネントのレンダリングに転送します。
そして、もしもマッチするルート URL が見つからない場合は、その URL への要求は Web ブラウザに処理を渡されます。
しかしこのとき、なんと、Blazor ランタイムは download
属性を考慮しません!
その結果、ユーザがこの <a>
タグをクリックした場合、ユーザはブラウザウィンドウ内に画像ファイルを見ることになり、ダウンロードは開始されません。
厳密に言えば、リンク先のファイルのコンテンツタイプが Web ブラウザが把握しているコンテンツタイプではない場合は、(ブラウザ内へのコンテンツ表示ではなくて) ダウンロードが開始されます。
しかし、このシナリオでダウンロードは実現はされるものの、アドレスバーの URL が、現在のページの URL からファイルの URL に変化してしまうので、好ましい動作ではないでしょう。
さらに加えて、このシナリオでダウンロードはされたとしても、download
属性で指定したダウンロードファイル名は無視されます。
方法2 - <a>
タグに、download
属性に加え... target="_top"
属性を追加!
実は、Blazor ランタイムによる URL ナビゲーションの捕捉を迂回するハックがあります。
そのハックとは、<a>
タグに target="_top"
属性を追加することです。
<a href="api/pictures/1" download="foo.jpeg" target="_top">
download
</a>
Blazor ランタイムが URL ナビゲーションを捕捉しないいくつかのパターンがありますが、target="_top"
属性を追加するのはそのパターンの一つです。
この実装方法は非常に簡潔で、且つ、期待どおりに完璧に動作します。
方法3 - JavaScript のヘルパー関数を使用する
さてところで、場合によっては、ダウンロード開始前にユーザーに確認したいこともあるでしょう。
これを実装するためには、JavaScript でヘルパー関数を実装しそれを呼び出す必要があります。
まず、ダウンロード URL とファイル名を引数に受け取り、その URL を、指定されたファイル名でダウンロードする JavaScript 関数を、以下のように実装します。
(余談ですが、自分は JavaScript のコードを実装する際には、いつも TypeScript を使っているので、以下の実装例も TypeScript で記述しています。)
// helper.ts
// (この TypeScript ソースファイルは "helper.js" にトランスパイルされます)
function downloadFromUrl(options: { url: string, fileName?: string }): void {
const anchorElement = document.createElement('a');
anchorElement.href = options.url;
anchorElement.download = options.fileName ?? '';
anchorElement.click();
anchorElement.remove();
}
この実装は、 JavaScript コードから所定の URL からのダウンロードを開始するための、よく知られた古典的な技法です。
こうして実装した JavaScript コードを、HTML 文書からインクルードしておきます。
...
<script src="_framework/blazor.webassembly.js"></script>
<script src="script/helper.js"></script>
</body>
</html>
ここまでできたらあとは、Blazor アプリ内の C# コードから、上記 JavaScriptヘルパー関数を呼び出すだけです (下記例)。
@inject IJSRuntime JSRuntime
...
<button @onclick="OnClickDownloadButton">
download
</button>
...
@code {
private async Task OnClickDownloadButton()
{
var res = await JSRuntime.InvokeAsync<bool>("確認", "よろしいですか?");
if (res == false) return;
await JSRuntime.InvokeVoidAsync(
"downloadFromUrl",
new {Url = "api/pictures/1", FileName = "foo.jpeg"});
}
}
方法4 - Blazor アプリ内のメモリ内バイト配列からダウンロードする
ここまでの方法はいずれも、所定の URL からのダウンロードは、Web ブラウザのナビゲータによって行なわれています。
しかしながら、Web ブラウザのナビゲータ経由では、所定の URL からの直接ダウンロードができない場合があります。
例えば、以下のような場合です。
- リソース URL がトークンベース (Cookie ベースではない) 認証で保護されており、Blazor アプリ内の HttpClient からしか取得できない場合。
- ダウンロードするコンテンツが、Blazor アプリ内の C# コードによって計算・生成される場合。
このような場合には、Blazor アプリ内のオンメモリのバイト配列を、ダウンロード可能にする技法が必要があります。
―――― [2020/10/18] 以下 @kasamal さんからのコメントを参考に改善 - ここから ――――
ところで、Blazor アプリ内部のバイト配列を JavaScript 側に引き渡す際、.NET 上のバイト配列を JavaScript にネイティブに転送する方法はないようです。
代わりに、Blazor アプリ内のバイト配列は、Blazor ランタイムの JavaScript 相互運用機能によって、 base64 にエンコードされた文字列に変換されてから、JavaScript 関数に渡るようです。
これを幸いに、ブラウザの data URL として、この base64 にエンコードされたバイト配列をブラウザに引き渡し、結果、.NET 側のオンメモリのバイト配列をダウンロードさせることが可能になります。
以上を踏まえつつ、以下のような JavaScript ヘルパー関数を実装しました。
// helper.ts
...
function downloadFromByteArray(options: {
byteArray: string,
fileName: string,
contentType: string
}): void {
// Blazor の JavaScript 相互運用機能によって、元のバイト配列が
// base64 文字列にエンコードされて渡ってくるので、
// これをそのまま data URL として組み立て
const url = "data:" + options.contentType + ";base64," + options.byteArray;
// 先の節で実装した、指定された URL からのダウンロードを開始する
// JavaScript 関数を呼び出して、ダウンロードを開始
downloadFromUrl({ url: url, fileName: options.fileName });
}
こんな感じで実装した JavaScript ヘルパー関数を、Blazor アプリ内の C# コードから呼び出すことになります (下記実装例)。
@inject HttpClient HttpClient
@inject IJSRuntime JSRuntime
...
<button @onclick="OnClickDownloadButton">
download
</button>
...
@code {
private async Task OnClickDownloadButton()
{
// ダウンロード元の URL がトークンベースの認証 (Cookie ベースではなく)
// で保護されている例を想像してください。
var bytes = await HttpClient.GetByteArrayAsync("api/pictures/1");
await JSRuntime.InvokeVoidAsync(
"downloadFromByteArray",
new
{
ByteArray = bytes,
FileName = "foo.jpeg",
ContentType = "image/jpeg"
});
}
}
以上のような実装にて、バイト配列をダウンロードさせることができます。
なお、この実装は、残念なことにとても非効率的です。
というのも、この実装では以下のように、元のバイト配列を何度も複製してしまうからです。
- 元のバイト配列は、まず、base64 文字列にエンコードされます。(元のバイト配列は、文字列という別の表現として複製される)
- Blazor ランタイムから JavaScript ランタイムに、この base64 文字列がコピーされます。(ランタイム境界を越えるときに文字列のコピーが発生する)
以上の点を考慮の上、サイズが大きくなるような場合は下手に C# 側で実装せずに JavaScript 側でがっつり実装するなどの別の実装技法を選ぶなどの判断が必要となるでしょう。
―――― [2020/10/18] @kasamal さんからのコメントを参考に改善 - ここまで ――――
―――― [2020/10/18] 以下は、@kasamal さんからのコメントを参考に改善する前の元の記事 - ここから ――――
そのためには、Webブラウザの **"オブジェクトURL"**機能を利用します。
具体的には、URL.createObjectURL()
API を使うことで、(バイト配列を内包する) "Blob" オブジェクトにリンクされた有効なURL (これを "オブジェクトURL" と呼ぶ) を作成することができます。
- "URL.createObjectURL() - Web APIs | MDN"
ここで一点、注意が必要なことがあります。
というのは、Blazor アプリ内部のバイト配列を JavaScript にネイティブに転送する方法がないようなのです。
代わりに、Blazor アプリ内のバイト配列は、Blazor ランタイムの JavaScript 相互運用機能によって、 base64 にエンコードされた文字列に変換されてから、JavaScript 関数に渡るようです。
以上を踏まえつつ、以下のような JavaScript ヘルパー関数を実装しました。
// helper.ts
...
function downloadFromByteArray(options: {
byteArray: string,
fileName: string,
contentType: string
}): void {
// base64 文字列を数値型の配列にデコード
const numArray = atob(options.byteArray).split('').map(c => c.charCodeAt(0));
// できた数値型の配列を、Uint8Array 型のオブジェクトに変換
const uint8Array = new Uint8Array(numArray);
// さらにそれを Blob 型のオブジェクトで包む
const blob = new Blob([uint8Array], { type: options.contentType });
// この Blob オブジェクトにリンクした "オブジェクト URL" を生成
const url = URL.createObjectURL(blob);
// 先の節で実装した、指定された URL からのダウンロードを開始する
// JavaScript 関数を呼び出して、ダウンロードを開始
downloadFromUrl({ url: url, fileName: options.fileName });
// 最後に不要リソースの後始末
URL.revokeObjectURL(url);
}
こんな感じで実装した JavaScript ヘルパー関数を、Blazor アプリ内の C# コードから呼び出すことになります (下記実装例)。
@inject HttpClient HttpClient
@inject IJSRuntime JSRuntime
...
<button @onclick="OnClickDownloadButton">
download
</button>
...
@code {
private async Task OnClickDownloadButton()
{
// ダウンロード元の URL がトークンベースの認証 (Cookie ベースではなく)
// で保護されている例を想像してください。
var bytes = await HttpClient.GetByteArrayAsync("api/pictures/1");
await JSRuntime.InvokeVoidAsync(
"downloadFromByteArray",
new
{
ByteArray = bytes,
FileName = "foo.jpeg",
ContentType = "image/jpeg"
});
}
}
以上のような実装にて、バイト配列をダウンロードさせることができます。
~~なお、この実装は、残念なことにとても非効率的です。 ~~
というのも、この実装では以下のように、元のバイト配列を何度も複製してしまうからです。
1. 元のバイト配列は、まず、base64 文字列にエンコードされます。(元のバイト配列は、文字列という別の表現として複製される)
2. Blazor ランタイムから JavaScript ランタイムに、この base64 文字列がコピーされます。(ランタイム境界を越えるときに文字列のコピーが発生する)
3. JavaScript 側に渡された base64 文字列は、数値配列にデコードされます。(元の base64 文字列が確保しているメモリとは別に、デコード後の数値配列メモリが確保、別の表現として再び複製される)。
4. (以下略)
以上の点を考慮の上、サイズが大きくなるような場合は下手に C# 側で実装せずに JavaScript 側でがっつり実装するなどの別の実装技法を選ぶなどの判断が必要となるでしょう。
―――― [2020/10/18] @kasamal さんからのコメントを参考に改善する前の元の記事 - ここまで ――――
おわりに
以上、今回の投稿では、Blazor アプリに「ファイルをダウンロード」機能を実装する方法を紹介しました。
サンプルコードはすべて下記の GitHub リポジトリで公開しています。
より効率的な実装をご存知の方はお知らせ下さい、ぜひその技術を共有しましょう!
Happy Coding! :)