読者です 読者をやめる 読者になる 読者になる

Rails 4 へのアップグレード時に遭遇した問題

技術部の鈴木 (@eagletmt) です。

クックパッドでは8月に本体アプリケーションや API サーバ等で使われている Rails のバージョンを 3.2 から 4.1 へ順次アップグレードを行いました。 アップグレードは主に松田さん (@amatsuda) と私で進めました。 この記事ではアップレードの際に遭遇した問題の一部を紹介します。

MySQL strict mode の有効化

MySQL を使っている場合、Rails 4.0 からデフォルトで @@SESSION.sql_mode = 'STRICT_ALL_TABLES' が最初に実行されるようになりました (Ruby on Rails 4.0 Release Notes) 。 これを無効化するために database.yml で strict: false という設定が用意されています。 しかし、同じく Rails 4.0 で導入された partial insert と組み合わさると、Rails 3.2 とは挙動が変わるケースがありました。

たとえば NOT NULL 制約がついているカラムに MySQL のデフォルト値が挿入されるようになりました。 以下のような recipes テーブルがあったとします。

mysql> SHOW CREATE TABLE recipes;
+---------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table   | Create Table                                                                                                                                                                              |
+---------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| recipes | CREATE TABLE `recipes` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `title` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 |
+---------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

このとき Rails 3.2 では、user_id や title を指定しないと、新しいレコードが NOT NULL 制約により作られませんでした。

[1] pry(main)> ActiveRecord::VERSION::STRING
=> "3.2.18"
[2] pry(main)> Recipe.create
   (0.2ms)  BEGIN
  SQL (0.4ms)  INSERT INTO `recipes` (`title`, `user_id`) VALUES (NULL, NULL)
   (0.1ms)  ROLLBACK
ActiveRecord::StatementInvalid: Mysql2::Error: Column 'title' cannot be null: INSERT INTO `recipes` (`title`, `user_id`) VALUES (NULL, NULL)

しかし Rails 4.0 以降で strict: false にすると、partial insert と MySQL のデフォルトの sql_mode の組み合わせにより INSERT に成功してしまいます。

[1] pry(main)> ActiveRecord::VERSION::STRING
=> "4.1.4"
[2] pry(main)> Recipe.create
   (0.2ms)  BEGIN
  SQL (0.3ms)  INSERT INTO `recipes` VALUES ()
   (0.3ms)  COMMIT
=> #<Recipe id: 1, user_id: nil, title: nil>
[3] pry(main)> Recipe.last
  Recipe Load (0.3ms)  SELECT  `recipes`.* FROM `recipes`   ORDER BY `recipes`.`id` DESC LIMIT 1
=> #<Recipe id: 1, user_id: 0, title: "">

これでは壊れたデータが保存されてしまうため、Rails のバグとして Rails 側を修正しようとしました。 しかし partial insert を維持しつつこの挙動をうまく抑えることは難しいと判断し、クックパッドでは strict mode を有効化することで対応しました。

幸い sql_mode はセッション毎に設定できるので、徐々に strict mode を有効化しながら進めることができました。 最初にテストを strict mode で通る状態にし、テスト時と開発環境で常に strict mode を有効化しました。 その後、本番の app サーバで徐々に strict mode を有効化していき、問題が無いか確認しながら最終的に全 app サーバで有効化しました。

acts_as_readonlyable からの脱却

こちらの記事にあったように、クックパッドでは acts_as_readonlyable を使い続けてきました。 acts_as_readonlyable は Rails において R/W splitting を行うための gem です。 本家の更新は2007年で止まっていますが、Rails のバージョンを上げる度に改修しながら使い続けてきました。

今回のアップグレードも同じように acts_as_readonlyable を改修して乗り切ろうとしましたが、大きく実装を変えなければ対応が難しかったため、switch_point を作りこちらへ移行することにしました。 Rails のバージョンに極力依存しないように書いたので、Rails のアップグレード前に置き換えることに成功しました。

