Rails 7.1 で render_to_string の仕様が変わった

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 オブジェクトとして表現されたバイト列だ[^bytes]。

[^bytes]: CSV データは純粋に文字列だが，Excel や PDF は文字列ではない。こういうものも String オブジェクトとして扱える。

そのデータをどうやって生成するか（どこにそのコードを書くか）はいろいろなやり方があるが，HTML と同じようにビューファイルで記述するのが Rails 流だろう。

たとえば Item 一覧の Excel ファイルが

https://example.com/items.xlsx

という URL で得られるようにしたければ，コントローラーは

```rb
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` に対応するため

```rb:config/initializers/mime_types.rb
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](https://rubygems.org/gems/rubyXL) gem を用いた。

ビューファイルはだいたいこんな感じだ

```rb:app/views/items/index.xlsx.ruby
# 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 ファイルになっておらず，中身が

```txt
#<StringIO:0x0000000138e9a3e8> 
```

のテキストファイルになっていたこと。
ああ，いかにも〈StringIO オブジェクトを `to_s` した文字列〉じゃん。
何が起こった？

解明にはだいぶ時間を費やしたが，分かってみればわりと単純な話だった。

# rubyXL の仕様

ビューファイルでは，最後に [RubyXL::Workbook#stream](https://www.rubydoc.info/gems/rubyXL/3.4.27/RubyXL%2FWorkbook:stream) を呼び出している。
このメソッドの返り値は String オブジェクトではなく StringIO オブジェクトだ。

# 原因：Rails 7.1 での仕様変更

Rails 7.1 では，ビューファイルが String オブジェクトではないものを返した場合，`render_to_string` は `to_s` を呼び出して文字列化するようだ。

StringIO オブジェクトの `to_s` を呼び出せば，当然 `#<StringIO:0x00000001027d81a8>` のような文字列を返す：

```rb
require "stringio"

p StringIO.new("hoge").to_s
# => "#<StringIO:0x00000001027d81a8>"
```

では，Rails 7.0.x まではなぜ問題が起きなかったのか？

どうやら，7.1 より前は，`each` メソッドを持つオブジェクトに対しては `each` でイテレートし，各回のブロックの評価値を順次利用するようになっていたのだ。
これはおそらく，データが極めて巨大である場合に，巨大な String オブジェクトを生成しなくてもすむように，といった配慮だったのではないかと思うが，よく分からない。

StringIO は確かに [each](https://docs.ruby-lang.org/ja/3.3/method/StringIO/i/each.html) メソッドを持っており，行ごとにブロックを評価するようになっている。

なお，Rails のこの仕様変更を確認するには，ビューをもっと単純な

```rb
["foo", "bar", "baz"]
```

のようなものに変え，Rails 7.0 と 7.1 でやってみればよい。以下のようになるはずだ：

* 7.0 `foobarbaz` 
* 7.1 `["foo", "bar", "baz"]`

# 解決策

原因が分かれば修正は簡単だ。
[StringIO#string](https://docs.ruby-lang.org/ja/3.3/method/StringIO/i/string.html) を使って，StringIO オブジェクトの中身を文字列として取り出せばよい。
つまり，ビューファイルの末尾を以下のように変える。

```rb
# 修正前
workbook.stream

# 修正後
workbook.stream.string
```

これで確かに直った。
なお，[StringIO#read](https://docs.ruby-lang.org/ja/3.3/method/StringIO/i/read.html) を使ってもよさそうだが，こちらは読み出し開始位置について考慮する必要がある。
何も考えずに全体の文字列を得るなら `string` を使うことにしておいたほうがいいと思う。

# おわりに

私はなぜ

```rb
workbook.stream.string
```

と書かずに

```rb
workbook.stream
```

と書いていたのか？
たぶん，rubyXL の [README のココ](https://github.com/weshatheleopard/rubyXL?tab=readme-ov-file#label-I-2FO) に，StringIO でやればウェブサーバーで便利だぜ，的な文言とともに後者のようなコードが載っていたからだと思う。

それから，私はなぜ原因の究明に手間取ったのか。
それはもちろん私がポンコツというか，よわよわというか，ロースキルシニアだからなのだが，そこを棚に上げて言い訳をすると以下のようなことになる。

* `stream` が StringIO オブジェクトを返すことは早い段階で気づいていたが，StringIO の仕様の確認に時間がかかった。
    * 公式リファレンスの StringIO のページが [間違っていた](https://github.com/rurema/doctree/issues/2880)。
    * Rails が StringIO を改造しているらしかったので[^maka]，それが原因である可能性も考えた。
* Rails 7.1 のリリースノートにこの非互換性が書かれていなかった（見落としかもしれない）
* [`redner_to_string` のコード](https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/actionpack/lib/abstract_controller/rendering.rb#L45-L48) を見たが，多重に下請けに出していて追いかける気力を無くした。

[^maka]: なにしろ Rails は組込みクラスすら魔改造するんだものな。