エンジニアを目指す初学者に向けて、わかりやすく解説したブログです。
サイトをリニューアルしました

ウェブサービスにおけるCSVダウンロード機能の実装方法

ゴール

この機能が実装できるようになる。

Image in a image block
id,name
1,hoge
2,fuga

やり方概要

  1. 特定のレスポンスヘッダーを設定したAPIエンドポイントを作成する
  2. APIエンドポイントを公開する
  3. ユーザーにはそのエンドポイントを踏ませるようにする

JavaScriptをつかってごちゃごちゃ実装するより、URLを踏ませてブラウザにDLを任せるほうが簡単で確実。

サンプル実装としてはRemix(React)、NestJSを用いるが、要点としては同様のAPIエンドポイントが作れればよい。

Image in a image block

NestJSのAPIはインターネットに公開しないため、RemixのプロキシAPIを介して配信する構成とする。

API実装のポイント

ポイントは3つ存在する。

  1. CSVデータのフォーマットと文字コードを指定する
  2. ブラウザで開くと自動ダウンロードするようにさせる
  3. キャッシュさせないようにする

①CSVデータのフォーマットと文字コードを指定する

レスポンスのContent-Typeヘッダーを用いて、CSVデータであることと、その文字コードを明確にする。

Content-Type: text/csv; charset=utf-8

②ブラウザで開くと自動ダウンロードするようにさせる

レスポンスのContent-Dispositionヘッダーを用いて、
ブラウザがコンテンツを受け取った後にどのように処理すべきかを伝える。

Content-Disposition: attachment; filename="sample.csv"
  • attachmentを指定:ブラウザにダウンロードすべきであることを伝える
  • filename="sample.csv" を指定:ダウンロード時のファイル名を指定する

③キャッシュさせないようにする

CSVダウンロードさせたいようなデータは、基本的にはキャッシュさせる必要がない上に
不用意にキャッシュしてしまうと思わぬバグの原因になるため、キャッシュさせないことを意識しておく。

Cache-Control: no-store

no-storeno-cacheでよく混乱するが、ざっくり以下のような違いで覚えておくと良い。

  • no-storeを指定:絶対にキャッシュしない。
  • no-cacheを指定:キャッシュする。ただしキャッシュを使っていいか都度確認する。

API側(NestJS)の実装

はじめに

以下のデータをCSV化してDLさせるものとする。

class UserCsv {
  constructor(
    public id: number,
    public name: string,
  ) {}
}

①CSVに関するライブラリインストール

オブジェクトをCSV形式に変換できるようなライブラリをインストールする。
なんでもいいが、今回は fast-csvを採用した。

# 今回はCSVの生成のみ利用するので、format機能のみ利用
$ yarn add @fast-csv/format

②Controllerを実装

以下のようにNestJSのControllerを実装する。

import { Controller, Get, Header, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiProduces } from '@nestjs/swagger';
import * as csv from '@fast-csv/format';

@Controller()
export class AppController {
  // GET /download でアクセスされるエンドポイント
  @Get('download')
  @ApiProduces('text/csv; charset=utf-8') // swagger用なので、APIの機能には関係ない
  @ApiOperation({ summary: 'CSVデータをダウンロードする' }) // swagger用なので、APIの機能には関係ない
  @Header('Cache-Control', 'no-store') // ポイント③: 受け取り側がCSVデータをキャッシュしないように設定
  // swagger用なので、APIの機能には関係ない
  @ApiOkResponse({
    description: 'success',
    type: UserCsv,
    example: 'id,name\n1,hoge\n2,fuga',
  })
  async downLoadUsers(): Promise<StreamableFile> {
    // サンプルデータを生成
    const users = [new UserCsv(1, 'hoge'), new UserCsv(2, 'fuga')];

    // fast-csvの機能を用いて、CSVデータのバッファに変換
    const csvBuffer = await csv.writeToBuffer(users, { headers: true });

    // StreamableFileオブジェクトとして返却する
    return new StreamableFile(csvBuffer, {
      // ポイント①: データのタイプを指定
      type: 'text/csv',
      // ポイント②: ブラウザに「sample.csv」というファイル名でダウンロードさせるように伝える
      disposition: 'attachment; filename="sample.csv"',
    });
  }
}

これでNestJS側の実装は終わり。

動作確認

APIに対してリクエストして動作確認をする。

  • レスポンスヘッダーが指定されていること
    • Cache-ControlContent-TypeContent-Disposition
  • レスポンスボディでデータが確認できること
$ curl -XGET "http://localhost:3020/download" -i
HTTP/1.1 200 OK
X-Powered-By: Express
Cache-Control: no-store
Content-Type: text/csv
Content-Disposition: attachment; filename="sample.csv"
Content-Length: 21
Date: Sun, 19 Jan 2025 02:26:30 GMT
Connection: keep-alive
Keep-Alive: timeout=5

id,name
1,hoge
2,fuga

フロントエンド側(Remix)の実装

フロントエンド側では、NestJSのプロキシエンドポイントを作成し、ボタンを押したらそのURLを踏む実装をする。

①プロキシエンドポイントを作成

RemixでAPIルートを作成するには、 loader関数のみを定義したファイルを作成すれば良い。

app/routes/api.downloadCsv.tsを作成する。
これで /api/downloadCsv のパスをブラウザが踏むとダウンロードが始まるようになる。

export const loader = async (): Promise<Response> => {
  // NestJSのレスポンスをそのまま返却する
  // docker composeを利用しているためホスト名がbeになっているが、NestJSに疎通できるURLを設定すれば良い
  return fetch('http://be:3020/download');
};

プロキシするだけなので、NestJSに対してリクエストを行うfetch関数をそのままreturnすれば良い。

動作確認

NestJSにリクエストしたときと同じレスポンスヘッダーとレスポンスボディであることが確認できる。

$ curl -XGET "http://localhost:3010/api/downloadCsv" -i
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
cache-control: no-store
connection: keep-alive
content-disposition: attachment; filename="sample.csv"
content-length: 21
content-type: text/csv
date: Sun, 19 Jan 2025 02:35:00 GMT
keep-alive: timeout=5
x-powered-by: Express

id,name
1,hoge
2,fuga

②ボタンを作成

Reactコンポーネントにaタグを配置すれば良い

app/root.tsx

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {/* 実装はRemixのプロキシAPIのURLを指定するだけ */}
        <a href="/api/downloadCsv">CSVダウンロード</a>
      </body>
    </html>
  );
}

動作確認

リンク(リンクに見えないが)を押すとダウンロードが始まる。

Image in a image block

まとめ

  • APIのレスポンスヘッダーを3つ指定する
  • フロントエンドからはURL踏ませるだけ、煩わしい実装は不要