過去に書いたソースコードを読んでいて、仕様を理解するのに手間取ってハマったので、共有のために記事を書いておきます。
以下のようなコードを見かけたとします。
user = User.find_or_initialize_by(email: 'sample@precena.com') do |user|
user.last_name = 'サンプル'
user.first_name = '太郎'
end
上のコードから、どのような仕様を想像するでしょうか?
筆者は、ユーザーが見つかった場合、または、初期化した場合、どちらもlast_name
、first_name
がそれぞれ、サンプル
と 太郎
に初期化されると思ってしまいました。
しかし、実際の挙動としては、以下でした。
ユーザーが見つかった場合は、ブロック内の処理は実行されず、
ユーザーをinitializeした場合は、ブロック内の処理が実行される。
実際に、コードを見てみると、以下のようになっています。
def find_or_initialize_by(attributes, &block)
find_by(attributes) || new(attributes, &block)
end
コードを見ても、上記の挙動が実装されているのが確認でき、find_by
で該当レコードが見つかった場合には、&block
部分が無視されるのが分かります。
本サイトの更新情報は、Twitterの株式会社プレセナ・ストラテジック・パートナーズエンジニア公式アカウントで発信しています。ご確認ください。
この記事では、Railsの本番環境におけるアプリケーションサーバー(pumaやunicornなど)のプロセス数とスレッド数のパラメータ設定に関する情報をまとめます。
アプリケーションサーバーのパラメータを設定する際に、OSにおけるプロセスとスレッドは重要な要素になるので、あらかじめ概要を説明しておきます。
プロセスはOSで実行中のプログラムのことで、プロセス内には1つ以上のスレッドが含まれます。CPUのコアに命令をしているのがスレッドです。
複数のプロセス同士は、同じメモリ領域を共有できません。しかし、同じプロセス内のスレッド同士は、同じメモリ領域を共有できるので、プロセス内に複数のスレッドを作成した方がメモリの利用効率が上がります。
複数のスレッドが同じメモリ領域を共有すると、メモリの利用効率は上がりますが、誤って別のスレッドに影響する情報を書き換えてしまう可能性もあります。いわゆるスレッドセーフではない状態が起こるということです。
1つのスレッドしかないプロセスを複数使用すれば、スレッドセーフかどうかを考慮する必要はなくなりますが、複数のスレッドを使う場合と比較して、メモリの利用効率は下がってしまいます。
1つのプロセス内に複数のスレッドを作った場合のメリットとして、一部のスレッドがIO待ち(例えば、データベースへのリクエストとそのレスポンスを待っているような状態)になってしまっても、プロセス内の他のスレッドで別の処理を進めることができるという点があります。この場合、プロセスとしてのスループットが大きくなります。
もし、1つのプロセスで1つのスレッドしか用意しなかったとしたら、そのスレッドでIO待ちが発生すると処理がブロックしてしまいます。
Railsで使うアプリケーションサーバーとして一般的なものにpumaとunicornがあります。
pumaは、マルチプロセス対応で、さらに、各プロセス内に複数のスレッドを作成することができます。
unicornは、マルチプロセス対応ですが、各プロセス内には複数のスレッドを作りません。
したがって、スレッド間でメモリを共有できる分、メモリ効率はpumaの方が良くなりやすいです。ただし、pumaを使う場合は利用しているgemも含めてスレッドセーフな実装でアプリケーションを作っておく必要があります。
一方、unicornは、メモリ効率はpumaと比較して良くありませんが、スレッドセーフであるかどうかを気にする必要がありません。また、詳細な説明は省略しますが、稼働中のサーバーにデプロイを行う際にゼロダウンタイムのデプロイをすることが可能、などの特徴もあります。
この記事では、Rails標準のアプリケーションサーバーであるpumaを使う想定で、以降の説明をします。
Ruby には、Giant VM lock (GVL)とよばれるものが存在します。Giant VM Lockが効いている間は、ネイティブスレッドがロックされ、実質1つのスレッドしか同時に実行できません。
しかし、GVLについては、Ruby 3.0.0のリファレンスマニュアル(上のリンク先)には、以下のように記載されています。
ネイティブスレッドを用いて実装されていますが、現在の実装では Ruby VM は Giant VM lock (GVL) を有しており、同時に実行されるネイティブスレッドは常にひとつです。ただし、IO 関連のブロックする可能性があるシステムコールを行う場合には GVL を解放します。その場合にはスレッドは同時に実行され得ます。また拡張ライブラリから GVL を操作できるので、複数のスレッドを同時に実行するような拡張ライブラリは作成可能です。
詳細な説明は省略しますが、要するに、Rubyのスレッドは、IO関連のシステムコールを行う場合にはGVLを開放するので、その場合は複数のスレッドを実行できます。しかし、逆にIO関連以外のシステムコール(例えば数値計算など)の場合には、GVLが効いてしまい、同時に1つのスレッドしか実行されません。
Webアプリケーションにおいては、IO関連のシステムコール(例えばネットワーク越しのリクエスト・レスポンス処理)が使われることも多くなるため、アプリケーションサーバーが、マルチスレッド対応をすることのメリットはあると考えられます。
前置きが長くなりましたが、ここからが本題です。
一般的なRailsアプリケーションを実行する本番環境のプロセス数とスレッド数を設定するには、以下を考慮に入れます。
CPU数(環境によっては、vCPU数、仮想CPU数)
利用可能なメモリの量
1スレッドで必要なメモリの量
Railsで設定しているコネクションプールの数
webとjobワーカーの数
データベースの同時接続数上限
CPU数は、利用しているサーバーのCPUコアの数です。クラウドのような仮想環境を利用している場合は、物理的なコア数ではなく仮想CPU数(vCPU数)を把握する必要があるかもしれません。
CPUの数は、設定可能なプロセス数に影響を及ぼします。どのように影響するかについて、Herokuの記事では、以下のように言及しています。
Due to the GVL, the Ruby interpreter (MRI) can only run one thread executing Ruby code at a time. Due to this limitation, to fully make use of multiple cores, your application should have a process count that matches the number of physical cores on the system.)
(訳注:IO待ちの場合は、GVLが開放されるとはいえ)GVLを考慮するとCPUの機能をフル活用するなら、Rubyコードのプロセス数は、CPUのコア数に一致させるのがよい、と記載されています。
利用しているサーバーのメモリ量も把握する必要があります。
スレッドごとにRailsアプリケーションの処理が実行されるので、スレッド数が増えればそれだけ必要なメモリ量も大きくなります。また、プロセス数が増えれば必然的に包含するスレッドも最低1つは増えますので、プロセス数を増やす場合も、必要なメモリ量は大きくなります。
利用しているサーバーのメモリ量は、プロセス数やスレッド数の上限を考えるのに必要です。
1スレッドで必要なメモリ量も把握する必要があります。
1スレッドで必要なメモリ量が例えば、400MB前後だとすると、スレッド数を1、2…と増やしていった場合に、必要なメモリは概ね400MB、800MB…と比例して増えていきます。
1スレッドで必要なメモリ量は、アプリケーションの実装によって幅が出てきます。筆者の経験では、Railsアプリケーションの本番環境では、概ね300MB〜1GBくらいの幅で変わるように思います。
もしすでに本番環境やステージング環境を運用しているのであれば、その環境でメトリクス計測ツール(New RelicやHerokuのMetrix機能)で確認するのが確実です。
稼働している本番環境やステージング環境で、実際に合計何スレッドが動作しているのかが分かれば、本番環境で利用しているメモリ量をその合計スレッド数で割れば、1スレッドで必要なメモリの量を概算することができます(厳密には、スレッド間で共有しているメモリがあるはずです。ここで計算しているのは、あくまで概算値です)。
Railsには、コネクションプールの仕組みがあり、以下のように、database.yml
のpool
の値で上限値を指定することができます。
default: &default
adapter: postgresql
host: localhost
pool: 5
timeout: 5000
username: postgres
password: postgres
encoding: utf8
development:
<<: *default
database: sample_app_development
test:
<<: *default
database: sample_app_test
コネクションプールとは、Railsの処理がデータベースにアクセスするたびにコネクション接続と切断を行って負荷が高くなったり、パフォーマンスが低下するのを防ぐために、予め決められた上限数を考慮してデータベースとの間に作っておく接続のグループのことです。
また、このコネクションプールは、各プロセスごとに作られます。プロセス間で共有のプールを持つことはできません。
コネクションは、スレッドの処理でデータベースへの接続が必要になった場合に、コネクションプールからスレッドに1つ割り当てられ、処理が終わるとプールに戻されます。
コネクションプールの値がスレッド数より少ない場合、プール数よりも多いデータベースへのアクセスリクエストが発生した際に、新しいスレッドにデータベースとの接続を割り当てることができなくなり、コネクションの割当待ちのような状態になります。
したがって、この割当待ちによるスループットの低下を防ぎたい場合は、
のような関係を保つように、database.ymlを設定する必要があります。
Herokuの記事では、コネクションプール数とスレッド数を同じ値にすることを推奨しているようです。実際に、これらの値が同じであっても上記の条件は満たされますし、スレッド数よりも過剰に大きいコネクションプール数を作成したとしても、その分サーバー上のメモリが無駄になるため、基本的にはHerokuの推奨のやり方に従うのが良いでしょう。
一般的なRailsアプリケーションにおいて、データベースに接続するのは、pumaやunicornのようなアプリケーションサーバーからだけではなく、sidekiqのようなjobワーカーも存在します。
次節で説明するように、データベース側に接続上限数が存在するため、jobワーカーが存在するか、そして、それが存在する場合どれくらいの数があり、合計何スレッドくらい実行されるか、も考慮にいれる必要があります。
データベース側の同時接続数の上限値も把握しておく必要があります。
データベース側の同時接続数が分かれば、全サーバーの全プロセスで作られるスレッド数の合計値が、その同時接続数を超えないようにパラメータを調整しなければなりません。
したがって、結果的に、データベースの同時接続数は、各サーバーのプロセス数やスレッド数の上限値に影響します。
これまでの説明を考慮すると、メモリに関するパラメータを除外すれば、少なくとも以下のような大小関係がなりたつように、各パラメータを設定する必要があります。
ただし、この大小関係を満たすという条件は変わりませんが、実際は、次の節で考慮に入れる利用可能なメモリ量で頭打ちになることが多いように思います。
上記の大小関係とは別に、メモリによってもパラメータの大小関係が決まってきます。
これは、単純に、以下になります。
最後に、当社で良く利用するHeroku環境の例をもとに、具体的な設定例を考えてみます。
以下のような架空の本番環境を想定することにします。
項目
値
2
利用可能なメモリの量(premium Mで利用可能なメモリ量)
2.5GB
1スレッドで必要なメモリの量(本記事用に、適当に想定)
600MB
Railsで設定しているコネクションプールの数(スレッド数と同じ値を設定)
あとで計算
アプリケーションサーバーとjobワーカー数 (説明を簡単にするため、アプリケーションサーバー数(web dynoの数)を2、jobワーカー数を0と想定)
2
データベースの接続上限数(Heroku Postgresのmax connectionsの数)
公式サイトを参考に確認する。今回の想定はstandard-2とする
400
CPU数は2なので、プロセス数の適切な値は2です。
ただし、メモリ関連のパラメータの大小関係を考慮すると、1スレッドで必要なメモリ量が600MBなので、合計スレッド数 × 600 <= 2500MB
となるように、合計スレッド数を4にすると良さそうです。
したがって、仮にプロセス数を2とすると、設定可能なスレッド数は2です。もし、プロセス数を1とすると、スレッド数は4です。
おそらく、後者の方がメモリの利用効率は高そうですが、GVLを考慮すると、アプリケーションによっては前者の方がスループットが大きい可能性もあります。これは、実際にステージング環境などで、どちらが良さそうかを確認すると良いかもしれません。
ここでは、仮に、プロセス数を2、スレッド数を2にすると決めたことにします。また、これに合わせて、コネクションプール数は2にします。
web dynoは、2つ使用するので、各web dynoごとに2プロセス、2スレッド作る場合、プロセス数は、合計で2 x 2= 4だけ作成することになります。
さて、この前提で、合計のデータベース接続数を念の為に計算すると、
となるため、データベースの接続上限数の400には、まだまだ余裕があり、問題ありません(この前提の場合、もっとスペックの低いデータベースを使用してもいいのかもしれません)。
pumaを使用する想定の場合、デフォルトでは、/config/puma.rbに以下のような起動設定が用意されています。
なお、実際には、説明用のコメントもつけられているはずですが、コードが長くなるので、以下では割愛しています。
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
threads threads_count, threads_count
port ENV.fetch("PORT") { 3000 }
environment ENV.fetch("RAILS_ENV") { "development" }
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
preload_app!
plugin :tmp_restart
rackup DefaultRackup
この設定ファイルの
threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
の部分がスレッド数を指定している部分で、
workers ENV.fetch("WEB_CONCURRENCY") { 2 }
の部分がプロセス数を指定している部分です。
したがって、具体的なパラメータとしては、RAILS_MAX_THREADS
を2に、WEB_CONCURRENCY
を2に設定することになります。設定しない場合、このコードの例では、デフォルト値としてスレッド数5とプロセス数2が使われるので、サーバーのスペックが不十分な場合は、障害が発生するかもしれません。
本サイトの更新情報は、Twitterの株式会社プレセナ・ストラテジック・パートナーズエンジニア公式アカウントで発信しています。ご確認ください。
先日、Rails6.1系で動いていたシステムをRails7.0系にアップグレードしました。
そのシステムは
Rails製APIアプリケーション
コード量・利用者ともに小規模
テストコードが充実している
と比較的アップグレードしやすかったこともあり、無事に完了しました。
この記事ではアップグレードの際に調べたことをまとめておきます。
なお、作業はピクシブ株式会社さんの永久保存版Railsアップデートガイドを参考にしながら進めました。ありがとうございました。
Rails6.1から7.0へアップグレードを計画した段階で、Railsの公式BlogRails7.0系のリリースノートを読みました。
また、Railsガイドのアップグレードガイドの Rails 6.1からRails 7.0へのアップグレードました。
資料を読み終えたところで、対象のシステムは問題なさそうと判断し、準備を進めることにしました。
Rails7.0がリリースされた直後は、使っているgemがRails7.0対応していないものがありました。
そこで、定期的に各gemのリポジトリを見に行き、Rails7.0対応に関するissueやPull Requestを確認しました。
その後、使用しているすべてのgemがRails7.0対応がなされているのが確認できたため、具体的な作業に入りました。
今回は小規模だったこともあり、
bundle outdated
でバージョンアップできるgemを確認
Rails以外を bundle update --conservative <gem名>
でバージョンアップ
--conservative
オプションにより、指定したgemと指定したgemが直接依存しているgemのみバージョンアップするようになります (Bundlerの公式ドキュメント)
Railsを bundle update --conservative rails
でバージョンアップ
の順で作業をしました。
また、各gemをバージョンアップするごとに、テストを流してパスすることを確認した上でGitブランチにコミットしました。
次に、6.1系と7.0系間のRailsDiffを見て各種設定ファイルの差分を確認しました。
まず、削除された設定について見てみましたが、主に
Rails7から、Springがデフォルトに含まれなくなったため、Springに関する設定だった
Rails7で config/initializers/
以下が整理されたが、対象システムでは使っていない設定だった
ため、システムから削除しても問題なさそうでした。
次に、設定ファイルの変更で気になった点(後述)については調査を行いました。その結果、変更による影響がなさそうだったため、Rails7.0系での変更を各種設定ファイルに取り入れました。
なお、調査をする際はTechRachoさんの記事が参考になりました。ありがとうございました。
ここでは、今回気になった点とその対応について以下にまとめます。
RailsDiffでの差分を引用します。
class ApplicationRecord< ActiveRecord::Base
- self.abstract_class = true
+ primary_abstract_class
end
TechRachoさんの記事を読むと primary_abstract_class
の方が良さそうでしたので、差し替えました。
RailsDiffでの差分のうち、気になった点は以下でした。
+ config.server_timing = true
- config.assets.debug = true
- config.file_watcher = ActiveSupport::EventedFileUpdateChecker
TechRachoさんの記事を読むと、server timing ミドルウェアは便利そうでしたので追加することにしました。
一方、
config.assets.debug
は使っていないシステムだった
config.file_watcher
はDockerを使っていると影響ありそうな情報が散見されるものの、今回の環境では不要そうだった
ため、これらは設定ファイルから削除することにしました。
RailsDiffの差分のうち、気になった点は以下でした。
- # Send deprecation notices to registered listeners.
- config.active_support.deprecation = :notify
-
- # Log disallowed deprecations.
- config.active_support.disallowed_deprecation = :log
-
- # Tell Active Support which deprecation messages to disallow.
- config.active_support.disallowed_deprecation_warnings = []
+ # Don't log any deprecations.
+ config.active_support.report_deprecations = false
config.active_support.report_deprecations = false
については、TechRachoさんの記事 によると一括でdeprecation warningを消せるオプションでした。 ただ、できる限りdeprecation warningは表示したいため、 true
にしておきました。
他の項目については、対象のシステムで無効にしていた設定だったため、対応は不要でした。
RailsDiffの差分のうち、気になった点は以下でした。
- # Do not eager load code on boot. This avoids loading your whole application
- # just for the purpose of running a single test. If you are using a tool that
- # preloads Rails for running tests, you may have to set it to true.
- config.eager_load = false
+ # Eager loading loads your whole application. When running a single test locally,
+ # this probably isn't necessary. It's a good idea to do in a continuous integration
+ # system, or in some way before deploying your code.
+ config.eager_load = ENV["CI"].present?
この変更を提供したところ、Github Actionsのデフォルトの環境変数 では CI = true
が設定されていたことから config.eager_load
が true
になってしまい、CI/CDまわりで不具合が出ました。
そのため、 config.eager_load
は従来のまま false
と明示的に設定しました。
RailsDiffで差分が出たデフォルト値に、 config/application.rb
の中のconfig.load_defaults
があり、値が 7.0
に変わっていました (該当箇所)。
そこで、Railsのデフォルト値の変更を調べることにしました。
まず、7.0
とした場合の影響については Railsガイドの load_defaults の結果 を参照しながら調査しました。
調査する中で、 rails7へのバージョンアップを安全に行うために使用するnew_framework_defaults_7_0.rbの各項目をさらっと解説 - Qiita に変更内容がまとまっていたため、参考にいたしました。ありがとうございます。
上記の記事にある項目を確認しましたが、今回のシステムに対して影響するものはありませんでした。
次に、さきほどのQiitaの記事で触れられていなかったものについて調査しました。それらを以下にまとめます。
Railsガイドには
ある種のRubyコアクラスに含まれる#to_sメソッドの上書きを無効にします。この設定は、アプリケーションでRuby 3.1の最適化をいち早く利用したい場合に使えます。
とあります。
今回は小規模なシステムだったこともあり影響がなかったため、デフォルト値の変更を受け入れました。
edgeのRailsガイド に詳細な記載がありました。
TechRachoさんの記事 を読み、今回のシステムには影響しなかったため、デフォルト値の変更を受け入れました。
edgeのRailsガイドに詳細がありました。
In Rails 7.1 and beyond, Active Storage has_many_attached relationships will default to replacing the current collection instead of appending to it.
とのことですが、今回のシステムには影響しなかったため、デフォルト値の変更を受け入れました。
Rails7.0系にアップグレード後も開発・運用を継続していますが、今のところ大きな問題は発生していません。
今後もRailsのバージョンアップを継続して行うとともに、作業で気になったこと等はTechBookに記載していこうと思います。
本サイトの更新情報は、Twitterの株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。
Railsには、スキーマファイルと呼ばれる schema.rb
があります。
Railsガイドには
Active Recordはマイグレーションの時系列に沿ってスキーマを更新する方法を知っているので、履歴のどの時点からでも最新バージョンのスキーマに更新できます。Active Recordは
db/schema.rb
ファイルを更新し、データベースの最新の構造と一致するようにします。
とあるように、schema.rb
はデータベース関連の機能で使われます。
その schema.rb
ですが、チームで開発をしている時に
自分の作業を中断して、プルリクエストのレビューをする
自分の作業に戻って、開発を続ける
を繰り返している中で、意図しない差分が schema.rb
に発生していると気づき、戸惑うことがあるかもしれません。
そこで、schema.rb
に差分が発生してしまうことについて
発生する事例
差分が発生していると何が起こるか
どう復旧するか
を記事としてまとめます。
発生する事例として、「どのような流れで発生してしまうのか」を体験できるチュートリアルを用意しました。
チュートリアルは以下の流れで進めます。なお、Railsのバージョンは 7.0.4.2
を想定しています。
mainブランチで、Railsのモデルを作成
mainから派生した第2のブランチ(feature/add_unique_index
)で、モデルの変更を伴うマイグレーションを適用
mainから派生した第3のブランチ(feature/add_column
)で、モデルの変更を伴うマイグレーションを適用
最小構成で rails new
します。後ほどgemを追加するため、現時点では bundle install
を行いません。
% bundle exec rails new schemaapp --minimal --skip-bundle
% cd schemaapp
今回の動作確認をRSpecで行うため、 Gemfile
の末尾に追加します。
group :test do
gem 'rspec-rails', '~> 6.0.1'
end
Gemfileの準備ができたため、一連のgemをインストールします。
% bundle install
RSpecのセットアップも行います。
% bin/rails g rspec:install
続いてモデルを用意します。今回は isbn
列を持つBookモデルとします。
% bin/rails g model Book isbn:string
マイグレーション適用後に、状態を確認しておきます。
% bin/rails db:migrate
% bin/rails db:migrate:status
database: db/development.sqlite3
Status Migration ID Migration Name
--------------------------------------------------
up 20230306095752 Create books
ここまでをコミットします。
(もし vendor/bundle
以下をコミットしたくない場合は .gitignore
に追加しておきます)
% git init
% git add .
% git commit -m "first commit"
feature/add_unique_index
)で、モデルの変更を伴うマイグレーションを適用main
ブランチから第2のブランチ( feature/add_unique_index
)を新しく作成し、「Bookの isbn
にUNIQUE制約を追加する」ためのマイグレーションファイルを生成します。
% git checkout -b feature/add_unique_index
% bin/rails g migration AddIndexToBook
生成したマイグレーションファイルにて、Bookの isbn
にUNIQUE制約を追加します。
class AddIndexToBook < ActiveRecord::Migration[7.0]
def change
add_index :books, :isbn, unique: true
end
end
マイグレーションを適用後、状況を確認します。
% bin/rails db:migrate
% bin/rails db:migrate:status
database: db/development.sqlite3
Status Migration ID Migration Name
--------------------------------------------------
up 20230306095752 Create books
up 20230306104025 Add index to book
作業が終わったため、コミットします。
% git add .
% git commit -m "add index"
feature/add_column
)で、モデルの変更を伴うマイグレーションを適用ここからが、意図しない差分を発生させてしまう手順になります。
本来ならば、第2のブランチ(feature/add_unique_index
)を離れる前にマイグレーションをロールバックします。
ただ、今回は schema.rb
に差分を発生させるため、マイグレーションを適用したまま、
第3のブランチ(feature/add_column
)を新しく作成・切り替え
モデルに列 name
を追加
マイグレーションを適用
を行います。
% git checkout main
% git checkout -b feature/add_column
% bin/rails g migration AddColumnToBook name:string
% bin/rails db:migrate
続いて、マイグレーションの状態を確認します。作業2.のマイグレーションが適用されたまま、今回のマイグレーションも適用されているのが分かります。
% bin/rails db:migrate:status
database: db/development.sqlite3
Status Migration ID Migration Name
--------------------------------------------------
up 20230306095752 Create books
up 20230306104025 ********** NO FILE **********
up 20230306104231 Add column to book
schema.rb
を見ると isbn
列にUNIQUE制約が定義されており、意図しない状態となっています。
ActiveRecord::Schema[7.0].define(version: 2023_03_06_104231) do
create_table "books", force: :cascade do |t|
t.string "isbn"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "name"
t.index ["isbn"], name: "index_books_on_isbn", unique: true
end
end
最後に、「この状態に気づかずに、うっかりコミット」した想定にしておきます。
% git add .
% git commit -m "add column"
今回はテストコードを使って、何が起こるかを見ていきます。
「第3のブランチ(feature/add_column
)ではまだUNIQUE制約を付けるマイグレーションがないから、テストはパスするだろう」と考え、以下のテストコードを書いたとします。
require 'rails_helper'
RSpec.describe Book, type: :model do
describe 'schema.rbの確認' do
before { Book.create!(isbn: '4797399848') }
it 'UNIQUE制約がないこと' do
expect { Book.create!(isbn: '4797399848') }.not_to raise_error
end
end
end
しかし、RSpecを実行すると
expected no Exception, got #<ActiveRecord::RecordNotUnique: SQLite3::ConstraintException: UNIQUE constraint failed: books.isbn> with backtrace:
とテストが失敗してしまいます。
これは、Railsガイドで解説があるように、テストでは schema.rb
を見てテスト用のデータベースを作っているためです。
今回の場合、UNIQUE制約を追加するマイグレーションファイルが存在しない第3のブランチ(feature/add_column
)でも、UNIQUE制約付きでテスト用のデータベースが作成されてしまった結果、テストが失敗しました。
今回は、以下の流れで復旧していきます。
UNIQUE制約を追加した第2のブランチ(feature/add_unique_index
)へ切り替え、UNIQUE制約のマイグレーション適用をロールバックする
第3のブランチ(feature/add_column
)へ切り替え、 db:migrate
を実行する
順に見ていきます。
feature/add_unique_index
)へ切り替え、UNIQUE制約のマイグレーション適用をロールバックするUNIQUE制約を追加した第2のブランチ( feature/add_unique_index
)へ切り替え、マイグレーションの状況を確認します。
Migration IDに対するNameのうち、NO FILE
と表示されていたものが Add index to book
という表示へと切り替わりました。
% git checkout feature/add_unique_index
% bin/rails db:migrate:status
database: db/development.sqlite3
Status Migration ID Migration Name
--------------------------------------------------
up 20230306095752 Create books
up 20230306104025 Add index to book
up 20230306104231 ********** NO FILE **********
続いて、第2のブランチ( feature/add_unique_index
)上で、不要なマイグレーション適用を元に戻します。
なお、 db:migrate:rollback
コマンドでは1ステップずつしか戻せないので、バージョンを指定できるコマンドで戻します。
% bin/rails db:migrate:down VERSION=20230306104025
再度マイグレーションの状況を確認すると、当該Migration IDが down
という未適用状態になりました。
% bin/rails db:migrate:status
database: db/development.sqlite3
Status Migration ID Migration Name
--------------------------------------------------
up 20230306095752 Create books
down 20230306104025 Add index to book
up 20230306104231 ********** NO FILE **********
ただ、この時点では schema.rb
に変更が入っていることから、第3のブランチ(feature/add_column
)へ切り替えようとしても
% git checkout feature/add_column
error: Your local changes to the following files would be overwritten by checkout:
db/schema.rb
Please commit your changes or stash them before you switch branches.
Aborting
と、エラーになってしまいます。
そこで、 schema.rb
の変更を、当ブランチのコミット時点に戻しておきます。
% git reset HEAD db/schema.rb
% git checkout db/schema.rb
feature/add_column
)へ切り替え、 db:migrate
を実行するあらためて第3のブランチ(feature/add_column
)へ切り替えます。
% git checkout feature/add_column
ここでマイグレーション適用状況を確認すると、第3のブランチ(feature/add_column
)のマイグレーションのみが適用・表示されています。
% bin/rails db:migrate:status
database: db/development.sqlite3
Status Migration ID Migration Name
--------------------------------------------------
up 20230306095752 Create books
up 20230306104231 Add column to book
ただ、まだ schema.rb
にはUNIQUE制約の定義が残ったままになっています。
そこで、もう一回 db:migrate
を実行し、 schema.rb
を更新します。
% bin/rails db:migrate
ターミナルの実行結果には何も表示されないものの、git status
ではschema.rb
の更新が確認できます。
% git status
On branch feature/add_column
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: db/schema.rb
更新後の schema.rb
を見ると、UNIQUE制約の定義が削除されています。
ActiveRecord::Schema[7.0].define(version: 2023_03_06_104231) do
create_table "books", force: :cascade do |t|
t.string "isbn"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "name"
end
end
これでようやく schema.rb
が復旧しました。
あとは忘れずに schema.rb
を第3のブランチ(feature/add_column
)へコミットすれば、復旧は完了です。
今回は schema.rb
に差分が発生する事例とその復旧方法を見てきました。
復旧するのには手間がかかることから、もしプルリクエストにマイグレーションファイルが含まれている場合は、schema.rb
に余分な更新がないかチェックするのが良さそうです。
本サイトの更新情報は、Twitterの株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。
当社のRailsシステム間連携では、各システムで公開しているWeb APIを使っています。
今までは各システムを bin/rails s
で起動し、開発を行ってきました。
ただ、 連携するシステムが増えたり、各システムで使うジョブワーカーが増えたりした結果、現在では各システムを起動する手間が増えてきました。
そこで、今後も効率的に開発できるよう、以下の設定を行いました。
tmux
+ overmind
にて、連携する各システムやワーカーを1つのコマンドで起動できるようにした
RubyMine
にて、 overmind
で起動したプロセスにアタッチし、デバッグできるようにした
この記事では、複数システムを1コマンドで起動できるようにするために、 tmux
+ overmind
+ RubyMine
にてどのような設定をしたか、チュートリアル形式で共有します。
このチュートリアルでは、以下のシステム構成とします。
mac上で、2つのRailsシステム(frontend_app
とbackend_app
)を開発している
frontend_app
について
外部からのHTTPリクエストを受け付ける
Delayed::Job
でジョブを管理している
bin/rails jobs:work
にて Delayed::Job Worker を起動する
各Railsシステムは、ローカルマシン上での bin/rails s
実行により起動する
各Railsシステムは、データベースを適切に設定している
次の図のように、overmind
ディレクトリの中に frontend_app
と backend_app
という2つのRailsシステムのリポジトリがあるものとします。
overmind/
├── backend_app/
│ ├── app/
│ ├── bin/
│ ...
└── frontend_app/
├── app/
├── bin/
...
terminal multiplexer
と呼ばれるソフトウェアのうちの1つです。
1つのターミナルの画面を、複数に分割して利用します。
Procfile
ベースのプロセスマネージャーです (Githubリポジトリ)。
Herokuで使う Procfile
と同じ書式で定義することで、定義したプロセスを管理できます。
参考: The Procfile | Heroku Dev Center
overmindのREADMEに従い、 tmux
と overmind
をインストールします。
% brew install tmux
% brew install overmind
tmux
はデフォルト設定のままでも問題なく使えます。
ただ、慣れないうちはマウス操作はできたほうが便利なため、 tmux
の設定を追加します。
~/.tmux.conf
ファイルを追加し、以下を記載します。
set-option -g mouse on
チュートリアルのルートディレクトリである overmind
に、ファイル Procfile
を作成します。
Procfile
には、各Railsシステムやジョブワーカーを起動する時のコマンドを記載します。
なお、ワーキングディレクトリを考慮するため、 &&
を使ってコマンドをチェーンしています。
参考:foreman - Procfile start processes in their own working directory - Stack Overflow
# frontendの設定
frontend_app: cd frontend_app && bin/rails s -b 0.0.0.0 -p 3030
frontend_worker: cd frontend_app && bin/rails jobs:work
# backendの設定
backend_app: cd backend_app && bin/rails s -b 0.0.0.0 -p 3031
macのターミナルから tmux
を起動します。
% tmux
次に、 Procfile
のあるディレクトリに移動し、overmind
にて各プロセスを起動します。
% overmind s
tmux
の画面では、 Procfile
で定義した各プロセスの様子が表示されています。
overmind s
した時の様子外部からのHTTPリクエストを受け付ける frontend_app
に対し、 curl
でアクセスします。
すると、 frontend_app
からJSONレスポンスが返ってきます。
% curl http://localhost:3030/shops
{"shop":{"name":"スーパーマーケット","apples":[{"name":"シナノゴールド"},{"name":"シナノスイート"},{"name":"秋映"}]}}
tmux
を見ると、各システムやジョブワーカーが連携し、JSONレスポンスを返したことが分かります。
動作確認ができたため、いったん Ctrl + C
にて overmind
での実行を停止します。
現在はtmuxの1つのペインに、 Procfile
で起動したすべてのプロセスのログが表示されています。
ただ、この状態のままでは各プロセスのログを追いづらいです。
そこで、別ペインで表示するよう設定します。
このチュートリアルでは、ウィンドウを上下ペインに分けます。
上ペインはここまで通り overmind
のログを表示します。
一方、下ペインでは backend_app
のログのみを表示するようにします。
以下の準備を行います。
tmuxで Ctrl + b
+ "
を入力し、水平ペインを開く
上ペインにて、以下の操作を行う
overmind s
を実行し、各システム・ワーカーを起動する
下ペインにて、以下の操作を行う
Procfile
のあるディレクトリに移動する
overmind connect backend_app
を実行し、overmindで実行している backend_app
のプロセスに接続する
再びcurlで frontend_app
にアクセスしてみます。
すると、上ペインでは、各システム・ワーカーのログが出力されています。
一方、矢印部分の下ペインでは、 backend_app
のログのみ表示されています。
backend_app
のログのみ表示される今までの操作にて、各システム・ワーカーを overmind s
だけで起動できるようになりました。
ただ、何か不具合があった時には、各システムをデバッグしたくなるかもしれません。
もしRubyMineを使っている場合は、 overmind
で起動したプロセスにアタッチ・デバッグできます。
参考: Attach to process | RubyMine
このチュートリアルでは、RubyMineを使って backend_app
のプロセスにアタッチしてみます。
以下の順番で設定を行います。
RubyMineにて、プロセスにアタッチしたいシステムのリポジトリを開く
このチュートリアルでは backend_app
リポジトリを開きます。
RubyMineのメニューにて、 Run > Attach to Process
を選択する
実行しているプロセスが表示されるため、 backend_app
のプロセスを選択する
下部のイメージ参照
RubyMineでブレークポイントを設定する
以上で、デバッグの準備が整いました。
curlで frontend_app
にアクセスしてみます。
すると、RubyMineで設定したブレークポイントで停止します。
実行時の各変数の内容も表示され、デバッグできていることが分かります。
tmux
+ overmind
を利用して、連携する一連のシステムやワーカーを起動できるようにしたことにより、より開発を効率的に行うことができるようになりました。
今後も開発を効率的に行う方法をTechBookにて共有していこうと思います。
本サイトの更新情報は、Twitterの株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。
Rails歴が長い人でも、意外とmigrationの追加用のコマンドを覚えていられず、毎回調べていているので、実装で使ったもの・使いそうなものを少しずつ追加しています。
この記事は定期的に内容が追加される予定です。
以下のように、マイグレーションの名前を指定しながらrails generate
コマンドを入力します。
% rails g migration AddXXXXsToSomeRecords
以下のようにコマンドでカラムと型を指定する(YYY
はテーブル名)か、
$ rails g migration AddXXXXToYYY カラム名:データ型
あるいは、空のマイグレーションファイルを作った後、次のように、直接、change
メソッド内にadd_column
メソッドを記載します。
class AddXXXXsToSomeRecords < ActiveRecord::Migration
def change
add_column :some_records, :column_name, :string
end
end
some_master_records
というテーブルがある前提で、別のsome_transaction_records
テーブルにdefault_some_master_record_id
というカラムを追加して、そのカラムを使ってsome_master_records
テーブルを参照したい場合に使います。
やり方はいくつかあると思いますが、筆者が使うのは、以下です。
その後、以下のようにadd_reference
メソッドにforeign_key
オプションを指定し、そのオプションの中でto_table
を指定します。
class AddXXXXsToSomeTransactionRecords < ActiveRecord::Migration
def change
add_reference :some_transaction_records, #カラムを追加したいテーブル
:default_some_master_record, #これでdefault_some_master_record_idカラムが作られる
{
foreign_key: {to_table: :some_master_records}
}
end
end
これで、some_transaction_records
テーブルに、default_some_master_record_id
カラムが追加され、some_master_records
テーブルへの外部キー制約も作られます。
なお、この場合、ActiveRecordのモデルクラス(SomeTransactionRecord
クラス)にも参照に使う外部キーと参照先のモデルの設定が必要になります(この記事での説明は省略します)。
本サイトの更新情報は、Twitterの株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。
当社では、社内で共通に使いたい機能をgemに切り出し、機能の利用側のGemfileでプライベートリポジトリを参照しています。
gem "some_internal_library", git: "https://github.com/precena-dev/some_internal_library.git", tag: "v1.0.0"
ローカル端末でのみ利用する場合はgitのURLはgit@ やssh@ で始まるURLを使えば問題なくbundle installできます。しかしCI/CD環境でもbundle installするため、httpsで始まるURLで登録しています。
このプライベートリポジトリをbundle install時に参照する方法について記載します。
こちらのドキュメントに記載があるように、BUNDLE_GITBHUB__COM の環境変数にgithubのPersonal Access Token(PAT)を登録することでbundle install時にプライベートリポジトリを参照する方法があります。しかし、GithubはPATの利用を推奨していません。ドキュメントに非推奨の「GitHub recommends that you use fine-grained personal access tokens instead」といった言及がされています。したがって、当記事ではPAT以外を利用した方法について記載します。
まずCLIをインストールしておきます。Macの場合はbrewコマンドでインストールできます。
% brew install gh
次に以下のコマンドを実行するとブラウザが開きますので、Githubの認可を行います。
% gh auth login
これで、bundle installが成功します。
以下のコマンドを実行します。参考
% git config url.git@github.com:.insteadOf https://github.com/
そうすると、sshで参照するようになるためbundle installが成功します。
github actionsやAWS codebuildなどのCI/CD環境について記載します。
まず、https://github.com/organizations/<your_organization_name>/settings/apps/new から、Github Appsを作ります。組織内でのみ利用したいため、「Where can this GitHub App be installed?」の項目は「Only on this account」にチェックしておきます。
パーミッションについては、プライベートリポジトリを参照してbundle installするだけであれば、「Contents:Read-only」を選択するだけで良いでしょう。
作成後にPrivate Keyを作れるようになりますので、ひとつ作成して秘密鍵をダウンロードしておきます。
画面上部に表示されているAppIDを控えます
Github App 左メニューのInstall Appを選択し、歯車アイコンをクリックします。
必要なリポジトリを選択し、Saveします。
控えておいたGITHUB_APP_IDおよびGITHUB_APP_PRIVATE_KEYを、下図のようにActionのsecretsに、登録しておきます。
Github App経由でtokenを取得します。その値を、環境変数BUNDLE_GITHUB__COM
に設定します。以下にGithub Actionsの設定例を掲載します。
steps:
- name: Generate github token
id: generate_token
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.PRIVATE_KEY }}
- uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
・・・中略・・・
- name: Set up Ruby
env:
BUNDLE_GITHUB__COM: x-access-token:${{ steps.generate_token.outputs.token }}
# To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
# change this to (see https://github.com/ruby/setup-ruby#versioning):
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
Github Actionsとやっていることは同じです。Github Appsを用意し、GITHUB_APP_IDとGITHUB_APP_PRIVATE_KEYを使って、Access Tokenを取得します。ただしgithub actionsのように公開された再利用可能ワークフローがないため、自前でスクリプトを実行してtokenを取得します。
GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEYに加え、GITHUB_APP_INSTALLATION_IDをActionのsecretsに登録しておきます。installation idは各リポジトリのSettingsメニューの下部Github Appsを選択し、Github Appsの一覧のConfigureボタンを押した先のURLに含まれています。https://github.com/organizations/<organization_name>/settings/installations/<installation_id> の形式です。このinstallation_idを控えてください。
以下が、buildspec.ymlから呼ぶスクリプトです。
# buildspec.ymlから呼ぶscript.
# github app経由でtokenを取得する
npm install axios jsonwebtoken
node ./get_github_token.js > $BUNDLE_GITHUB__COM
get_github_token.jsの内容は以下のとおりです。クラスメソッドさんのブログ記事より流用、改変しています。
// github app経由でtokenを取得するスクリプト
// 成果物のtokenは標準出力に出す
// https://dev.classmethod.jp/articles/register-github-app-and-get-access-token/ を改変
const jwt = require("jsonwebtoken")
const axios = require("axios")
const githubAppId = process.env.GITHUB_APP_ID;
const githubAppPrivateKey = process.env.GITHUB_APP_PRIVATE_KEY;
const githubAppInstallationId = process.env.GITHUB_APP_INSTALLATION_ID;
const payload = {
exp: Math.floor(Date.now() / 1000) + 60, // JWT expiration time
// ちょっとだけ時間を手前にしておくとアクセストークンの発行に失敗し辛いらしい。
// https://qiita.com/icoxfog417/items/fe411b94b8e7ae229e3e#github-apps%E3%81%AE%E8%AA%8D%E8%A8%BC
iat: Math.floor(Date.now() / 1000) - 10, // Issued at time
iss: githubAppId
}
const cert = githubAppPrivateKey;
const token = jwt.sign(payload, cert, { algorithm: 'RS256'});
axios.default.post(`https://api.github.com/app/installations/${githubAppInstallationId}/access_tokens`, null, {
headers: {
Authorization: "Bearer " + token,
Accept: "application/vnd.github.machine-man-preview+json"
}
})
.then(res => {
// 標準出力に出たものをシェルスクリプトでリダイレクトして使う想定
console.log(`x-access-token:${res.data.token}`);
})
.catch(res => {
console.error('error');
console.error(res);
throw new Error(res.data);
})
本サイトの更新情報は、X(旧Twitter)の株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。
ActiveSupportには、to_json
という便利なメソッドがあります。
Railsで開発しているときに使う場面としては、DBから取得したレコードをAPIのレスポンスとして返す場合があります。
しかし、deviseを認証に使っているサービスなどで、何も考えずに、user.to_json
のようなコードを書いてしまうと、以下のようなJSONがレスポンスに返されてしまいます。
{
\"id\":1111111,
\"email\":\"xxxxx@yyyyy.zzzzz\",
\"created_at\":\"2023-09-06T14:29:22.188+09:00\",
\"updated_at\":\"2023-09-06T15:29:38.638+09:00\",
\"last_sign_in_user_agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36\"
}
出力される属性がこれだけなら、それほど大きな問題がないようにも見えます。しかし、userモデルには、サービスへの機能追加に伴ってプライベートな情報が追加されやすいため、そういった情報がto_json
メソッドで出力されてしまったり、あるいは、本人以外のuser情報も併せて一覧で取得するような場合に、他人の email
やプライベートな情報が含まれてしまったりすると個人情報の漏洩問題になる可能性があります。
したがって、to_json
メソッドで出力する属性を制限するべきかどうかについて、注意し検討する必要が出てきます。
幸い、to_json
メソッドでは、オプションを指定することで、出力を絞り込むことができます。
only
特定の属性のみに絞り込む場合に使います。
except
特定の属性を除外したい場合に使います。
include
特定のassociation(has_manyやbelongs_toで指定しているような別のモデル)を出力に含めたい場合に使います。
methods
特定のメソッドを呼び出した結果を含めたい場合に使います。
methodsオプションは、特定の属性を絞り込むというよりかは、追加で情報を出力する用途に使いますが、関連する機能なので併せて説明します。
以下、各オプションの使用例を示します。
さきほどの例で、only
を指定すると、以下のように指定した属性だけが出力されます。
> user.to_json(only: [:id])
=> "{\"id\":1111111}"
さきほどの例で、except
を指定すると、以下のように指定した属性以外のものが出力されます(※)。
※読みやすくするために、改行を入れています。
> user.to_json(except: [:email])
=>
{
\"id\":1111111,
\"created_at\":\"2023-09-06T14:29:22.188+09:00\",
\"updated_at\":\"2023-09-06T15:29:38.638+09:00\",
\"last_sign_in_user_agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36\"
}
さきほどの例で、include
を指定すると、以下のように指定したassociationの属性も併せて出力されます(※)。
※ some_associationという属性がある前提です。
> user.to_json(include: [:some_association])
=>
{
\"id\":1111111,
\"created_at\":\"2023-09-06T14:29:22.188+09:00\",
\"updated_at\":\"2023-09-06T15:29:38.638+09:00\",
\"last_sign_in_user_agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36\",
\"some_association\":{\"id\":222222,\"name\":\"名前1\"}
}
なお、includeで指定したassociation内でも出力する属性を制限したい場合は、以下のように書けます。
> user.to_json(include: [{some_association: {only: :name}}])
=>
{
\"id\":1111111,
\"email\":\"xxxxx@yyyyy.zzzzz\",
\"created_at\":\"2023-09-06T14:29:22.188+09:00\",
\"updated_at\":\"2023-09-06T15:29:38.638+09:00\",
\"last_sign_in_user_agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36\",
\"some_association\":{\"name\":\"名前1\"}
}
さきほどの例で、methods
を指定すると、以下のように指定したメソッドの呼び出し結果も併せて出力されます(※)。
※some_methodというメソッドがある前提です。
> user.to_json(methods: [:some_method])
=>
{
\"id\":1111111,
\"email\":\"xxxxx@yyyyy.zzzzz\",
\"created_at\":\"2023-09-06T14:29:22.188+09:00\",
\"updated_at\":\"2023-09-06T15:29:38.638+09:00\",
\"last_sign_in_user_agent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36\",
\"some_method\": \"some_output\"}
}
本サイトの更新情報は、X(旧Twitter)の株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。
userの情報を返すAPIを実装する際、render json: user
とするとuserモデルのすべてのフィールドを含むJSONを返してしまい危険です。パスワードはハッシュ化されているものの、deviseが提供するフィールドlast_sign_in_ip
などクライアントに返してはならない個人情報が含まれており、情報漏えいにつながってしまうためです。こちらの記事も参考にしてください。
上述のような危険な実装をコードレビューのみに頼らずに、Rubyの静的コード解析ツールであるRuboCopで機械的にチェックする方法を、当ページでは記載します。
基本的には公式ドキュメントの手順通りです。
rubocop gemはインストール済みの前提で記載します。
以下の4行目をエラーとすることを目標にします。
# some_controller.rb
class SomeController < ApplicationController
def some_method
user = User.first
render json: user, status: 200 # これを検知したい。statusパラメータは省略可能
end
end
rubocop gemを入れていれば使えるruby-parse
というコマンドでASTを出力します。
$ ruby-parse some_controller.rb
(class
(const nil :SomeController)
(const nil :ApplicationController)
(def :some_method
(args)
(begin
(lvasgn :user
(send
(const nil :User) :first))
(send nil :render
(kwargs
(pair
(sym :json)
(lvar :user))
(pair
(sym :status)
(int 200)))))))
検知したいのは、この
(send nil :render
(kwargs
(pair
(sym :json)
(lvar :user))
(pair
(sym :status)
(int 200))))))
の部分です。また、renderメソッドの第二引数のstatusは省略可能な引数ですので、第二引数の有無にかかわらず検知できるようにしたいです。
上記パターンにマッチするようにマッチャを記述します。_や...はワイルドカードです。詳細はドキュメントをご確認ください。
renderメソッドを呼び出していて
その引数にはjsonという名前付き引数を指定しており
第二引数以降は問わない
というマッチャを記述しlib/custom_cops/dangerous_render_json.rb
として配置します。
module CustomCops
class DangerousRenderJson < RuboCop::Cop::Cop
# キーワード引数jsonを第一引数にしているrenderメソッド
def_node_matcher :render_json_call?, <<~PATTERN
(send ... :render
(hash
(:pair
(:sym :json)
(_ ...)
)
...
)
)
PATTERN
MSG = 'Do not use render json.'
def on_send(node)
return unless node.method_name == :render
add_offense(node) if tojson_call?(node)
end
end
end
次に、.rubocop.yml
にて、今作ったカスタムルールを読み込む設定を追記します。
require:
- rubocop-rails
- rubocop-rspec
- ./lib/custom_cops/dangerous_render_json
rubocopコマンドを実行して動作確認をします。
うまくいけば、以下のように、エラーとして検知できます。
% bundle exec rubocop
・・・中略・・・
app/controllers/some_controller.rb:4:5: C: CustomCops/DangerousRenderJson: Do not use render json.
render json: user, status: 200 # これを検知したい。statusパラメータは省略可能
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
本サイトの更新情報は、X(旧Twitter)の株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。