GROWTH VERSE TECH BLOG

株式会社GROWTH VERSEのテックブログです。

GolangのExcelizeでStreamWriterを使用したが、SetCellValueに変更した話

はじめに

株式会社GROWTH VERSEにてバックエンドエンジニアをしている川田です。この記事では、業務によって得たStreamWriterに関する学びを共有しようと思います。業務で以下のレポート機能を開発しました。

  • ユーザーが作成したエクセルのテンプレートファイルをアップロードできる
  • DBに対してクエリを実行してデータを取得し、取得したデータの内容をエクセルテンプレートファイルに書き込む。書き込み後、ファイルはレポートとして保存される

書き込み時の要件は以下の内容です。

  • 書き込みしようとしているセルに既存のデータがある場合は、上書きすること
  • エクセルテンプレートの内容は上書きを除き、処理後も保持されていなければならない

この要件を満たすために、ExcelizeのStreamWriterというストリーム処理を実施するための構造体を使った実装を試みましたが、最終的にSetCellValueという非ストリーム処理による特定cell内データの更新をするためのメソッドを使用する方針に変更することにしました。この記事では、その経緯を共有します。

一般にストリーム処理で求められること

ストリーム処理では、以下の点が一般的に求められます

  1. メモリ効率: 大量のデータを一度に処理するのでなく、逐次的に処理することでメモリ使用量を抑える。
  2. スループット: データを高速に処理できるよう、スムーズな流れを維持すること。
  3. 柔軟性: データの読み書きのタイミングや方法を調整可能であること。

求められることに対しての各ライブラリでの対応状況

弊社ではGoを使用しています。Goの標準ライブラリではエクセル操作が可能なライブラリがないため、対応するにはOSSを選定する必要がありました。選択肢としてExcelizeとxlsxがあり、それぞれの特徴を次のように整理しました。

Excelizeの特長

  • フルストリーム機能があり、書き込みのタイミングを柔軟に調整できる
  • エクセル関連の操作が豊富。画像の挿入やチャートの編集など、幅広い機能を提供している
  • スター数がxlsxと比較して多い

短所

  • 学習コストがxlsxと比べて高い

xlsxの特長

  • 限定的ながらストリーム機能がある
  • 1行ごとの書き込みが可能で、直感的に使える
  • 学習コストが低く、シンプルに実装できる

短所

  • パフォーマンス問題が発生した場合のチューニングが難しい
  • より細かいエクセル操作が必要になった際には対応できない

将来的なパフォーマンス問題や細かいエクセル操作の要件を考慮しExcelizeを選択しました。一度のレポート作成時のデータ量は数万から数十万件に及び、同時処理が発生する可能性もあるため、メモリ圧迫を防ぐためにStreamWriterで実装する方針を決めました。StreamWriterなら書き込み処理の頻度を調整でき、パフォーマンス最適化の余地も広がります。

今回の開発において求められている要件

今回の開発で求められている書き込み時の要件は以下の通りです

  1. 記載されたセルへの適切な上書き: 書き込み処理を行う際、既存のデータがあるセルには新しいデータで上書きすること。
  2. テンプレート保持: エクセルテンプレートの他の部分は保たれ、変更が加えられないようにする必要があること。
  3. メモリ効率の考慮: 大規模なデータセットを扱うため、ストリーム処理を用いることでメモリの使用を最小限に抑えること。
  4. 操作のスムーズさ: 書き込み操作がエラーなどで中断されないよう設計すること。
  5. パフォーマンス: 大量のデータに対しても迅速かつ効率的に処理を行うこと。

これらの要件を満たすべく、初期段階ではExcelizeのStreamWriterを試みましたが、特定の制約によりSetCellValueの使用に切り替えることとしました。

実装して発生した問題

エクセルテンプレートにクエリで取得したデータを書き込む際、次のエラーに直面しました。

stream duplicate row error: row 1 already exists

これは、StreamWriterで既存のデータがある行に書き込みをしようとすると発生するエラーです。一方、SetCellValueでは指定したセルにデータが存在していても上書きが可能です。例えば、次のように簡単に値を設定できます。

f := excelize.NewFile()

// A1セルに値を設定
f.SetCellValue("Sheet1", "A1", 123)

要件を満たすためにはSetCellValueの方が適していると判断し実装を修正しました。ただし、今後メモリ問題が生じる可能性があるため、その場合にはStreamWriterとSetCellValueの併用を検討します。その際の処理の想定フローは以下の内容です。

  1. 新規エクセルファイルを生成し、StreamWriterでデータを書き込む
  2. 書き込み完了後、SetCellValueを使ってエクセルテンプレートの内容を書き込む。既にデータがあるセルはスキップし、ない場合は書き込む

