MENU

【DataTables】データをCSVダウンロードする方法

DataTablesの一覧にCSVダウンロードを付けるだけなら、Buttons拡張を使うのが一番早いです。
ただし、serverSide: trueでサーバー側処理にしている画面では、「画面に見えている分」と「CSVに出したい全量」がずれることがあります。

この記事では、Buttons拡張でCSVボタンを追加する基本形と、サーバー側処理を使っている場合に全量CSVを出す設計を整理します。
サーバー側で一度に全件CSVを作るとメモリ使用量が大きくなることもあるため、既存のページングAPIを繰り返し呼び出してクライアント側でCSVを組み立てる方法もあわせて扱います。

目次

公式ページ

Buttons拡張を使う

DataTables本体だけではCSVダウンロードボタンは使えません。
CSV、Excel、PDF、コピーなどの出力機能を使うには、Buttons拡張を読み込みます。

CDNで読み込む場合の例です。

<link rel="stylesheet" href="https://cdn.datatables.net/2.3.8/css/dataTables.dataTables.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/buttons/3.2.5/css/buttons.dataTables.min.css">

<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
<script src="https://cdn.datatables.net/buttons/3.2.5/js/dataTables.buttons.min.js"></script>
<script src="https://cdn.datatables.net/buttons/3.2.5/js/buttons.html5.min.js"></script>

Buttonsを使う場合は、DataTables本体に加えて以下が必要です。

  • Buttons本体
  • HTML5出力用のButtonsプラグイン
  • Buttons用のCSS

実際のバージョンや組み合わせは、DataTablesのDownload Builderで選ぶと間違いにくいです。

CSVボタンを追加する

DataTables 2系では、layoutオプションでボタンの表示位置を指定できます。

new DataTable('#usersTable', {
    layout: {
        topStart: {
            buttons: ['csvHtml5']
        }
    }
});

これだけで、DataTablesの上部にCSV出力ボタンが表示されます。

csvHtml5の代わりに、短い別名としてcsvも使えます。

new DataTable('#usersTable', {
    layout: {
        topStart: {
            buttons: ['csv']
        }
    }
});

DataTables 1.x系のサンプルでは、dom: 'Bfrtip'のような書き方もよく見かけます。
DataTables 2系でも互換のために使えますが、新しく書くならlayoutを使う方が分かりやすいです。

ボタン名やファイル名を変更する

ボタンの表示名やファイル名を変えたい場合は、ボタンをオブジェクトで指定します。

new DataTable('#usersTable', {
    layout: {
        topStart: {
            buttons: [
                {
                    extend: 'csvHtml5',
                    text: 'CSVダウンロード',
                    filename: 'users',
                    bom: true
                }
            ]
        }
    }
});

textはボタンに表示する文字、filenameはダウンロードするファイル名です。
bom: trueを指定すると、UTF-8 BOM付きのCSVになります。

Excelで日本語CSVを開く場合、環境によってはBOMがないと文字化けすることがあります。
そのため、日本語のCSVをExcelで開く想定がある場合は、bom: trueを付けておくと扱いやすいです。

出力する列を指定する

操作ボタン列やチェックボックス列など、CSVに出したくない列がある場合はexportOptions.columnsを使います。

new DataTable('#usersTable', {
    layout: {
        topStart: {
            buttons: [
                {
                    extend: 'csvHtml5',
                    text: 'CSVダウンロード',
                    bom: true,
                    exportOptions: {
                        columns: [0, 1, 2]
                    }
                }
            ]
        }
    }
});

表示中の列だけを出力したい場合は、:visibleも使えます。

new DataTable('#usersTable', {
    layout: {
        topStart: {
            buttons: [
                {
                    extend: 'csvHtml5',
                    text: '表示列をCSV出力',
                    exportOptions: {
                        columns: ':visible'
                    }
                }
            ]
        }
    }
});

画面に操作列がある場合は、以下のように操作列をCSV対象外にすることが多いです。

new DataTable('#usersTable', {
    columns: [
        { data: 'id', title: 'ID' },
        { data: 'name', title: '名前' },
        { data: 'email', title: 'メール' },
        {
            data: null,
            title: '操作',
            searchable: false,
            orderable: false,
            render: function () {
                return '<button type="button">詳細</button>';
            }
        }
    ],
    layout: {
        topStart: {
            buttons: [
                {
                    extend: 'csvHtml5',
                    text: 'CSVダウンロード',
                    exportOptions: {
                        columns: [0, 1, 2]
                    }
                }
            ]
        }
    }
});

検索結果だけ出力する

