【Laravel × Next.js】Axiosを使ってCSVファイルをダウンロードする

## 概要
フロント`next.js` × API `Laravel` の構成で作成しているアプリケーションにおいて、
「フロント側でとあるボタンをクリックしたら、API側で生成した**CSVファイル**をダウンロードする」
という機能を業務で実装する機会があったので、実装方法や勉強になったことをまとめておきます。

全体の流れは以下の通りです。

1.　フロント側から`Axios`を使ってAPIへリクエストを飛ばす。
2.　API側（`Laravel`）でCSVファイルを生成し、レスポンスとして返す。
3.　フロント側でレスポンスを受け取り、CSVファイルをダウンロードする。

# API側の実装
API側から実装していきます。
※本来はサービスクラスなどへ処理を切り分けるべきではありますが、ここではコントローラーに全ての処理を書くものとします。

## ルーティング
フロント側（`Next.js`）から`Axios`で送られてくるリクエストに対するルーティングを定義します。

```php
// routes/api.php

Route::get('/download/csv', [DownloadCsvController::class, 'downloadCsv']);
```

ここで定義した通り、`DownloadCsvController`の`downloadCsv`メソッドにCSVファイルを生成してレスポンスとして返す処理をかいていきます。


## コントローラー
### コードの概要
先に概要だけざっくり説明すると、以下の通りです。

-  Laravelの`streamDownload`メソッドを使って、CSVファイルをレスポンスとして返す。

-  `streamDownload`メソッドは引数を3つ取るので、各引数を用意する。
　・第1引数：エクスポートするファイルを生成するコールバック関数
　・第2引数：ファイル名
　・第3引数：レスポンスヘッダーの配列

※`streamDownload`メソッドについてはReadoubleの以下の箇所を参照ください。

https://readouble.com/laravel/8.x/ja/responses.html?header=%25E3%2582%25B9%25E3%2583%2588%25E3%2583%25AA%25E3%2583%25BC%25E3%2583%25A0%25E3%2583%2580%25E3%2582%25A6%25E3%2583%25B3%25E3%2583%25AD%25E3%2583%25BC%25E3%2583%2589#:~:text=%E3%81%97%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99%E3%80%82-,%E3%82%B9%E3%83%88%E3%83%AA%E3%83%BC%E3%83%A0%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89,-%E7%89%B9%E5%AE%9A%E3%81%AE%E6%93%8D%E4%BD%9C

### コードの全文
詳しい解説は後にして、コードの全文を載せます。

```php
// 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](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Disposition#:~:text=Content%2DDisposition%3A%20attachment%3B%20filename%3D%22filename.jpg%22)

```
// レスポンスヘッダーに以下の記述が必要
Content-Disposition: attachment

// ファイル名を指定する場合
Content-Disposition: attachment; filename="filename.jpg"

// ファイル名をエンコードする場合
Content-Disposition: attachment; filename*=UTF-8''URLエンコーディングされたファイル名
```

そこで`streamDownload`メソッドは、この`Content-Disposition`をよしなにレスポンスヘッダーに加えてくれます。

以下の画像は、第2引数に`'ファイル.csv'`と指定してレスポンスを返した際のレスポンスヘッダーを、検証ツールで確認したものです。
![レスポンスヘッダー.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1208065/7372725d-21ba-5650-6cf2-a37ed297bc11.jpeg)
ファイル名に日本語を指定すると、URLエンコーディングされた状態でレスポンスヘッダーに付与されています。

### CSVファイル作成コールバック

- **CSVファイルの作成**

コールバック関数内で行う処理の大まかな流れは以下の通りで、
`CSVファイルを作成→中身を書き込む→ファイルを閉じる`
というものです。

```php
// ファイルを作成する
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](https://www.php.net/manual/ja/function.mb-convert-variables.php)

```php
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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers)

参考記事：[Axiosでレスポンスヘッダが取得できなかった (CORSなAPI)](https://note.kiriukun.com/entry/20200303-axios-response-header-could-not-get)

よって`streamDownload`メソッドの第3引数には、以下のレスポンスヘッダー情報を渡します。

```php
// レスポンスヘッダー情報
$responseHeader = [
  'Content-type' => 'text/csv',
  'Access-Control-Expose-Headers' => 'Content-Disposition' 
],
```

API（`Laravel`）側の実装は以上です。

ここまでの実装で、直接APIのURLをブラウザのURLに入れてアクセスすればCSVファイルをダウンロードできるはずです。
ただしフロント側にレスポンスとして返すだけではフロント側ではファイルダウンロードは行われないので、続けてフロント側も実装していきます。

# フロント側の実装
フロント側の実装方法は2通り紹介させていただきたいと思います。

## 実装方法①
`file-saver` というライブラリを使ってファイル保存を行います。
ライブラリ：[file-saver](https://www.npmjs.com/package/file-saver)

このライブラリの `saveAs` メソッドに、

・第1引数： `blob` オブジェクト
・第2引数：ファイル名

を渡すだけで、指定したファイル名でファイルを保存することができます。

実装したコードは以下の通りです。

```jsx
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` から、

```jsx
res.headers['content-disposition']
```

のように書くと取り出すことができます。

レスポンスヘッダーの`Content-Disposition`をフロント側で受け取り、出力してみると、以下のようにエンコードされた状態です。

```jsx
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''`以降のファイル名の部分のみを切り取ります。

```jsx
const getFileName = (contentDisposition: string) => {
  return decodeURI(contentDisposition).substring(
    contentDisposition.indexOf("''") + 2,
    contentDisposition.length,
  )
}
```

## 実装方法②
`HTML` の `a` タグに`download` 属性を設定すると、 `a` タグをクリックした場合、ブラウザはユーザーをそのURLへ遷移させるのではなくそのコンテンツを保存させます。
[: アンカー要素 - HTML: HyperText Markup Language | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Element/a#:~:text=%E3%81%8C%E3%81%82%E3%82%8A%E3%81%BE%E3%81%99%E3%80%82-,download,HTML5,-%E3%83%AA%E3%83%B3%E3%82%AF%E3%81%95%E3%82%8C)

この機能を使ってファイル保存を行います。

```jsx
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`メソッドがレスポンスヘッダーをよしなに生成してくれていることに気付いたとき、フレームワークってすごい…と感動してしまいました！


## 参考記事

https://coinbaby8.com/php-csv-export.html

https://www.wakuwakubank.com/posts/799-it-content-type-content-disposition/

https://qiita.com/koushisa/items/ac908d81361534264d35

https://jpdebug.com/p/978039

https://javascript.keicode.com/newjs/download-files.php#1-1

https://tkkm.tokyo/post-177/

https://helloworld-blog.tech/javascript/axios%E3%81%8B%E3%82%89csv%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E3%83%80%E3%82%A6%E3%83%B3%E3%83%AD%E3%83%BC%E3%83%89%E3%81%95%E3%81%9B%E3%82%8B%E6%96%B9%E6%B3%95