はじめに
Excelダウンロード機能を実装するとき、普段のJSON APIと同じ感覚で設計すると前提が少しズレる部分がありました。
特に 「レスポンスがJSONではなくバイナリになる」ことに伴う
ヘッダ・型・Swagger/OpenAPI
このまわりの扱いは、少し特殊なので記事にまとめることにしました。
今回のアーキテクチャはこんな感じです。
Next.js(フロント)
↓
PHP(中継サーバー)
↓
Nest.js(API / BFF サーバー)
- 認証のため中継サーバーに寄せる必要がある
- その後ろで Nest.jsのAPIがExcelのバイナリを返す
- フロントはそれをブラウザ標準のUIでダウンロードさせる
という前提で設計・実装しました。
大まかな方針
- バイナリデータを返却するAPIを作成する
- ブラウザのメモリからダウンロードする
Excel生成は重い処理なのでバックエンドに任せます。
また、ダウンロードUIはリッチな機能がすでにあるブラウザの標準機能を活用します。
細かい制約
通信形式
よく利用するjsonのやりとりのAPIとは、少し異なる実装が必要です。
普段のJSON APIでは
- Request: JSON
- Response: JSON
の世界で完結します。
一方、Excelダウンロードでは
- Request: JSON
- Response: binary(Excel)
になるので、ヘッダと型の考え方が変わります。
Requestヘッダ
POSTで検索条件を送りたいので、普通のJSONと同じく
- Content-Type: application/json
はそのままにします。
ただし レスポンスとしてExcelも許可したいのでAcceptヘッダーを意識します。
エラーの場合はjsonを返すようにしているので、'application/json'も配置します。
- Accept: 'application/json'
- Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
例:
const res = await fetch('/api/excel', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': [
'application/json',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
].join(','),
},
body: JSON.stringify(params),
});
こうすることで、明示的にサーバー側にレスポンス形式を伝えることができ、
リクエストとレスポンスにの形式に互換性が生まれます。
Responseヘッダ
レスポンスヘッダが特に重要です。
Content-Typeにはエクセル形式のバイナリであることを明示的に伝えます。
これがあることで、ブラウザがファイル種別を確定できます。
'Content-Type':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
Content-Dispotionはコンテンツを受け取った側がどう扱うか、ということを示すヘッダです。
下記の2種類のオプションがあり、今回はダウンロードさせたいのでattachmentを使用します。
- inline:ブラウザが表示できるなら、そのまま画面に表示してOK
- attachment:表示できても ファイルとして保存(ダウンロード)させたい
'Content-Disposition':'attachment; filename="sales_list.xlsx"',
filenameに保存したいファイル名と形式を記載します。
こうするとブラウザは画面表示じゃなく、
sales_list.xlsxという名前で保存ダイアログを出すようになります。
最終このような感じです。
return new Response(buffer, {
headers: {
'Content-Type':'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition':'attachment; filename="sales_list.xlsx"',
},
});
型・DTO
OpenAPIは JSON Schemaベースなので、
JSONで表現できない型(バイナリ)は持てません。
type: string
format: binary
今回のAPIは同一エンドポイントでJSONかExcelを返すパターンなので、
「検索条件によってJSONとExcelのどちらかを返す」設計です。
そのためレスポンスは Content-Type ごとに定義します。
@ApiResponse({
status: 200,
description: '商品一覧 or Excel',
content: {
'application/json': {
schema: { $ref: getSchemaPath(ListResponseDto) },
},
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
schema: { type: 'string', format: 'binary' },
},
},
})
このcontentは Content-Typeに応じたレスポンスの分岐定義です。
OpenAPIを元に自動型生成を使って、フロントとバックエンドの型を揃える設計ですが、
Jsonで定義できないので、自動型生成の対象外です。
そのため、フロンではBinaryは下記のように定義します。
ListResponseDtoはOpenAPIから自動型生成したもの、Blobはフロント側でのみ使えるJS用の型です。
type ListApiResponse = ListResponseDto | Blob;
参考ドキュメント
ブラウザUI(標準機能をどう使うか)
ブラウザ標準のダウンロード機能を利用します。
下記のようなやつです。
window.location.href = "/api/excel"
window.href="api/excel"でブラウザにバイナリ用のAPIを叩かせるのが、ダウンロード機能をすべて任せられるので一番シンプルです。
キャンセルや停止、再ダウンロードなどストリーミングにしつつ状態管理をすべてブラウザに任せれます
ただ、この条件には制約があって、GETじゃないといけません。
今回の場合、POSTで検索条件を送る既存APIのロジックを利用してパラメータを加えて、実装する設計でした。
なのでその方法は諦めました。
PostでStreamingダウンロードできる方法を他にもあり、form形式にしてブラウザにFetchさせる方法です。
ですがこれにも制約があります。
- form送信になって Content-Type: application/x-www-form-urlencoded を要求され、既存APIとの併用が複雑になる
- PHP中継サーバーが絡んでストリーミング対応しないといけなく範囲が広い
- そもそもそこまで巨大データでもない
最終的に、下記の方法を取りました。
バイナリをfetch → Blob化 → メモリ上にURL生成 → aタグクリックでブラウザダウンロード
古くからある方法で、実装も簡単です。
Blobをfetch仕切ってからダウンロードするので、ユーザーからみると一瞬でダウンロードしたように見えるのが難点です。
しかし、ファイルサイズが小さいことと、制約の中での落とし所はここと考えました。
ダウンロード用関数
export function downloadBlob(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
}
利用側
const result = await listProducts(params);
if (result instanceof Blob) {
downloadBlob(result, 'sales_list.xlsx');
}
参考ドキュメント
ライブラリ選定
Excel.jsを利用しました。
Excelの生成処理は NestJS 側で行いました。
要件は 「DBから取得したデータをそのままExcelに変換し、スタイルやシート構成も制御したい」 というものです。
ExcelJSを採用した理由です。
- 行・列の追加、セル書式、シート分割などの柔軟性が高い
- 無料でスタイルも含めて制御できる
- 故に書き込みに強い
Node.js / NestJS での利用実績が多い SheetJS(xlsx)も検討しましたが、
- 読み取りに強いが、本件では読み取りはしない
- 細かいスタイルやリッチな機能は有料プラン依存になりがち
- 公式npmはメンテされず、非公式でCND経由の利用になる
という理由で ExcelJS を採用しました。
(※ここはプロジェクトの要件次第で変わると思います)
参考ドキュメント
タイムアウト
バイナリファイルは通信に時間がかかります。
インフラ側の調査と実測値で最大時間を推定する必要があります。
調査したところ、このような値で設定されていました。
CloudFront: 30 秒 (デフォルト)
ALB: 60 秒 (デフォルト)
ECS: 30秒 (Nodeの設定)
APIは最大期間を指定した場合、15秒程度でした。
つまりインフラの設定には依存しなさそうです。
まとめ
- Excelダウンロードは JSON APIと違い、レスポンスがバイナリになる
- OpenAPIはBlob型を表現できないため、フロント側で union(DTO | Blob)補正が必要
- Content-Typeごとに content.schema を分けるとSwaggerの定義が整理できる
- ダウンロードUIは ブラウザ標準機能を最大限使うのが楽&安全
- Excel生成は NestJS側でExcelJSを使ってDB結果を変換する方針が現実的
- タイムアウトの考慮は必要
ダウンロード機能は意外と考えることが多く、フロント・バックエンド・インフラと考慮する必要がありますが、すごく知識の深堀りができました。
課題としては、ストリーミング対応できてないので、そちら挑戦したいです。
