概要
フロントnext.js
× API Laravel
の構成で作成しているアプリケーションにおいて、
「フロント側でとあるボタンをクリックしたら、API側で生成したCSVファイルをダウンロードする」
という機能を業務で実装する機会があったので、実装方法や勉強になったことをまとめておきます。
全体の流れは以下の通りです。
1. フロント側からAxios
を使ってAPIへリクエストを飛ばす。
2. API側(Laravel
)でCSVファイルを生成し、レスポンスとして返す。
3. フロント側でレスポンスを受け取り、CSVファイルをダウンロードする。
API側の実装
API側から実装していきます。
※本来はサービスクラスなどへ処理を切り分けるべきではありますが、ここではコントローラーに全ての処理を書くものとします。
ルーティング
フロント側(Next.js
)からAxios
で送られてくるリクエストに対するルーティングを定義します。
// routes/api.php
Route::get('/download/csv', [DownloadCsvController::class, 'downloadCsv']);
ここで定義した通り、DownloadCsvController
のdownloadCsv
メソッドにCSVファイルを生成してレスポンスとして返す処理をかいていきます。
コントローラー
コードの概要
先に概要だけざっくり説明すると、以下の通りです。
-
Laravelの
streamDownload
メソッドを使って、CSVファイルをレスポンスとして返す。 -
streamDownload
メソッドは引数を3つ取るので、各引数を用意する。
・第1引数:エクスポートするファイルを生成するコールバック関数
・第2引数:ファイル名
・第3引数:レスポンスヘッダーの配列
※streamDownload
メソッドについてはReadoubleの以下の箇所を参照ください。
コードの全文
詳しい解説は後にして、コードの全文を載せます。
// DownloadCsvController.php
/**
* CSVダウンロード
* @return StreamedResponse
*/
public function downloadCsv(): StreamedResponse
{
// CSVファイル作成コールバック
$downloadCsvCallback = function () {
// CSVファイル作成
$csv = fopen('php://output', 'w');
// CSVの1行目
$colums = [
'id' => 'ユーザーID',
'name' => 'ユーザー名',
'email' => 'メールアドレス'
];
// 文字化け対策
mb_convert_variables('SJIS-win', 'UTF-8', $colums);
// CSVの1行目を記入
fputcsv($csv, $colums);
// CSVの2行目以降
$users = User::all();
foreach ($users as $user) {
$userData = [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email
];
// 文字化け対策
mb_convert_variables('SJIS-win', 'UTF-8', $userData );
// CSVファイルの2行目以降にユーザー情報を記入
fputcsv($csv, $userData);
}
// CSVファイルを閉じる
fclose($csv);
}
// ファイル名
$fileName = 'ユーザー情報.csv';
// レスポンスヘッダー情報
$responseHeader = [
'Content-type' => 'text/csv',
'Access-Control-Expose-Headers' => 'Content-Disposition'
],
return response()->streamDownload($downloadCsvCallback, $fileName, $responseHeader);
}
ここから上記のコードのポイントを解説していきます。
steramDownloadメソッド
- 引数と返り値について
先述の通り、 steramDownload
メソッドは以下の3つの引数を取り、返り値は Symfony\Component\HttpFoundation\StreamedResponse
となります。
・第1引数:エクスポートするファイルを生成するコールバック関数
・第2引数:ファイル名
・第3引数:レスポンスヘッダーの配列
今回の構成では、フロント側(Next.js
)からAPIリクエストを受け取ってStreamedResponse
の形式でレスポンスを返すことになります。
- レスポンスヘッダーについて
HTTPレスポンスの際に、ブラウザで表示するのではなくファイルをブラウザでダウンロードさせたい場合は、レスポンスヘッダーにContent-Disposition
を持たせる必要があります。
Content-Disposition - HTTP | MDN
// レスポンスヘッダーに以下の記述が必要
Content-Disposition: attachment
// ファイル名を指定する場合
Content-Disposition: attachment; filename="filename.jpg"
// ファイル名をエンコードする場合
Content-Disposition: attachment; filename*=UTF-8''URLエンコーディングされたファイル名
そこでstreamDownload
メソッドは、このContent-Disposition
をよしなにレスポンスヘッダーに加えてくれます。
以下の画像は、第2引数に'ファイル.csv'
と指定してレスポンスを返した際のレスポンスヘッダーを、検証ツールで確認したものです。
ファイル名に日本語を指定すると、URLエンコーディングされた状態でレスポンスヘッダーに付与されています。
CSVファイル作成コールバック
- CSVファイルの作成
コールバック関数内で行う処理の大まかな流れは以下の通りで、
CSVファイルを作成→中身を書き込む→ファイルを閉じる
というものです。
// ファイルを作成する
fopen('php://output', 'w');
// CSVの1行目のカラムを記入する
fputcsv($csv, $colums);
// CSVの2行目以降に企業情報を記入する
fputcsv($csv, $userData);
// ファイルを閉じる
fclose($csv);
- 文字化け対策について
デフォルトの文字コードは、
・Excel:SJIS
・PHP:UTF-8
(php.ini
で設定されている)
という違いがあります。
// php.ini
default_charset = "UTF-8"
なのでPHPの文字コードがUTF-8
のままCSVファイルを生成すると、Excelで開いたときに文字化けしてしまいます。
これを防ぐため、PHPのmb_convert_variables
関数を使って文字コードをUTF-8
からSJIS-win
に変換します。
PHPドキュメント:mb_convert_variables
mb_convert_variables('SJIS-win', 'UTF-8', $colums);
※SJIS-win
はWindows向けに使われるShift-JIS
で、通常のSJIS
よりも対応している文字が多いようです。
レスポンスヘッダーについて
先述の通り、streamDownload
メソッドがレスポンスヘッダーにContent-Disposition
を自動で含めてくれます。
レスポンスヘッダーの要素はフロント側で AxiosResponse
から受け取ることが出来るので、後ほどフロント側でレスポンスヘッダーからファイル名を取得することになります。
但し今回のような CORS
の通信の場合、標準的なレスポンスヘッダー以外の場合はAPIから返すレスポンスヘッダーに
Access-Control-Expose-Headers: {レスポンスヘッダ名}
を追加しておかないと、AxiosResponse
からレスポンス情報を受け取ることが出来ないので、Content-Disposition
については上記の記述が必要です。
Access-Control-Expose-Headers - HTTP | MDN
参考記事:Axiosでレスポンスヘッダが取得できなかった (CORSなAPI)
よってstreamDownload
メソッドの第3引数には、以下のレスポンスヘッダー情報を渡します。
// レスポンスヘッダー情報
$responseHeader = [
'Content-type' => 'text/csv',
'Access-Control-Expose-Headers' => 'Content-Disposition'
],
API(Laravel
)側の実装は以上です。
ここまでの実装で、直接APIのURLをブラウザのURLに入れてアクセスすればCSVファイルをダウンロードできるはずです。
ただしフロント側にレスポンスとして返すだけではフロント側ではファイルダウンロードは行われないので、続けてフロント側も実装していきます。
フロント側の実装
フロント側の実装方法は2通り紹介させていただきたいと思います。
実装方法①
file-saver
というライブラリを使ってファイル保存を行います。
ライブラリ:file-saver
このライブラリの saveAs
メソッドに、
・第1引数: blob
オブジェクト
・第2引数:ファイル名
を渡すだけで、指定したファイル名でファイルを保存することができます。
実装したコードは以下の通りです。
import { saveAs } from 'file-saver'
// 中略
axiosApi
.get('/download/csv', {
responseType: 'blob',
})
.then((res: AxiosResponse) => {
const blob = new Blob([res.data], { type: res.data.type })
const fileName = getFileName(res.headers['content-disposition'])
saveAs(blob, fileName)
})
const getFileName = (contentDisposition: string) => {
return decodeURI(contentDisposition).substring(
contentDisposition.indexOf("''") + 2,
contentDisposition.length,
)
}
ポイント
responseType: 'blob'
この指定をしないと文字化けしてしまいます。
- ファイル名の取得
レスポンスヘッダーの情報は、AxiosResponse
の res
から、
res.headers['content-disposition']
のように書くと取り出すことができます。
レスポンスヘッダーのContent-Disposition
をフロント側で受け取り、出力してみると、以下のようにエンコードされた状態です。
axiosApi
.get(`/download/csv/${entryType}`, {
responseType: 'blob',
})
.then((res: AxiosResponse) => {
console.log(res.headers['content-disposition']) // 出力する
const blob = new Blob([res.data], { type: res.data.type })
const fileName = getFileName(res.headers['content-disposition'])
saveAs(blob, fileName)
})
出力結果
attachment; filename=___20220301173602.csv; filename*=utf-8''%E9%A1%A7%E5%AE%A2%E6%83%85%E5%A0%B1_%E3%82%82%E3%81%AE%E3%81%A5%E3%81%8F%E3%82%8A%E8%A3%.csv
なので上記の状態からデコードを行い、さらにfilename*=utf-8''
以降のファイル名の部分のみを切り取ります。
const getFileName = (contentDisposition: string) => {
return decodeURI(contentDisposition).substring(
contentDisposition.indexOf("''") + 2,
contentDisposition.length,
)
}
実装方法②
HTML
の a
タグにdownload
属性を設定すると、 a
タグをクリックした場合、ブラウザはユーザーをそのURLへ遷移させるのではなくそのコンテンツを保存させます。
: アンカー要素 - HTML: HyperText Markup Language | MDN
この機能を使ってファイル保存を行います。
axiosApi
.get('/download/csv', {
responseType: 'blob',
})
.then((res: AxiosResponse) => {
// Blobを参照するための一時的なURLを生成
const url = window.URL.createObjectURL(new Blob([res.data]))
// HTML要素のaタグを生成
const link = document.createElement('a')
link.href = url
// aタグのdownload属性を設定
link.setAttribute('download', `顧客情報_ものづくり補助金_全件_${getTimestamp()}.csv`)
// 生成したaタグを設置し、クリックさせる
document.body.appendChild(link)
link.click()
// URLを削除
window.URL.revokeObjectURL(url)
}
さいごに
自分で実装して検証ツールで確認しながら進めることで、レスポンスヘッダーの働きやレスポンスヘッダーが違えば挙動も変わることを実感できたので良かったと思います。
またLaravelの steramDownload
メソッドがレスポンスヘッダーをよしなに生成してくれていることに気付いたとき、フレームワークってすごい…と感動してしまいました!
参考記事