DataTablesのCSV出力は、検索や並び替えの状態を反映できます。
標準では、検索条件や並び替えが反映されたデータが出力対象になります。

検索条件を無視して、DataTablesが持っている全データを出力したい場合は、exportOptions.modifier.searchnoneにします。

new DataTable('#usersTable', {
    layout: {
        topStart: {
            buttons: [
                {
                    extend: 'csvHtml5',
                    text: '全データをCSV出力',
                    exportOptions: {
                        modifier: {
                            search: 'none'
                        }
                    }
                }
            ]
        }
    }
});

この方法は、DataTablesがブラウザ側に全データを持っている場合に有効です。
たとえば、HTMLのtbodyに全件がある場合や、Ajaxで全件を読み込んでいる場合です。

serverSideでは現在ページ分しか出ないことがある

serverSide: trueを使っている場合、DataTablesはページング、検索、並び替えのたびにサーバーへAjaxリクエストを送ります。
ブラウザ側には、基本的に現在表示に必要な分のデータだけがあります。

そのため、ButtonsのCSV出力をそのまま使うと、現在のページにあるデータだけがCSV出力されることがあります。

new DataTable('#usersTable', {
    processing: true,
    serverSide: true,
    ajax: '/api/users',
    layout: {
        topStart: {
            buttons: ['csvHtml5']
        }
    },
    columns: [
        { data: 'id', title: 'ID' },
        { data: 'name', title: '名前' },
        { data: 'email', title: 'メール' }
    ]
});

この状態でCSVボタンを押しても、DataTablesがブラウザ側に持っていない行は出力できません。
exportOptions.modifier.search: 'none'を付けても、サーバー上にしかない全件データをブラウザだけで出力することはできません。

serverSideで全量CSVを出したい場合

サーバー側処理を使っている画面で全量CSVを出したい場合は、CSV出力用の専用エンドポイントを作るのが安全です。

考え方は以下です。

  1. DataTablesの画面表示はserverSide: trueでページ単位に取得する
  2. CSVボタンはDataTablesの標準CSV出力ではなく、独自ボタンにする
  3. 現在の検索条件や並び替え条件をCSV用APIへ渡す
  4. サーバー側で条件に合う全件をCSVとして返す
  5. ブラウザはCSVファイルをダウンロードする

フロント側の例です。

const table = new DataTable('#usersTable', {
    processing: true,
    serverSide: true,
    ajax: {
        url: '/api/users/search',
        type: 'POST'
    },
    columns: [
        { data: 'id', title: 'ID', name: 'id' },
        { data: 'name', title: '名前', name: 'name' },
        { data: 'email', title: 'メール', name: 'email' },
        { data: 'createdAt', title: '登録日', name: 'created_at' }
    ],
    layout: {
        topStart: {
            buttons: [
                {
                    text: '全件CSVダウンロード',
                    action: function () {
                        downloadAllUsersCsv(table);
                    }
                }
            ]
        }
    }
});

CSVダウンロード関数の例です。

function downloadAllUsersCsv(table) {
    const params = table.ajax.params();
    const query = new URLSearchParams();

    query.set('search', params.search.value ?? '');

    if (params.order.length > 0) {
        const order = params.order[0];
        const column = params.columns[order.column];

        query.set('orderColumn', column.name);
        query.set('orderDir', order.dir);
    }

    window.location.href = `/api/users/csv?${query.toString()}`;
}

table.ajax.params()を使うと、DataTablesが直近のAjaxリクエストで送ったパラメータを取得できます。
そこから検索条件や並び替え条件を取り出し、CSV用APIへ渡します。

この例ではGETでCSVを取得しています。
検索条件が多い場合や、条件が長くなる場合はPOSTでCSV作成リクエストを送る設計にしてもよいです。

サーバー側CSV APIの考え方

CSV用APIでは、DataTablesのページング用APIとは違い、条件に一致する全件を対象にします。

Node.jsの疑似コードで書くと、以下のようなイメージです。

app.get('/api/users/csv', async (req, res) => {
    const search = String(req.query.search ?? '');
    const orderColumn = getAllowedOrderColumn(req.query.orderColumn);
    const orderDir = req.query.orderDir === 'desc' ? 'DESC' : 'ASC';

    const rows = await userRepository.findAllForCsv({
        search,
        orderColumn,
        orderDir
    });

    res.setHeader('Content-Type', 'text/csv; charset=utf-8');
    res.setHeader('Content-Disposition', 'attachment; filename="users.csv"');

    res.write('\uFEFF');
    res.write('ID,名前,メール,登録日\n');

    for (const row of rows) {
        res.write([
            csvEscape(row.id),
            csvEscape(row.name),
            csvEscape(row.email),
            csvEscape(row.createdAt)
        ].join(',') + '\n');
    }

    res.end();
});

