Rails 7.1 で,render_to_string の仕様が変わったらしい。
そのために,自作アプリで〈rubyXL で Excel データを生成してダウンロード〉という機能がおかしくなって困ったが,最終的に直せた,という話。
なお,Rails のコードを調べたわけではなく,実験によって得た知見なので,少し間違っているかもしれない。誤りに気づかれた方はご指摘いただけると助かります。
各種バージョンは以下のとおり
- ruby 3.3.1 (2024-04-23 revision c56cd86388) [arm64-darwin23]
- Rails 7.1.3.2
- rubyXL 3.4.27
- stringio 3.1.0
(トラブルが起こらなかった Rails バージョンとしては,7.0.8.1 や 6.1.7.7 など)
はじめに
Rails アプリで,CSV だの Excel だの PDF だのを動的に生成して返す(ダウンロードさせる),ということはよくある。
コントローラーでは send_data
メソッドにそういったデータを渡してやればいい。
渡すデータは,String オブジェクトとして表現されたバイト列だ1。
そのデータをどうやって生成するか(どこにそのコードを書くか)はいろいろなやり方があるが,HTML と同じようにビューファイルで記述するのが Rails 流だろう。
たとえば Item 一覧の Excel ファイルが
という URL で得られるようにしたければ,コントローラーは
class ItemsController < ApplicationController
def index
respond_to do |format|
format.xlsx do
send_data render_to_string
end
end
end
end
みたいに書いておき,app/views/items/index.xlsx.ruby
というビューファイルで Excel データを生成して返すようにすればいい。
なお,拡張子 .xlsx
に対応するため
Mime::Type.register "application/excel", :xlsx
が必要になる。
ビューファイルのファイル名が index.xlsx.ruby
のように .ruby
となっているのは,Rails に対して,「このファイルは Ruby スクリプトとして評価して,その最後の式の値を使ってくれ」と教えていることになる。
さて,コントローラーのコードに,本題である render_to_string
が出てきた。
このメソッドは,規約に従ってビューファイルを探し,それを評価して得られた値を文字列として返すもの。
今の場合,ItemsController
の index
メソッドで xlsx
フォーマットの場合だから app/views/items/index.xlsx.ruby
を見つけてくる,というわけだ。
なお,render_to_string
の返り値は String オブジェクトであることが保証されているらしい。
ビュー
私は Excel データの生成に rubyXL gem を用いた。
ビューファイルはだいたいこんな感じだ
# frozen_string_literal: true
workbook = RubyXL::Workbook.new
worksheet = workbook[0]
# worksheet のセルに値を入れていく処理
# 返り値
workbook.stream
Rails 5.x ではこれでうまくいっていた。
Rails バージョンを上げていって,7.1 になったところでトラブルが発生した。
あとでいろいろ実験したところ,7.0 では OK なようだったので,7.0→7.1 の仕様変更が効いたらしい。
トラブル
トラブルとは,これでダウンロードしたファイルが Excel ファイルになっておらず,中身が
#<StringIO:0x0000000138e9a3e8>
のテキストファイルになっていたこと。
ああ,いかにも〈StringIO オブジェクトを to_s
した文字列〉じゃん。
何が起こった?
解明にはだいぶ時間を費やしたが,分かってみればわりと単純な話だった。
rubyXL の仕様
ビューファイルでは,最後に RubyXL::Workbook#stream を呼び出している。
このメソッドの返り値は String オブジェクトではなく StringIO オブジェクトだ。
原因:Rails 7.1 での仕様変更
Rails 7.1 では,ビューファイルが String オブジェクトではないものを返した場合,render_to_string
は to_s
を呼び出して文字列化するようだ。
StringIO オブジェクトの to_s
を呼び出せば,当然 #<StringIO:0x00000001027d81a8>
のような文字列を返す:
require "stringio"
p StringIO.new("hoge").to_s
# => "#<StringIO:0x00000001027d81a8>"
では,Rails 7.0.x まではなぜ問題が起きなかったのか?
どうやら,7.1 より前は,each
メソッドを持つオブジェクトに対しては each
でイテレートし,各回のブロックの評価値を順次利用するようになっていたのだ。
これはおそらく,データが極めて巨大である場合に,巨大な String オブジェクトを生成しなくてもすむように,といった配慮だったのではないかと思うが,よく分からない。
StringIO は確かに each メソッドを持っており,行ごとにブロックを評価するようになっている。
なお,Rails のこの仕様変更を確認するには,ビューをもっと単純な
["foo", "bar", "baz"]
のようなものに変え,Rails 7.0 と 7.1 でやってみればよい。以下のようになるはずだ:
- 7.0
foobarbaz
- 7.1
["foo", "bar", "baz"]
解決策
原因が分かれば修正は簡単だ。
StringIO#string を使って,StringIO オブジェクトの中身を文字列として取り出せばよい。
つまり,ビューファイルの末尾を以下のように変える。
# 修正前
workbook.stream
# 修正後
workbook.stream.string
これで確かに直った。
なお,StringIO#read を使ってもよさそうだが,こちらは読み出し開始位置について考慮する必要がある。
何も考えずに全体の文字列を得るなら string
を使うことにしておいたほうがいいと思う。
おわりに
私はなぜ
workbook.stream.string
と書かずに
workbook.stream
と書いていたのか?
たぶん,rubyXL の README のココ に,StringIO でやればウェブサーバーで便利だぜ,的な文言とともに後者のようなコードが載っていたからだと思う。
それから,私はなぜ原因の究明に手間取ったのか。
それはもちろん私がポンコツというか,よわよわというか,ロースキルシニアだからなのだが,そこを棚に上げて言い訳をすると以下のようなことになる。
-
stream
が StringIO オブジェクトを返すことは早い段階で気づいていたが,StringIO の仕様の確認に時間がかかった。 - Rails 7.1 のリリースノートにこの非互換性が書かれていなかった(見落としかもしれない)
-
redner_to_string
のコード を見たが,多重に下請けに出していて追いかける気力を無くした。