なお、switch_point については RubyKaigi 2014 の LT で発表しています。 詳細については、こちらの資料をご覧ください。 https://speakerdeck.com/eagletmt/w-splitting-in-rails

database_cleaner + 独自パッチからの脱却

クックパッドの本体アプリケーションには大量のモデルがあるため、テストケース毎に全てのテーブルへ DELETE 文を発行すると、非常に時間がかかってしまいます。 この問題に対処するため、以前は database_cleaner に独自パッチをあてて、INSERT が行われたテーブルのみレコードを削除していました。 しかし、今回のアップグレード時にこの独自パッチ部分が問題になりました。 そこで同等のことを行える database_rewinder を松田さんが作り、こちらへ移行しました。

implicit join references

Rails 3.2 までは includes で指定した関連先が where で使われている場合、Rails が JOIN を補ってくれていました。

class User < ActiveRecord::Base
  has_many :recipes
end

class Recipe < ActiveRecord::Base
  belongs_to :user
end

このような関連があるとき、Rails 3.2 では Recipe.includes(:user).where('users.id = 1') とすると、where の引数を見て users テーブルを LEFT OUTER JOIN することでまとめて取得していました。 このため、明示的に joins を指定せずに where にこのような条件式を渡しても正常に動作していました。

しかし Rails 4.0 でこの挙動が deprecated になり、4.1 で取り除かれました。 そのため、Rails 4.1 では SELECT recipes.* FROM recipes WHERE (users.id = 1) ORDER BY recipes.id ASC LIMIT 1 というような不正なクエリが生成されてしまいます。 クックパッドのコードベースには Rails 3.2 までの includes と where の挙動に依存したコードが多くあり、テストの失敗を見ながら1つずつ joins や references を補っていきました。

Time の JSON 表現にマイクロ秒

Ruby 1.9 から Time がマイクロ秒を持つようになりました。 Rails 4.0 からは Ruby 1.9 以上のみをサポートするようになったため、JSON 表現にもデフォルトで小数点以下3桁まで含むようになりました。 ところが MySQL に保存する際に時刻のマイクロ秒が落とされてしまうので、従来のまま小数点以下の値を切り捨てることにしました。 これは config.active_support.time_precision = 0 とすることで設定できます。 http://guides.rubyonrails.org/configuring.html#configuring-active-support

セッション flash の非互換性

クッキーへのセッションの保存方式が Rails 4.0 で変わりました。 Rails 3.2 方式の設定である config.secret_token のみ設定していれば、従来の保存方式が使われるため、ロールバックする可能性があるアップグレード時には従来の方式のみ使われるようにしました。 また Rails 4.1 ではデフォルトのシリアライズ形式が marshal から json へと変わったため、config.action_dispatch.cookies_serializer = :marshal として従来の marshal のみを使うように設定しました。 http://guides.rubyonrails.org/upgrading_ruby_on_rails.html#cookies-serializer

これでアップグレードした場合もロールバックした場合もセッションを維持することができるようになりました。 しかし、flash で使うために保存されているオブジェクトに非互換性がありました。 Rails 3.2 では ActionDispatch::Flash::FlashHash というクラスのインスタンスであったのに対し、Rails 4.1 では単純な Hash になりました。 このため、ロールバックによって Rails 4.1 でセットされたセッション flash を持って Rails 3.2 にアクセスした場合、エラーが発生してしまいます。 具体的にはこの行でエラーになっていました https://github.com/rails/rails/blob/3-2-stable/actionpack/lib/action_dispatch/middleware/flash.rb#L239

この非互換性に対処するため、rails_4_session_flash_backport という gem を利用しました。 この gem を Gemfile に書くだけでモンキーパッチがあたり、Rails 4.0 方式のセッション flash を Rails 3.2 でも読めるようになります。

ActiveRecord が生成するクエリの変化によるスロークエリ

