はじめに
JPRゲーム事業本部開発基盤部の @namusyaka です。
業務ではDeNAのゲームプラットフォームである Sakasho のバックエンドやインフラ周りの開発・運用をしています。
そして最近アイコンを8~9年ぶりくらいに変えました。よろしくお願いいたします。
さて本題ですが、Sakashoでは今年の2月に管理アプリケーションのRuby・Railsのバージョンの大幅なアップグレードを実施しました。この記事ではそのアップグレード対応について、一つの事例として紹介させていただければと思います。
概略
冒頭でも触れましたが、アップグレードしたのはDeNAのモバイルゲームプラットフォームであるSakashoの機能を制御するための管理アプリケーションになります。
Sakashoとは
Sakashoは、DeNAが持つモバイルゲームのためのプラットフォームです。 モバイルゲームを開発するために必要な機能を一通り提供し、ゲームの開発の効率化を図るための共通基盤として開発・運用されています。
Sakasho全体のアーキテクチャは次のようなイメージです。
今回のアップグレードは、この図の右端に位置する管理アプリケーションが対象となります。 ところで、余談ではありますが、SakashoのAPIは原則としてRailsではなくSinatraをベースに実装されています。 機会があれば、そのアプリケーションアーキテクチャについても、今後紹介できればと思います。
管理アプリケーションの特性
主に次のような特性を持ちます。
- ruby-2.1.4/rails-4.1.1で動作している
- ゲームプラットフォームが持つ全機能に対する制御系統を持つ。
- ゆえに、コードベースがかなり大きい。
- 複数DBを前提とした作りになっている。
- 一部シャーディングもしている。
- テストはある程度揃っている
実際の規模感をお伝えするために、rake stats
の実行結果を貼っておきます。
+----------------------+--------+--------+---------+---------+-----+-------+
| Name | Lines | LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Controllers | 19075 | 15518 | 247 | 2224 | 9 | 4 |
| Helpers | 1879 | 1657 | 0 | 225 | 0 | 5 |
| Models | 21974 | 16928 | 581 | 1442 | 2 | 9 |
| Mailers | 0 | 0 | 0 | 0 | 0 | 0 |
| Javascripts | 3000 | 2763 | 0 | 620 | 0 | 2 |
| Libraries | 10018 | 7358 | 96 | 395 | 4 | 16 |
| Tasks | 4416 | 3132 | 2 | 15 | 7 | 206 |
| Config specs | 23 | 20 | 0 | 0 | 0 | 0 |
| Controller specs | 45014 | 40239 | 28 | 4 | 0 | 10057 |
| Helper specs | 850 | 727 | 0 | 1 | 0 | 725 |
| Lib specs | 9852 | 8384 | 1 | 10 | 10 | 836 |
| Model specs | 17982 | 15974 | 0 | 3 | 0 | 5322 |
| Support specs | 1038 | 943 | 2 | 13 | 6 | 70 |
| Validator specs | 396 | 328 | 2 | 7 | 3 | 44 |
+----------------------+--------+--------+---------+---------+-----+-------+
| Total | 135517 | 113971 | 959 | 4959 | 5 | 20 |
+----------------------+--------+--------+---------+---------+-----+-------+
Code LOC: 47356 Test LOC: 66615 Code to Test Ratio: 1:1.4
簡単に見方を説明すると、LOCというのはLines Of Codeの略で、コードの行数を指します。 その他にも、例えばモデルと分類されるクラスが何個あるか、といった情報を読み取ることができるため、参考にしていただければ幸いです。
改修期間
対応のために掛けた期間は着手から本番リリースまで凡そ一ヶ月弱です。 大きく「開発」・「テスト」の2つのフェーズに分けることができ、それぞれにかかった期間としては次のようになります。
- ruby-2.4.0・rails-5.0.1にアップグレードし、全てのテストケースがpassすること (一週間)
- QAによるテスト (二週間)
開発期間中のアップグレード作業は私一人で進め、変更点のレビューなどはチームメンバー全体に依頼する形で進めました。 今思うと大分駆け足だった感はありますが、現時点で大きな障害もなく稼働しています。
なお、一応補足しますが、この記事は「テスト」ではなく「開発」のフェーズについての解説となります。
アップグレード戦略
前提となりますが、この管理アプリケーションはruby-2.1.4/rails-4.1.1で動作していました。
それらを目的のruby-2.4.0/rails-5.0.1にアップグレードするために参考にしたのが、Railsが公式に提供している A Guide for Upgrading Ruby on Rails です。
このアップグレードガイドによると、rails-5にアップグレードするには、対象のアプリケーションはrails-4.2でなければならないようです。 したがって、目標のアップグレードを実施するには段階を踏んでアップグレードする必要があり、まずはrails-4.1.1をrails-4.2.7.1にアップグレードしなければなりません。
実際のアップグレードの流れは次のとおりです。
- rails-4.1.1をrails-4.2.7.1までアップグレード
- ruby-2.1.4をruby-2.4.0までアップグレード
- ruby-2.4にバージョンをあげたらrails-4.2.7.1では動作しなかったので、railsの4-2-stableにこの時点でスイッチしました。
- rails-5.0.1までアップグレード
一つのフェーズごとにテストが全てpassすることをゴールと設定しています。
そして、各バージョンにアップグレードする前には、可能な限りdeprecation warningsを潰すようにしました。 これは、将来的に削除される機能などを事前に回避しておくことでトラブルを防ぐ意味合いがあります。
また、この戦略を進めるにあたり、リリースブランチとは別にアップグレード専用のブランチを用意するほか、ruby-2.4でテストするための専用のjenkins jobを用意して、通常のリリースフローとは独立する形で作業を進めました。
変更点
この手の作業はbundle update
を通す作業から始まります。
幸いにして5.0.1は去年の12月リリースだったこともあって、ある程度依存しているgemは対応済みで、gemに対してパッチを当てたりforkしたり、といったことは特にする必要はありませんでした。
新しいバージョンが出たばかりのHotな時期にアップグレードするのもいいですが、数年間アップグレードされてこなかった規模の大きいアプリケーションを対象とする場合は、数ヶ月程度の時間を置いてから進めた方がハマりどころが少なくて助かるかもしれません。
次に実際の変更点について紹介します。 しかしながらあまりに数が多すぎるため、特に影響範囲が大きかった変更と、deprecation warningsなどの中から、アップグレードガイドに記載されていないものを中心にいくつか取り上げようと思います。
rails-4.2.7.1対応
元がrails-4.1.1なので、まずは Upgrading from Rails 4.1 to Rails 4.2 に従って粛々と対応しました。 Upgrade guideに書いてあることは割愛するとして、主要な変更点を挙げます。
deprecations & minor changes
config.serve_static_assets
をserve_static_files
に変更rspec-rails
に起因するnamed_routes.helpers
参照のdeprecationを修正activerecord-import-0.6.0
に起因するserialized_attributes
の削除に向けたdeprecationの修正activerecord-import
を0.6.0から0.17.0にアップグレードすることで修正- 参考:
Deprecate
serialized_attributes
without replacement by sgrif · Pull Request #15704 · rails/rails · GitHub
ActiveRecord::ConnectionAdapters::Column.value_to_xxx
系のメソッドの削除- 4.2.7.1の時点では
ActiveRecord::Type::XXX.new.type_cast_from_database
を使うように修正 - 参考: Inline typecasting helpers from Column to the appropriate types by sgrif · Pull Request #15207 · rails/rails · GitHub
- 4.2.7.1の時点では
Rake::Application#last_comment
の削除rake
のバージョンを一旦は11.1.0に固定することで修正。- 参考:
Please add a
last_comment
removal/deprecation warning for now · Issue #116 · ruby/rake · GitHub
default_value_for
に起因してcan't modify frozen ActiveSupport::HashWithIndifferentAccess
でRails起動時に失敗するdefault_value_for
を3.0.0.1から3.0.2にアップグレードして修正
ActiveRecord::Base.find
にActiveRecord::Base
インスタンスを渡さないようにする- rails-4.2系から導入されたAdequate Recordに関連して、インスタンスを渡した場合にdeprecationが出るようになっていたので修正。
- 参考: add the deprecation to adequate record too · rails/rails@23ffd03 · GitHub
ActiveRecord::Base.connection#table_exists?
をActiveRecord::Base.connection#data_source_exists?
に変更delete_all(cond)
・destroy_all(cond)
のdeprecationを修正- それぞれ
where(cond).delete_all
・where(cond).destroy_all
を使うように修正 - 参考: Deprecate passing conditions to AR::Relation destroy_all and delete_all methods by morgoth · Pull Request #21505 · rails/rails · GitHub
- それぞれ
render text:
を将来的にdeprecatedとし、:body
・:html
・:plain
といったより直感的にmimetypeを判断しやすいようなoptionを使うように修正
Primary keyでないid
columnを持つテーブルにおいて、取得したレコードをinspectした結果、idがnilと表記される
id
というカラムは存在するが、primary keyではないケースで発生する不具合です。
不具合といってもinspectの結果がおかしい程度ではあるのですが、次のようなコードで正常にidが出力されない問題がありました。
Book.find_by(id: 1).inspect #=> "#<Book id: nil, ...>"
これについてはRails側を修正することで対応しました。たまたま見つけた不具合という感じです。
Pull Request: Fix inspection behavior when the :id column is not primary key by namusyaka · Pull Request #27935 · rails/rails · GitHub
t.timestamps
のデフォルトがNULLを許可
からNOT NULL
に変更
次のように指定することで解決しました。
create_table :books do |t|
t.timestamps null: false
end
従来どおりの挙動にしたければnull: true
とします。
動的にtableを生成するケースなどがもしあれば、要注意といえるでしょう。
JSON.load
にnil
を渡さないように修正
4.2.7.1では例外が出るようになっていました。
null
が返されることを期待しているわけでもなかったので渡さないように変更しました。
MySQL-5.6以上では時刻のミリ秒を保存するようになった
MySQLにミリ秒を持たせるのは別の意味でしんどくなりそうだったので、モンキーパッチで回避しました。
参考
- Rails-4.2+MySQL-5.6での時刻オブジェクトのミリ秒の扱いについて - Qiita
- MySQL 5.6 and later supports microsecond precision in datetime. · rails/rails@df5a38f · GitHub
ActiveRecord/ActiveModelが範囲外の値に対して例外を吐くようになった
例えば、次のようなコードについて考えます。
Book.where(id: -1).first
この処理はrails-4.1と4.2で挙動が異なり、4.1ではnil
が返り、4.2以降はActiveModel::RangeError: -1 is out of range for ActiveModel::Type::UnsignedInteger with limit X
といった例外が発生するようになっています。
ActiveModelにtype castingの機構が実装されたお陰だと思います。 ロジックに問題のあるコードがこれで顕在化されることになるため、これを機に既存の実装を見直しました。
ところで、この機構は明示的にlimitが指定されていない場合に、signed int(4bytes)を自動的にリミットとして定めてしまうという問題があります。
参考:
Avoid RangeError
without explicit limit
by kamipo · Pull Request #26302 · rails/rails · GitHub
Adequate Recordの影響で動的にテーブル名をセットしているところが壊れた件
Adequate Recordはactive_record/core.rb
に定義されている.find
・.find_by
・.find_by_xxx
系のメソッドに対して、ActiveRecord::StatementCache
を用いてキャッシュを有効化する機能を指します。
Arelを経由したSQLへの変換を行わなくて良くなるので二倍ほど高速化が見込めるという話のようです。
この機能自体は非常に素晴らしいのですが、これを実現するために内部的に使用されているキャッシュキーはprimary keyをベースにしたものとなっており、テーブル名については一切考慮されていませんでした。 したがって、テーブル名が動的に変わった場合もprimary keyが一致すればキャッシュが効いてしまうので、誤ったSQLを発行してしまうリスクが存在しています。
無論そんな使い方をするなという話もあるかもしれませんが、動きそうなところが動かないのは困るということで、Railsを修正しました。
直し方としてはtable_name=
が実行されたらキャッシュをリセットするように修正する、というものです。
Pull Request:
Make table_name=
reset current statement cache by namusyaka · Pull Request #27953 · rails/rails · GitHub
ちなみにこれを回避するには、Adequate Recordが効かないQueryMethodsを使ってやるだけで良いので、次のように書き換えるのが簡単です。
Book.find_by(id: 1) # before
Book.where(id: 1).first # after
ruby-2.4.0対応
rails-4.2.7.1の対応を終えた後に、ruby-2.1.4からruby-2.4.0にアップグレードしたところ全く動きませんでした。
どうやら4.2.7.1はruby-2.4.0に対応していないようで、ruby-2.4にアップグレードする前に一旦4-2-stable
を使うように変更しました。
なお、私が対応していた時点では4.2系のバージョンの中では4.2.7.1が最新版でしたが、つい先日 4.2.8がリリースされ、公式にruby-2.4がサポートされています 。今後アップグレードする際には4.2.8を使用することをオススメします。
さて、この項ではruby-2.1.4からruby-2.4.0にアップグレードしたことで発生した主要な問題点を挙げてみます。といっても、Railsほど苦労はなかった印象です。
Fixnum
をInteger
に統合
軽く書いてはいますが、native extension系のgemは要注意です。 以下は具体例です。
- jsonの例: Ruby 2.4.0 で導入予定の Integer Unification まとめ, 夏休みで北海道へ - HsbtDiary(2016-08-29)
- therubyracerの例: therubyracerのRuby 2.4.0対応の進捗 - koicの日記
アップグレードする対象のアプリケーションが依存するgemのruby-2.4 対応状況については事前に洗い出しておいた方が良さそうです。 必要であればパッチを送り、新バージョンのリリースをねだりましょう。
サブクラスの抽出に自前のObjectSpace#each_object
ではなくActiveSupportのコア拡張であるClass#subclasses
を使うように変更
ruby-2.3.0からObjectSpace#each_object
がシングルトンクラスを含むようになりました。
SakashoではClass#subclasses
の自前実装をObjectSpace#each_object
をベースに持っていて、そちらを修正しようとも考えたのですが、ActiveSupportのコア拡張でほぼ同じことをやっていたので、そちらを使うようにして修正しました。
参考
- Bug #11360: Singleton class doesn’t appear by ObjectSpace.each_object - Ruby trunk - Ruby Issue Tracking System
-
Exclude singleton classes from
subclasses
anddescendants
· rails/rails@4e73ffa · GitHub
openssl
系の標準添付ライブラリにて、keyやivの文字数がvalidでないと例外が発生するようになった
もともとの実装では、文字数がオーバーしている場合は丸められていたようです。 したがって、事前に文字列を適切にsliceするように変更して修正しました。
参考: openssl: make Cipher#key= and #iv= reject too long values · ruby/ruby@ce63526 · GitHub
rails-5.0.1対応
rails-5.0.1にアップグレードする際にも、まずは Upgrade guide に沿って進めました。
5へのアップグレードには設定ファイルの追加やinitializerの追加などを含むため、そのあたりを中心に対応を自動化するための機構として、app:update
というRakeタスクが提供されています。
対話的に大きな差分を自プロダクトに反映していくことになりますが、ある程度既存のコードにも影響のある部分なので、しっかり差分を注視しながら進めた方が良さそうです。
このフェーズについても、Upgrade guideの内容は割愛し、主要な変更点を挙げていこうと思います。
deprecations & minor changes
ActiveModel::Errors#messages
の挙動が変わった- rails-4系では
person.errors.messages[:foo]
はnil
を返すが、rails-5からは[]
を返すようになった。 - そもそもmessagesからわざわざ参照する必要もないので、
person.errors[:foo].present?
などとしてやるとよい。
- rails-4系では
- 暫定的に
config.enable_dependency_loading
を有効化eager_load_paths
に適宜ロードしたいパスを加えるのが妥当っぽい。- 参考
scope
で既存のscopeを上書きしないように変更
ActionController::Parameters
がHashを継承しなくなった
多くの場所で取り上げられている変更点ですが、例に漏れず対応が困難だったもののうちの一つです。 Hashが持つメソッドや振る舞いに依存しているコードの多くが正常に動作しなくなるため、アップグレードする際には事前にその辺を洗い出しておくと楽かもしれません。
参考: Make AC::Parameters not inherited from Hash by sikachu · Pull Request #20868 · rails/rails · GitHub
datetime_select
に入力された値をDateTimeとして取得する際に、受け取った値をそのままhidden_field
やtext_field
に渡すとRubyのHashをinspectした値がvalueに埋め込まれてしまう件
例えば次のような要素があるとします。
= f.datetime_select :opened_at, use_month_numbers: true, start_year: default_start_year
このヘルパーは、日付にかかる情報を扱うために複数のselect要素をレンダリングします。
そして、opened_at(1i)
・opened_at(2i)
…といった複数のキーからなるopened_atにかかる情報をサーバに送信し、それらのパラメータをActiveRecord::Base
インスタンスに渡してやることで、よしなにopened_at
に時刻情報が代入されるという作りになっています。
これはもとからRailsが持つ機能ではありますが、Sakashoでは、更にこのsubmitされた値を確認画面などでhidden_fieldに埋め込むケースが非常に多いです。
サンプルとしては、次のようなコードになります。
= f.hidden_field :opened_at
form_for
などに代表されるこれらのform methodは、第一引数で受け取ったカラム名を用いて、対象となるインスタンスに対して実行し、その結果をvalue属性に代入します。
今回はこの代入される値に問題がありました。次のようになります。
<input type="hidden" value="{1=>2017, 2=>2, 3=>27, 4=>21, 5=>0, 6=>0}" name="archive[opened_at]" id="archive_opened_at" />
これは、内部的に実行されるxxx_before_type_cast
という、その値をキャストする前の値を返す機構に起因します。
今回のケースでは、opened_at
にはもともとdatetime_select
によって生成された複数のselect要素群から作られたHashが代入されています。
当然、validationを通過した結果の確認画面ではそのHashがopened_at
の値として埋め込まれており、確認画面を経て、いざ作成しようとした際にエラーが発生して保存できない、といった問題が発生することになります。
この問題についてはPull Requestを投げてはいるものの、まだレビュー待ちという状態です。
反省点
今回は短期間で一気にアップグレードを実施しましたが、本来であれば計画的に実施すべきだと思います。
Railsを使ってアプリケーションを作って終わりではなく、定期的に依存するgemのアップグレードに追従しようとする姿勢こそがRailsと付き合っていくコツといえるでしょう。
そこでSakashoでは、bundle update
を自動で行い、アップグレードされたgemの差分を表示しつつ、そのGemfile.lockの変更差分をコミットするPull Requestを自動で作成する機構を導入しました。
これにより、普段の業務でも自分たちが採用しているossのリリースを考慮して取り組めるようになると考えています。
おわりに
大規模なRailsアプリケーションで、かつ長らくアップグレードされてこなかったプロダクトを最新バージョンにアップグレードするのは非常に骨の折れる作業でしたが、短期間で集中的に取り組めたことは良い経験となりました。
また、管理アプリケーションとしての特性上複数DBを前提としているなど、一般的なRailsアプリケーションのそれとは異なる構成であり、それをきっかけとして発見したRailsのいくつかの不具合を修正することができました。
このような環境下で運用されるRailsアプリケーションはそう多くはない認識ですし、Sakashoのような環境でしか発生しない不具合はまだ存在するはずです。Railsを利用する立場として、そういった不具合に積極的に対処してコミュニティに還元していく姿勢が今後重要であると考えています。
最後になりますが、Railsを採用される際には、用法用量を守って正しくお使いください。
最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。