CSVの1項目をエスケープする関数の例です。

function csvEscape(value) {
    const text = String(value ?? '');
    const escaped = text.replaceAll('"', '""');
    return `"${escaped}"`;
}

CSVでは、カンマ、改行、ダブルクォートを含む値に注意が必要です。
基本的には各値をダブルクォートで囲み、値の中のダブルクォートは""にします。

既存のページングAPIを使ってクライアント側で全件CSVを作る

CSV専用APIでサーバー側が全件を取得してCSVを作る方法は分かりやすいですが、対象件数が多い場合はサーバー側のメモリ使用量が大きくなることがあります。
特に、全件を配列に読み込んでからCSV文字列を作る実装にすると、データ件数や列数によってはメモリを圧迫します。

すでにDataTables用のページングAPIがあるなら、そのAPIをクライアント側からページごとに呼び出し、取得したデータを少しずつCSV行へ変換する方法もあります。
この方法ではサーバーは通常のページ取得処理を繰り返すだけなので、サーバー側で巨大なCSVを一度に組み立てる必要がありません。

考え方は以下です。

  1. 現在の検索条件や並び替え条件をtable.ajax.params()から取得する
  2. startlengthを指定して、既存のDataTables用APIをページごとに呼び出す
  3. レスポンスのdataをCSV行に変換して配列へ追加する
  4. recordsFilteredに達するまで繰り返す
  5. 最後にBlobを作ってCSVとしてダウンロードする

例として、既存の/api/users/searchがDataTablesのサーバー側処理形式でレスポンスを返す場合を考えます。
recordsFilteredには検索後の総件数、dataにはそのページ分のデータが入っている想定です。

async function downloadAllUsersCsvByPages(table) {
    const baseParams = table.ajax.params();
    const pageSize = 1000;
    const rows = [['ID', '名前', 'メール', '登録日']];

    let start = 0;
    let total = null;

    while (total === null || start < total) {
        const params = {
            ...baseParams,
            start,
            length: pageSize
        };

        const response = await fetch('/api/users/search', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-Token': csrfToken
            },
            body: JSON.stringify(params)
        });

        if (!response.ok) {
            alert('CSV用データの取得に失敗しました。');
            return;
        }

        const result = await response.json();
        total = result.recordsFiltered;

        for (const user of result.data) {
            rows.push([
                user.id,
                user.name,
                user.email,
                user.createdAt
            ]);
        }

        start += pageSize;
    }

    downloadCsvRows(rows, 'users.csv');
}

CSVとしてダウンロードする処理は以下のように分けておくと、他の一覧でも使い回しやすくなります。

function downloadCsvRows(rows, filename) {
    const csv = rows
        .map(row => row.map(csvEscape).join(','))
        .join('\n');

    const blob = new Blob(['\uFEFF', csv], {
        type: 'text/csv;charset=utf-8'
    });

    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');

    link.href = url;
    link.download = filename;
    link.click();

    URL.revokeObjectURL(url);
}

この方式は、サーバー側のメモリ使用量を抑えやすい一方で、ブラウザ側には最終的なCSVデータが集まります。
そのため、数十万件のような大きなデータでは、今度はブラウザ側のメモリや処理時間が問題になります。
あくまで「サーバー側で巨大CSVを一括生成したくないが、既存のページングAPIで現実的に取得できる件数」のための選択肢として考えるのがよいです。

実運用では、以下のような制御も入れておくと安全です。

  • CSV出力できる最大件数を決める
  • 取得中はボタンを無効化して二重実行を防ぐ
  • 進捗表示を出す
  • 1回のlengthを大きくしすぎない
  • API側でもlengthの上限を固定する
  • 権限チェックは各ページ取得APIでも必ず行う

件数が非常に多い場合や、CSV作成に時間がかかる場合は、サーバー側でストリーミング出力する、またはバックグラウンドジョブでCSVを作成して後からダウンロードさせる方式も検討します。
どの方式でも、画面表示用のページングAPIとCSV出力の要件を分けて考えることが重要です。

並び替えカラムはホワイトリストで制限する

CSV用APIでは、画面から送られてきたorderColumnをそのままSQLに入れないようにします。

const ORDER_COLUMNS = {
    id: 'id',
    name: 'name',
    email: 'email',
    created_at: 'created_at'
};

function getAllowedOrderColumn(value) {
    return ORDER_COLUMNS[value] ?? 'id';
}

