LoginSignup
1
0

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

Last updated at Posted at 2024-05-01

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

config/initializers/mime_types.rb
Mime::Type.register "application/excel", :xlsx

が必要になる。

ビューファイルのファイル名が index.xlsx.ruby のように .ruby となっているのは,Rails に対して,「このファイルは Ruby スクリプトとして評価して,その最後の式の値を使ってくれ」と教えていることになる。

さて,コントローラーのコードに,本題である render_to_string が出てきた。
このメソッドは,規約に従ってビューファイルを探し,それを評価して得られた値を文字列として返すもの。
今の場合,ItemsControllerindex メソッドで xlsx フォーマットの場合だから app/views/items/index.xlsx.ruby を見つけてくる,というわけだ。

なお,render_to_string の返り値は String オブジェクトであることが保証されているらしい。

ビュー

私は Excel データの生成に rubyXL gem を用いた。

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

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 ファイルになっておらず,中身が

#<StringIO:0x0000000138e9a3e8> 

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

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

rubyXL の仕様

ビューファイルでは,最後に RubyXL::Workbook#stream を呼び出している。
このメソッドの返り値は String オブジェクトではなく StringIO オブジェクトだ。

原因:Rails 7.1 での仕様変更

Rails 7.1 では,ビューファイルが String オブジェクトではないものを返した場合,render_to_stringto_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 の仕様の確認に時間がかかった。
    • 公式リファレンスの StringIO のページが 間違っていた
    • Rails が StringIO を改造しているらしかったので2,それが原因である可能性も考えた。
  • Rails 7.1 のリリースノートにこの非互換性が書かれていなかった(見落としかもしれない)
  • redner_to_string のコード を見たが,多重に下請けに出していて追いかける気力を無くした。
  1. CSV データは純粋に文字列だが,Excel や PDF は文字列ではない。こういうものも String オブジェクトとして扱える。

  2. なにしろ Rails は組込みクラスすら魔改造するんだものな。

1
0
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0