Excelを扱うRubyGemまとめ 2022

※本記事は、22/12/02の[Qiita Night～Ruby～](https://increments.connpass.com/event/265957/)のLTでお話したことを記事化したものです。

8年前、[RailsでExcelを扱うGemまとめ](https://qiita.com/Kta-M/items/02a2c41c5624f75498aa)という記事を書きました（RubyではなくRailsと書いているのは若気の至り）。Qiitaに記事を書き始めてから初めてたくさんのいいねをもらった思い出深い記事です。

ですが、投稿から8年も経っているにもかかわらず、「excel gem」と検索するとまだ最初に出てきます。
Excel関連Gem業界にとってはあんまり良くないと思うので、改めてまとめてみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/39565/2f857727-e9bd-1d7c-d9d1-ade24dd500cf.png)

なお、本記事での検証用コードは以下に置いています
https://github.com/Kta-M/excel_gems

# システムでExcelファイルを使う需要
システム的にはCSVのほうが扱いやすいですが、Excelファイルの需要もそれなりにあります。
どうしてもExcelで！と言われたときに、この記事を参照してみてください。

- CSVの壁
  - CSVは(ExcelがデフォルトでShift-JISで読み込むので)扱えない文字がある
  - CSVは(Excelが0始まりの数字文字列や日時文字列を勝手に解釈してしまうので)
    - 出力内容通りに表示してくれない
    - 開いて保存するだけで内容が変わってしまう
  - 罫線を引いたり、セルや文字に色を付けたり、セルのサイズを変えたりしたい
- PDFの壁
  - あとから書き込める帳票がほしい
- 運用の壁
  - 既存の業務をExcelでやっていたので

# 調査対象
[The Ruby Toolbox](https://www.ruby-toolbox.com/categories/reporting)の上位にあるものから選択しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/39565/58c9af0a-a4f1-e936-0ec0-932c251a8f41.png)

`axlsx`は`caxlsx`に引き継がれたため除外、`axlsx_rails`は`axlsx`をrailsに導入するためのgemなので除外しました(caxlsx_railsもあります)。
そのため、調査対象は以下になります。8年前と変わってない…。

- [roo](https://github.com/roo-rb/roo)
- [spreadsheet](https://github.com/zdavatz/spreadsheet)
- [rubyXL](https://github.com/weshatheleopard/rubyXL)
- [caxlsx](https://github.com/caxlsx/caxlsx)

## 簡単比較表
ひとまず、それぞれ何ができるのか、簡単にまとめておきます。

||新規作成|書き出し|読み込み|拡張子(Excel関連のみ)|
|:--:|:--:|:--:|:--:|:--:|
|roo|x|x|o|`.xls`, `.xlsx`|
|spreadsheet|o|o|o|`.xls`|
|rubyXL|o|o|o|`.xlsx`, `.xlsm`|
|caxlsx|o|o|x|`.xlsx`|

# 書き込み部門
rooは読み込み専門なので、spreadsheet, rubyXL, caxlsxで比較していきます。

ちょっと無理やりいろんな書式を使っている感がありますが、以下のような表を作ってみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/39565/3ff16217-c0b0-fe3d-29f6-b2b80b180fbc.png)

## spreadsheet
- 😄
  - いろいろな方法でセルにデータを入れられる
- 🥲
  - 計算式が入力できない
  - デフォルトのフォントが設定できず、行ごと・セルごとに指定する必要がある
  - 色がカラーパレットからしか選べない
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/39565/f99154d2-07fe-fb69-cb20-7773de464e21.png)
  - ドキュメントが手薄なので、ドキュメントに載っていないものはソースコードを見て回る必要がある
  - そもそも`.xls`しか対応していない。。

<details><summary>サンプルコード</summary>

```ruby
require 'spreadsheet'

# Workbookを作成
workbook = Spreadsheet::Workbook.new

# Sheetを作成
sheet = workbook.create_worksheet(name: 'curry')

# 各セルに書き込み
# いろいろな方法でデータを入れられる
# 計算式は入力できない
sheet.row(0).concat %w{品名 単価 数量 計}

sheet[1,0] = 'にんじん'
sheet[1,1] = 80
sheet[1,2] = 1
sheet[1,3] = 80

row = sheet.row(2)
row.push 50
row.push 2
row.push 100
row.unshift 'たまねぎ'

sheet.row(3).replace [ 'じゃがいも', 40, 2, 80 ]

sheet.row(4).push '牛肉', 1, 200
sheet.row(4).insert 1, 200

sheet.update_row 5, 'カレー粉', 150, 1, 150

sheet[6,0] = '総計'
sheet[6,3] = 610

# セルのマージ
sheet.merge_cells(6, 0, 6, 2)

# 各行のフォントを変更
format = Spreadsheet::Format.new(font: Spreadsheet::Font.new('メイリオ'))
(0..6).each do |row|
  sheet.row(row).default_format = format
end

# 背景色設定
# パレットからの選択(カラーコード指定不可)
# https://github.com/zdavatz/spreadsheet/blob/master/lib/spreadsheet/datatypes.rb#L15-L77
(0..3).each do |col|
  sheet.row(0).update_format(col, pattern_fg_color: :silver, pattern: 1)
end
sheet.row(6).update_format(3, pattern_fg_color: :xls_color_16, pattern: 1)

# フォントカラー設定
sheet.row(6).update_format(3, color: :red)

# 書体変更
sheet.row(6).update_format(3, weight: :bold)

# アラインメント
(0..3).each do |col|
  sheet.row(0).update_format(col, align: :center)
end
sheet.row(6).update_format(0, align: :right)

# 罫線
(0..6).each do |row|
  (0..3).each do |col|
    sheet.row(row).update_format(col, border: :thin)
  end
end

# 書き出し
workbook.write('output/example/spreadsheet.xls')
```
</details>

## rubyXL
- 😄
  - 数式が使える
  - 書式設定系のメソッドに命名規則があって分かりやすい
  - `.xlsx`が扱えて、読み込み、書き込みができるのはこれだけ
    - テンプレートのExcelファイルを読み込んで必要箇所だけ更新して出力するような用途に向いていそう
- 🥲
  - デフォルトのフォントが設定できず、行ごと・セルごとに指定する必要がある
  - 罫線を引くとき、セルの上下左右それぞれを個別に指定する必要がある
    - 広範囲に罫線を引くと結講重い
  - 値が入っていないセルの書式をいじろうとしたら怒られる

<details><summary>サンプルコード</summary>

```ruby
require 'rubyXL'
require 'rubyXL/convenience_methods'

# Workbookを作成
workbook = RubyXL::Workbook.new

# Sheet取得
sheet = workbook[0]   # 最初からシートが１つある

# Sheetの名前変更
sheet.sheet_name = 'curry'

# 各セルに書き込み
sheet.add_cell(0, 0, '品名')
sheet.add_cell(0, 1, '単価')
sheet.add_cell(0, 2, '数量')
sheet.add_cell(0, 3, '計')

sheet.add_cell(1, 0, 'にんじん')
sheet.add_cell(1, 1, 80)
sheet.add_cell(1, 2, 1)
sheet.add_cell(1, 3, '', 'B2*C2')

sheet.add_cell(2, 0, 'たまねぎ')
sheet.add_cell(2, 1, 50)
sheet.add_cell(2, 2, 2)
sheet.add_cell(2, 3, '', 'B3*C3')

sheet.add_cell(3, 0, 'じゃがいも')
sheet.add_cell(3, 1, 40)
sheet.add_cell(3, 2, 2)
sheet.add_cell(3, 3, '', 'B4*C4')

sheet.add_cell(4, 0, '牛肉')
sheet.add_cell(4, 1, 200)
sheet.add_cell(4, 2, 1)
sheet.add_cell(4, 3, '', 'B5*C5')

sheet.add_cell(5, 0, 'カレー粉')
sheet.add_cell(5, 1, 150)
sheet.add_cell(5, 2, 1)
sheet.add_cell(5, 3, '', 'B6*C6')

sheet.add_cell(6, 0, '総計')
sheet.add_cell(6, 1, '') # 値を入れないと罫線が引けない
sheet.add_cell(6, 2, '') # 値を入れないと罫線が引けない
sheet.add_cell(6, 3, '', 'SUM(D2:D6)')

# セルのマージ
sheet.merge_cells(6, 0, 6, 2)

# 各行のフォントを変更
(0..6).each do |row|
  sheet.change_row_font_name(row, 'メイリオ')
end

# 背景色設定
(0..3).each do |col|
  sheet[0][col].change_fill('d0d0d0')
end
sheet[6][3].change_fill('b8cce4')

# フォントカラー設定
sheet[6][3].change_font_color('ae2f29')

# 書体変更
sheet[6][3].change_font_bold(true)

# アラインメント
sheet.change_row_horizontal_alignment(0, 'center')
sheet[6][0].change_horizontal_alignment('right')

# 罫線
(0..6).each do |row|
  (0..3).each do |col|
    sheet[row][col].change_border(:top, 'thin')
    sheet[row][col].change_border(:left, 'thin')
    sheet[row][col].change_border(:bottom, 'thin')
    sheet[row][col].change_border(:right, 'thin')
  end
end

# 書き出し
workbook.write("output/example/rubyXL.xlsx")
```
</details>

## caxlsx
- 😄
  - Exampleがとても充実しているので導入しやすい
    - https://github.com/caxlsx/caxlsx/tree/master/examples
  - 指定範囲に一括で書式設定ができる
  - なんとグラフまで作れてしまう
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/39565/dc7a65e6-3274-70e3-f9ff-036eca0c4305.png)
- 🥲
  - 勝手に列の幅が調整されてしまう
    - 固定するなら逐一指定する必要がある
  - 基本的にExcelのセル番地?での指定（`A1:C1`など)になる
    - 行・列のindex値からの変換メソッドを用意すれば解決だけれども
    - (余談)いざ作ろうとしたら案外難しいｗ

<details><summary>サンプルコード</summary>

```ruby
require 'caxlsx'

# Workbookを作成
package = Axlsx::Package.new
workbook = package.workbook

# Sheetを作成
sheet = workbook.add_worksheet(name: 'curry')

# 各セルに書き込み
sheet.add_row(['品名',        '単価', '数量', '計'])
sheet.add_row(['にんじん',    80,     1,      '=B2*C2'])
sheet.add_row(['たまねぎ',    50,     2,      '=B3*C3'])
sheet.add_row(['じゃがいも',  40,     2,      '=B4*C4'])
sheet.add_row(['牛肉',        200,    1,      '=B5*C5'])
sheet.add_row(['カレー粉',    150,    1,      '=B6*C6'])
sheet.add_row(['総計',        '',     '',     '=SUM(D2:D6)'])

# セルのマージ
sheet.merge_cells('A7:C7')

# 各行のフォントを変更
sheet.add_style('A1:D7', font_name: 'メイリオ')

# 背景色設定
sheet.add_style('A1:D1', bg_color: 'd0d0d0')
sheet.add_style('A7:A7', bg_color: 'b8cce4')

# # フォントカラー設定
sheet.add_style('D7:D7', fg_color: 'ae2f29')

# # 書体変更
sheet.add_style('D7:D7', b: true)

# アラインメント
sheet.add_style("A1:D1", alignment: { horizontal: :center })
sheet.add_style("A7:A7", alignment: { horizontal: :right })

# 罫線
sheet.add_style('A1:D7', border: { style: :thin, color: '000000' })

# 書き出し
package.serialize('output/example/caxlsx.xlsx')
```
</details>

# 書き込みベンチマーク
## 実行速度
各操作について実行速度を計測してみました。
Webシステムから出力する際、あまりに遅いと非同期処理にしないといけなくなったりして大変なので、速度は重要ですよね。

シート作成は100枚、セルへの各種操作は100x100のセルに対して行っています。
実際のコードは[こちら](https://github.com/Kta-M/excel_gems/blob/main/src/write/benchmark/time.rb)。

```
## Create Workbook
                 user     system      total        real
spreadsheet  0.000786   0.000017   0.000803 (  0.000793)
rubyXL       0.013436   0.000615   0.014051 (  0.021612)
caxlsx       0.000581   0.000053   0.000634 (  0.000637)

## Create Sheet
                 user     system      total        real
spreadsheet  0.001184   0.000031   0.001215 (  0.001214)
rubyXL       0.040784   0.000804   0.041588 (  0.042620)
caxlsx       0.017650   0.001672   0.019322 (  0.030375)

## Add Cells
                 user     system      total        real
spreadsheet  0.014695   0.000271   0.014966 (  0.014968)
rubyXL       0.354589   0.001620   0.356209 (  0.356193)
caxlsx       0.186781   0.000104   0.186885 (  0.186876)

## Merge Cells
                 user     system      total        real
spreadsheet  0.003145   0.000010   0.003155 (  0.003158)
rubyXL       0.037855   0.000411   0.038266 (  0.038267)
caxlsx       0.045560   0.000038   0.045598 (  0.045594)

## Change Font
                 user     system      total        real
spreadsheet  0.101532   0.000776   0.102308 (  0.102336)
rubyXL       0.824043   0.000607   0.824650 (  0.824719)
caxlsx       0.035586   0.000142   0.035728 (  0.035724)

## Change Fill
                 user     system      total        real
spreadsheet  0.158450   0.000404   0.158854 (  0.158845)
rubyXL       1.028291   0.000648   1.028939 (  1.028883)
caxlsx       0.036306   0.000307   0.036613 (  0.036611)

## Change Font Color
                 user     system      total        real
spreadsheet  0.115407   0.000327   0.115734 (  0.115728)
rubyXL       1.247051   0.001759   1.248810 (  1.248745)
caxlsx       0.038612   0.000194   0.038806 (  0.038802)

## Change Font Color
                 user     system      total        real
spreadsheet  0.105828   0.000548   0.106376 (  0.106370)
rubyXL       0.998762   0.000824   0.999586 (  0.999547)
caxlsx       0.036599   0.000151   0.036750 (  0.036750)

## Change Alignment
                 user     system      total        real
spreadsheet  0.114057   0.000414   0.114471 (  0.114463)
rubyXL       0.712438   0.000425   0.712863 (  0.712828)
caxlsx       0.035214   0.000090   0.035304 (  0.035299)

## Change Border
                 user     system      total        real
spreadsheet  0.218832   0.000549   0.219381 (  0.219381)
rubyXL       4.297522   0.006336   4.303858 (  4.303643)
caxlsx       0.040979   0.000129   0.041108 (  0.041106)
```

spreadsheetは`.xls`, rubyXL, caxlsxは`.xlsx`という違いはありますが、
全体的にrubyXLはちょっと重めかなという感じです。

## メモリ使用量
一応、メモリの使用量も見てみました。
なかなか正確な値を出しづらいので、あくまで参考値としてとらえてください。

実行速度の計測で行なっている処理をすべて繋げたもので計測してみました。
実際のコードは[こちら](https://github.com/Kta-M/excel_gems/tree/main/src/write/benchmark/memory)。

計測方法は2通り。いずれも処理前後の値の比較をしています。
- すべての生存しているオブジェクトが消費しているメモリ使用量を計測する[memsize_of_all](https://docs.ruby-lang.org/ja/latest/method/ObjectSpace/m/memsize_of_all.html)を利用したもの。
- psコマンドのrss(プロセスが確保している物理使用量)を利用したもの

```
## spreadsheet
memsize_of_all: 12.07 MB, rss: 19.86 MB

## rubyXL
Bmemsize_of_all: 11.4 MB, rss: 32.3 MB

## caxlsx
memsize_of_all: 5.55 MB, rss: 21.82 MB
```

劇的な差が出ているわけではないですが、どれも10~数十MBぐらいは使うようですね。

# 読み込み部門
caxlsxは新規作成専門なので、roo, spreadsheet, rubyXLで比較していきます。

書き込みのときに目標としていたこれを読みこんで、各セルの値を出力してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/39565/3ff16217-c0b0-fe3d-29f6-b2b80b180fbc.png)

## roo
- これだけだとわかりにくいですが、データテーブルを読むのが得意な印象です。
  - streamで少しずつ読み込むことができる
  - csv, matrix, xml, yamlへの変換が可能
  - ヘッダを与えて各行をhashとして取得することも可能

<details><summary>サンプルコード</summary>

```ruby
require 'roo'

# Workbookを読み込み
workbook = Roo::Excelx.new('input/example.xlsx')

# Sheet取得
sheet = workbook.sheet('curry')

# 値の取得
sheet.first_row.upto(sheet.last_row) do |row|
  puts "#{sheet.cell(row, 1)} #{sheet.cell(row, 2)} #{sheet.cell(row, 3)} #{sheet.cell(row, 4)}"
end
```
</details>

## spreadsheet
- 計算式の入力はできませんが、計算式かどうかの判定はできます。

<details><summary>サンプルコード</summary>

```ruby
require 'spreadsheet'

# Workbookを読み込み
workbook = Spreadsheet.open('input/example.xls')

# Sheet取得
sheet = workbook.worksheet('curry')

# 値を取得
sheet.each do |row|
  # 数式が入っているセルは .value で値を取得
  puts "#{row[0]} #{row[1]} #{row[2]} #{row[3].instance_of?(Spreadsheet::Formula) ? row[3].value : row[3]}"
end
```
</details>

## rubyXL
- 計算式の部分は計算後の値を取得してくれます。

<details><summary>サンプルコード</summary>

```ruby
require 'rubyXL'
require 'rubyXL/convenience_methods'

# Workbookを読み込み
workbook = RubyXL::Parser.parse('input/example.xlsx')

# Sheet取得
sheet = workbook['curry']

# 値を取得
sheet.each do |row|
  puts "#{row[0].value} #{row[1].value} #{row[2].value} #{row[3].value}"
end
```
</details>

# 読み込みベンチマーク
## 実行速度
10000x100のデータを読み込んで速度を計測しました。
実際のコードは[こちら](https://github.com/Kta-M/excel_gems/blob/main/src/read/benchmark/time.rb)

```
                 user     system      total        real
roo         25.934566   0.193718  26.128284 ( 26.482589)
spreadsheet  8.964426   0.043074   9.007500 (  9.111481)
rubyxl      75.116438   9.415123  84.531561 ( 90.197534)
```

spreadsheetが異様に早いですが、これは `.xls` だからなのか…🤔
`.xlsx` だけに関していえば、rooのほうが速いですね。

## メモリ使用量
同じ処理でメモリ使用量も計測してみました。
実際のコードは[こちら](https://github.com/Kta-M/excel_gems/tree/main/src/read/benchmark/memory)

```
## roo
memsize_of_all: 2.25 MB, rss: 63.52 MB

## spreadsheet
memsize_of_all: 40.79 MB, rss: 45.62 MB

## rubyXL
memsize_of_all: 724.05 MB, rss: 92.21 MB
```

rooはstreamで読み込み、それ以外は一括での読み込みです。
さすがにrooはメモリの節約ができていますね。
rubyXLはちょっと苦しい…。

# Gemの使い分け
それぞれ一長一短があって、何も考えずにこれを選んでおけば問題ない、というものはありません。
選ぶにあたっては、以下の特徴を抑えておくといいかなと思います。

## roo
- 読み込み専用
- データテーブルを読み込むのが得意
  - streamでの読み込みができる
  - csv, matrix, xml, yamlへの変換も可能

=> Excelからデータを読み込みだけなら、rooを選択しておくと吉

## spreadsheet
- 読み書きができる
- `.xls`しか扱えない

=> 令和の時代はあまり出番がないかもしれませんが、`.xls`を扱うならこちら

## rubyXL
- 読み書きができる
- 計算式の入力や大抵の書式設定が可能
- 比較的動作は重め

=> 帳票のテンプレートを読み込んで改変して出力するようなタスクにはよさそう

## cxlsx
- 新規作成のみ
- 処理が速い
- 書式の設定がやりやすい
- グラフも作れる

=> リッチな帳票をゼロから作って出力する場合はこれ！

# それ以外の方法
Rubyに囚われずとも、場合によっては他のやり方を検討してもいいかもしれません。

## Lambdaを利用して別言語で！
S3経由でLambdaにcsvやjsonでデータを渡して、その内容に従ってLambdaでExcelを作成してS3に保存するというやり方もいいかもしれません。

### .Net Core
Lambdaであれば、.Net Coreが使えます（使ったことはないですが
[Open XML SDK 2.5 for Office](https://learn.microsoft.com/ja-jp/office/open-xml/open-xml-sdk)という公式SDKがあるとのことで、これを使えば純正のやり方でExcelファイルが作れてしまいます。
Open XML SDKはちょっとしたことをやるにもかなりのコードを書かないといけないので、これをラップした[ClosedXML](https://github.com/ClosedXML/ClosedXML)というOSSライブラリもあるようです。

### Python
Rubyが使えればなんとなく使えるPythonにもライブラリがあります。
[openpyxl](https://openpyxl.readthedocs.io/en/stable/)というライブラリがデファクトらしいです。
読み書きができて、グラフも出せるそうです。

## 込み入ったことはVBAで！
VBAならExcelでできることは何でもできるんですよね。ちょっと書きづらいですが。
なので、VBAを書いたExcelファイルを作って作業者に配布しておき、システムから簡単なExcelやCSVを出力して、VBAからそれを読み込んでもらって目的のファイルを作成する、という最終手段もあります。

# まとめ
8年越しに情報を更新してみましたが、Gemの顔ぶれがほとんど変わっておらず驚きました。
久しぶりに実家に帰ってきたけど何も変わってない！みたいなｗ

ずっと開発を続けてくれているコミッターの皆様には本当に感謝です🙏