DataTablesのcolumns.nameに、サーバー側で許可した名前を入れておくと扱いやすくなります。

columns: [
    { data: 'id', title: 'ID', name: 'id' },
    { data: 'name', title: '名前', name: 'name' },
    { data: 'email', title: 'メール', name: 'email' },
    { data: 'createdAt', title: '登録日', name: 'created_at' }
]

サーバー側では、受け取った値をホワイトリストに通してから使います。
SQLを組み立てる場合は、値の埋め込みにもプレースホルダやクエリビルダを使いましょう。

POSTでCSVを取得したい場合

検索条件が複雑な場合は、window.location.hrefではなくfetchでPOSTし、BlobとしてCSVをダウンロードする方法もあります。

async function downloadAllUsersCsv(table) {
    const params = table.ajax.params();

    const response = await fetch('/api/users/csv', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-Token': csrfToken
        },
        body: JSON.stringify({
            search: params.search.value,
            order: params.order,
            columns: params.columns
        })
    });

    if (!response.ok) {
        alert('CSVの作成に失敗しました。');
        return;
    }

    const blob = await response.blob();
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');

    link.href = url;
    link.download = 'users.csv';
    link.click();

    URL.revokeObjectURL(url);
}

POSTにする場合は、CSRF対策も忘れずに行います。
また、CSVの件数が非常に多い場合は、同期的にすぐ返すのではなく、バックグラウンドでCSVを作成して、完了後にダウンロードさせる設計も検討します。

画面表示用とCSV出力用の値を分ける

画面には「有効」「停止」のような表示用ラベルを出し、データ上はactivestoppedを持っている場合があります。

CSVにどちらを出したいかを決めておきます。

画面表示と同じ値をCSVに出す場合は、renderdisplay以外の値も意識します。

const STATUS_LABELS = {
    active: '有効',
    stopped: '停止'
};

new DataTable('#usersTable', {
    data: users,
    columns: [
        {
            data: 'status',
            title: 'ステータス',
            render: function (data, type) {
                if (type === 'display' || type === 'export') {
                    return STATUS_LABELS[data] ?? '不明';
                }

                return data;
            }
        }
    ],
    layout: {
        topStart: {
            buttons: [
                {
                    extend: 'csvHtml5',
                    text: 'CSVダウンロード',
                    exportOptions: {
                        orthogonal: 'export'
                    }
                }
            ]
        }
    }
});

exportOptions.orthogonalを使うと、CSV出力時にrenderへ渡す種類を指定できます。
表示用、検索用、ソート用、CSV出力用を分けたい場合に便利です。

CSV出力時の注意点

CSVダウンロードでは、以下にも注意します。

  • 日本語をExcelで開くならbom: trueを検討する
  • 操作列や非表示にしたい列はexportOptions.columnsで除外する
  • HTMLを含む列は、CSVに出したい値に整える
  • serverSide: trueではブラウザ側に全件がない
  • 全量CSVはサーバー側で作る方法と、ページごとに取得してクライアント側で作る方法がある
  • 大量CSVはサーバー側とブラウザ側の両方でタイムアウトやメモリ使用量に注意する
  • CSVをExcelで開く場合は、数式インジェクションにも注意する

ButtonsのCSV出力では、HTMLを取り除いたり、HTMLエンティティをデコードしたりするためのオプションがあります。
大量データをブラウザ側で出力する場合は、変換処理自体が重くなることもあります。

まとめ

全量をブラウザ側で読み込んでいる一覧なら、Buttons拡張のcsvHtml5でCSVダウンロードを付けられます。

  • CSV出力にはButtons拡張を使う
  • DataTables 2系ではlayoutbuttonsを配置する
  • 日本語CSVをExcelで開くならbom: trueを検討する
  • 出力対象列はexportOptions.columnsで制御する
  • 検索条件を無視して出力するならexportOptions.modifier.search: 'none'を使う
  • serverSide: trueではブラウザ側に現在ページ分しかないことがある
  • サーバー側処理で全量CSVを出すなら、CSV専用APIを作る方法がある
  • サーバー側のメモリが気になる場合は、既存のページングAPIを繰り返し呼び出してクライアント側でCSVを組み立てる方法もある
  • 検索条件や並び替え条件をCSV API、またはページングAPIへ渡し、画面と同じ条件でCSVを生成する

小規模な一覧ならButtonsのCSVボタンだけで十分です。
一方、大量データやserverSide: trueの画面では、サーバー側でCSVを作る、ページごとにクライアント側で集める、バックグラウンドで作る、といった選択肢をデータ量に応じて使い分けます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次