なぜStreamWriterは上書きに対応していないのか

なぜStreamWriterは上書きに対応していないのか、その理由はStreamWriterが逐次的な処理を前提とした設計であり、書き込みをする際の行の順序が昇順であることを保証するためです。

具体的にExcelizeのコードを用いて解説します。

// StreamWriter defined the type of stream writer.
type StreamWriter struct {
    file            *File
    Sheet           string
    SheetID         int
    sheetWritten    bool
    cols            strings.Builder
    worksheet       *xlsxWorksheet
    rawData         bufferedWriter
    rows            int
    mergeCellsCount int
    mergeCells      strings.Builder
    tableParts      string
}
// Note that after writing data with the stream writer for the worksheet, you
// must call the 'Flush' method to end the streaming writing process, ensure
// that the order of row numbers is ascending when set rows, and the normal
// mode functions and stream mode functions can not be work mixed to writing
// data on the worksheets. The stream writer will try to use temporary files on
// disk to reduce the memory usage when in-memory chunks data over 16MB
...

func (sw *StreamWriter) SetRow(cell string, values []interface{}, opts ...RowOpts) error {
    col, row, err := CellNameToCoordinates(cell)
    if err != nil {
        return err
    }
    if row <= sw.rows {
        return newStreamSetRowError(row)
    }
    sw.rows = row
    sw.writeSheetData()
    ...
}

StreamWriterのrowsは最後に書き込みをした行の番号です。rows以下の行番号に対して書き込みをしようとした場合にエラーを発生させることで逐次的な処理であることを保証しています。

OSS対応commit:https://github.com/qax-os/excelize/commit/2df615fa2831bd578371d4e3606f16461c474ce7#diff-4f588cddf63cad8c5e4fc9685c5b200fb4daacf14be2bece09a8296d4e266d90R44

修正するきっかけとなったissue:https://github.com/qax-os/excelize/issues/1139#issuecomment-1281742335

StreamWriterが逐次処理を前提とした設計になっている理由は、データ整合性を確保するためだと私は捉えています。以下に、その理由について解説します。

  1. Excelファイルの構造:
    • Excelファイルは特定のフォーマット(例えば、Open XML形式)で保存されており、これには複数のシート、セル、スタイルなどが含まれています。
    • フォーマットが壊れると、Excelファイル全体が正常に開けなくなる可能性があります。
  2. 逐次処理による一貫性の維持:
    • StreamWriterでは、データは順番に書き込まれていきます。これは、出力されるXMLデータの整合性を確認しやすくし、誤った順序や重複によって生じる問題を防ぎます。
    • 各要素が正しい順序で書き込まれることで、それぞれのデータが相互にどのように関連しているかを正確に保ち、ファイルが適切に終了することを保証します。
  3. リソース管理:
    • 逐次処理ではメモリ使用量を抑えることができ、大規模なデータセットを扱う際でも安定した動作が期待できます。これは結果として、データが途中で破損しないようにするための重要な手段となります。
  4. エラーハンドリングの容易さ:
    • ストリーム処理はエラーが発生した時点ですぐにそれに対処できるので、ファイル全体の一貫性が損なわれる前に修正が可能です。

これらのポイントから、StreamWriterの逐次処理は、データ整合性を確保しつつ大規模なデータの操作を効率よく行うための重要な設計方針と言えます。

StreamWriterの使用で学んだこと

Excelize以外の多くのライブラリにおいても StreamWriter のようなクラスでストリーム処理を実現しています。これらのStreamWriterは主にストリームベースの大量データ書き込みを高速に処理するために設計されています。これにより、逐次的にデータを追加する操作を想定しているため、同じ行に複数回書き込もうとする場合には問題が生じることが多いです。他の多くのライブラリでも、StreamWriter を使う際にデータがすでに存在する行に対して上書きしようとすると、エラーや予期しない動作が起こることがあります。これは StreamWriter の本来の設計が、連続的なデータの流れを管理し、メモリ効率を重視しているためです。そのため、書き込み処理が連続的でなかったり、データが上書きされる場面を想定していない場合には問題が生じます。StreamWriterを使用する際は上記の点を意識する必要があることを学びました。

採用情報

弊社では、プロダクト開発チームのメンバーを積極採用中です!

growth-verse.ai