はじめに
個人開発で『LeapRows』というブラウザ完結のCSV分析ツールを作っています。内部ではDuckDB-WASMを使っており、すべてブラウザ内で完結するツールです。
開発を進めるなかでDuckDBのテーブルをXLSX形式で出力する機能が必要になったのですが、既存の方法ではどれも中規模以上のデータに耐えられませんでした。
最終的に「DuckDB SQLでExcel用のXMLを直接生成し、JSZipで圧縮してXLSXファイルを組み立てる」というアプローチで解決できたので、その経緯と仕組みを紹介します。
※普段はSEOマーケターとして働いており、エンジニアではないので用語の使い方が不正確な部分があるかもしれません🙏
課題:ブラウザで100万行のXLSXを出力したい
DuckDB-WASMのXLSX出力は不安定だった
まず、DuckDB-WASM組み込みのXLSX出力(Excel拡張のCOPY TO)を試しましたが、出力が不安定で、Excelで開くと「ファイル形式またはファイル拡張子が正しくありません」というエラーになり、ファイルを開けないケースがありました。
そのためプロダクションでは避けたほうが良さそうと判断し、代替案を探し始めます。
JSライブラリはメモリが厳しい
次にSheetJSやExcelJSなどのJSライブラリを試しました。
小さなデータであれば問題なく処理できますが、データ規模が大きくなるとメモリ消費が膨大になり、変換自体が遅い…。
実際に100万行のデータで試したところ、進捗が95%まで進んだあとに20秒近くブラウザがフリーズしてしまう現象が起きました。さらに変換完了後もブラウザが重たい状態が続いたり、ひどいときはそのままクラッシュしてしまうこともありました。
それでも100万行を出力したい
しかし、LeapRowsでは「100万行超でも高速に処理できる」ということをアピールポイントにしています。なんとしてでもExcelの限界である104万行でも高速に出力させたいと考えました。
XLSXの実体はZIP + XML
普段Excelで開いているXLSXの正体は、実はZIPファイルで、中にはXMLが格納されています。ご存知の方も多いかと思いますが、拡張子を.zipに変えるとそのまま中身が見えます。
これらのXMLにはExcelで表示する際に使われる装飾情報(フォント、色、罫線、条件付き書式など)が大量に含まれています。
しかし、データ本体はxl/worksheets/sheet1.xmlです。
私のツールはDuckDBのテーブルをXLSXで出力したいだけなので、装飾情報は一切不要。動的に生成が必要なのはsheet1.xmlだけで、残りの4〜5ファイルは固定文字列で済みます。
メインデータである最低限のsheet1.xmlだけ用意することができれば、あとはなんとかなりそうだと考えました。
DuckDB SQLでXMLを直接生成する
元データはすでにDuckDBのテーブルとして持っています。そしてDuckDB SQLはCONCATやREPLACEなど文字列操作をとても高速に処理できます。
それならば、「SQLクエリの結果としてXMLセル文字列を出力し、それをJSZipでZIP圧縮すればXLSXになるのでは?」と考えました。
Before
DuckDB → JSオブジェクト → ライブラリ → XML → ZIP
After
DuckDB → XML文字列(SQL) → JSZip → ZIP
これならJSオブジェクトの中間生成をスキップできるので、ブラウザのメモリ圧迫も、ガベッジコレクション(メモリの自動解放処理)で重たくなることも避けられそうです。
具体的には、カラムの型ごとに適切なExcel表現を出し分けるSQL CASE式を作成して、DuckDBのパワーに頼って力技で書き出すという流れです。数値はそのまま、日付はExcelのシリアル値に変換、文字列はXMLエスケープ付きで出力します。
■実際のSQLイメージ
SELECT
'<row r="' || CAST(rn + 1 AS VARCHAR) || '">'
-- name列(文字列): XMLエスケープしてinline stringとして出力
|| CASE WHEN "name" IS NOT NULL
THEN '<c r="A' || CAST(rn + 1 AS VARCHAR) || '" t="inlineStr"><is><t>'
|| REPLACE(REPLACE(REPLACE("name", '&', '&'), '<', '<'), '>', '>')
|| '</t></is></c>'
ELSE '' END
-- age列(数値): そのまま数値として出力
|| CASE WHEN "age" IS NOT NULL
THEN '<c r="B' || CAST(rn + 1 AS VARCHAR) || '"><v>'
|| CAST("age" AS VARCHAR)
|| '</v></c>'
ELSE '' END
-- created_at列(日付): 1899-12-30からの日数に変換(Excelのシリアル値)
|| CASE WHEN "created_at" IS NOT NULL
THEN '<c r="C' || CAST(rn + 1 AS VARCHAR) || '" s="1"><v>'
|| CAST(date_diff('day', DATE '1899-12-30', "created_at") AS VARCHAR)
|| '</v></c>'
ELSE '' END
|| '</row>' AS xml_row
FROM (
SELECT *, ROW_NUMBER() OVER () AS rn
FROM my_table
LIMIT 50000 OFFSET 0
)
このSQLを実行すると、1行につき1つのXML 要素が返ってきます。
<row r="2"><c r="A2" t="inlineStr"><is><t>田中太郎</t></is></c><c r="B2"><v>30</v></c><c r="C2" s="1"><v>45302</v></c></row>
<row r="3"><c r="A3" t="inlineStr"><is><t>鈴木花子</t></is></c><c r="B3"><v>25</v></c><c r="C3" s="1"><v>45150</v></c></row>
あとはこれをヘッダー行や固定XMLと合わせてJSZipで圧縮すれば、XLSXファイルの完成です。
結果
ExcelJSで処理していたときは、100万行規模のXLSX書き出しに20秒前後かかり、メモリも1GB以上使ってしまっていました(Beforeのスクショ撮り忘れ……)。
このXML書き出しの仕組みを導入すると、変換速度もXML書き出しで5.5s、ZIP圧縮で7.3sと変換速度は半分近くになり、なによりもメモリ消費量がかなり削減。ブラウザのフリーズも回避できました。
※本当はZIP圧縮もWASM側で行えばもっと早くなるのだと思うのですが、大変そうなので今回はスコープ外にしました![]()
GC対象のオブジェクトが減ったことで、変換後にブラウザが重たくなってしまう問題も解消されています。
トレードオフとして、セルの色や罫線などExcel固有の装飾には対応していません。ただ、私のツールはデータ出力だけの用途なので問題なしとしています。
まとめ
「XLSXは複雑なフォーマット」という先入観を捨てると、データの書き出しだけなら最小限のXMLで十分に行えることがわかりました。
DuckDBは処理が本当に高速なので、今回のようなXLSXファイル出力(というかSQLでのXML生成)以外にも、いろいろと応用が利きそうな気がしています。