自動テストでは気付きにくい点ですが、ActiveRecord が生成するクエリの変化によりスロークエリになっている箇所がありました。

first を使ったときの ORDER BY id ASC

User.first と書いたときに、Rails 3.2 では SELECT users.* FROM users LIMIT 1 というクエリが生成されていましたが、Rails 4.1 では SELECT users.* FROM users ORDER BY users.id ASC LIMIT 1 になりました。 この ORDER BY がつくようになった影響でスロークエリになっていたものが何箇所かありました。

一般に ORDER BY をつけなかった場合に LIMIT 1 でどのレコードが取得されるかは不定です。 しかし、対象が一意に決まるような条件式を与えたときには ORDER BY は不要です。 このような場合には take を使います。User.take ならば SELECT users.* FROM users LIMIT 1 というクエリになります。

scope 内での current_scope の変化

例えば以下のような Entry モデルがあったとします。

class Entry < ActiveRecord::Base
  CATEGORY_ALPHA = 1

  scope :recent, lambda { where('id >= ?', Entry.maximum(:some_id)) }
  scope :with_category, lambda { |n| where(category: n) }
end

このとき、Entry.with_category(CATEGORY_ALPHA).recent とすると、Rails 3.2 では

/* Rails 3.2: Entry.with_category(CATEGORY_ALPHA).recent */
SELECT MAX(some_id) AS max_id FROM `entries`;
SELECT `entries`.* FROM `entries` WHERE `entries`.`category` = 1 AND (id >= 42);

のようなクエリが生成されていました。 一方 Rails 4.1 では

/* Rails 4.1: Entry.with_category(CATEGORY_ALPHA).recent */
SELECT MAX(`entries`.`some_id`) AS max_id FROM `entries` WHERE `entries`.`category` = 1;
SELECT `entries`.* FROM `entries` WHERE `entries`.`category` = 1 AND (id >= 42);

のようなクエリが生成されます。このように、scope の中でクエリを実行したときにそれまでの scope が引き継がれるようになりました。 したがって、Entry.recent.with_category(CATEGORY_ALPHA) のように scope の適用順序を変えるとクエリも変わります。

/* Rails 4.1: Entry.recent.with_category(CATEGORY_ALPHA) */
SELECT MAX(`entries`.`some_id`) AS max_id FROM `entries`;
SELECT `entries`.* FROM `entries` WHERE (id >= 42) AND `entries`.`category` = 1;

scope の中でこのようなことをしたい場合、明示的に unscoped を使うことで回避しました。

class Entry < ActiveRecord::Base
  CATEGORY_ALPHA = 1

  scope :recent, lambda { where('id >= ?', Entry.unscoped.maximum(:some_id)) }
  scope :with_category, lambda { |n| where(category: n) }
end

これならば、Entry.with_category(CATEGORY_ALPHA).recent でも Rails 3.2 と同じクエリが生成されます。

actionpack-xml_parser の切り出し

Rails 3.2 までは JSON だけではなく XML を入力した場合でもリクエストパラメータとして取得することができました。 しかし、この機能は Rails 4.0 から Rails 本体から切り出されて actionpack-xml_parser という gem になりました。 比較的新しい API はすべて JSON フォーマットでやりとりしている一方、古い API では XML フォーマットも使っているため actionpack-xml_parser が必要でした。 コントローラのテストでは params をハッシュで渡しているのでこの変化に気付かず、実際のアプリの接続先をアップグレード後のサーバに向けるとなぜかパラメータが空になる……という現象が発生しました。

まとめ

ここで紹介した修正以外にも、ActiveRecord の記法の変化のような単純な構文上の修正や、Rails の内部実装に強く依存した実装やモンキーパッチの修正などを行いました。 それにより、最終的に Rails のアップグレードに成功しました。 レスポンスタイムの悪化が懸念されていましたが、API サーバで若干悪化したものの、想定の範囲内に収まっています。

この記事がこれから Rails 4.x へのアップグレードをする方の参考になったら幸いです。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/