全てのページ
GitBook提供
1 / 10

Ruby on Rails

ActiveRecordのfind_or_initialize_byメソッドにブロックを渡したときの挙動

過去に書いたソースコードを読んでいて、仕様を理解するのに手間取ってハマったので、共有のために記事を書いておきます。

find_or_initialize_byメソッドの例

以下のようなコードを見かけたとします。

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のアプリケーションサーバーのプロセス数とスレッド数の設定方法

はじめに

この記事では、Railsの本番環境におけるアプリケーションサーバー(pumaやunicornなど)のプロセス数とスレッド数のパラメータ設定に関する情報をまとめます。

プロセスとスレッドの特徴概要

アプリケーションサーバーのパラメータを設定する際に、OSにおけるプロセスとスレッドは重要な要素になるので、あらかじめ概要を説明しておきます。

プロセスはOSで実行中のプログラムのことで、プロセス内には1つ以上のスレッドが含まれます。CPUのコアに命令をしているのがスレッドです。

複数のプロセス同士は、同じメモリ領域を共有できません。しかし、同じプロセス内のスレッド同士は、同じメモリ領域を共有できるので、プロセス内に複数のスレッドを作成した方がメモリの利用効率が上がります。

複数のスレッドが同じメモリ領域を共有すると、メモリの利用効率は上がりますが、誤って別のスレッドに影響する情報を書き換えてしまう可能性もあります。いわゆるスレッドセーフではない状態が起こるということです。

1つのスレッドしかないプロセスを複数使用すれば、スレッドセーフかどうかを考慮する必要はなくなりますが、複数のスレッドを使う場合と比較して、メモリの利用効率は下がってしまいます。

1つのプロセス内に複数のスレッドを作った場合のメリットとして、一部のスレッドがIO待ち(例えば、データベースへのリクエストとそのレスポンスを待っているような状態)になってしまっても、プロセス内の他のスレッドで別の処理を進めることができるという点があります。この場合、プロセスとしてのスループットが大きくなります。

もし、1つのプロセスで1つのスレッドしか用意しなかったとしたら、そのスレッドでIO待ちが発生すると処理がブロックしてしまいます。

pumaとunicornの違い

Railsで使うアプリケーションサーバーとして一般的なものにpumaとunicornがあります。

pumaは、マルチプロセス対応で、さらに、各プロセス内に複数のスレッドを作成することができます。

unicornは、マルチプロセス対応ですが、各プロセス内には複数のスレッドを作りません。

したがって、スレッド間でメモリを共有できる分、メモリ効率はpumaの方が良くなりやすいです。ただし、pumaを使う場合は利用しているgemも含めてスレッドセーフな実装でアプリケーションを作っておく必要があります。

一方、unicornは、メモリ効率はpumaと比較して良くありませんが、スレッドセーフであるかどうかを気にする必要がありません。また、詳細な説明は省略しますが、稼働中のサーバーにデプロイを行う際にゼロダウンタイムのデプロイをすることが可能、などの特徴もあります。

この記事では、Rails標準のアプリケーションサーバーであるpumaを使う想定で、以降の説明をします。

RubyのGVLの概要

Ruby には、Giant VM lock (GVL)とよばれるものが存在します。Giant VM Lockが効いている間は、ネイティブスレッドがロックされ、実質1つのスレッドしか同時に実行できません。

Logoスレッド (Ruby 3.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コアの数です。クラウドのような仮想環境を利用している場合は、物理的なコア数ではなく仮想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.)

https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server#process-count-value より一部引用

(訳注:IO待ちの場合は、GVLが開放されるとはいえ)GVLを考慮するとCPUの機能をフル活用するなら、Rubyコードのプロセス数は、CPUのコア数に一致させるのがよい、と記載されています。

利用可能なメモリ量

利用しているサーバーのメモリ量も把握する必要があります。

スレッドごとにRailsアプリケーションの処理が実行されるので、スレッド数が増えればそれだけ必要なメモリ量も大きくなります。また、プロセス数が増えれば必然的に包含するスレッドも最低1つは増えますので、プロセス数を増やす場合も、必要なメモリ量は大きくなります。

利用しているサーバーのメモリ量は、プロセス数やスレッド数の上限を考えるのに必要です。

1スレッドで必要なメモリ量

1スレッドで必要なメモリ量も把握する必要があります。

1スレッドで必要なメモリ量が例えば、400MB前後だとすると、スレッド数を1、2…と増やしていった場合に、必要なメモリは概ね400MB、800MB…と比例して増えていきます。

1スレッドで必要なメモリ量は、アプリケーションの実装によって幅が出てきます。筆者の経験では、Railsアプリケーションの本番環境では、概ね300MB〜1GBくらいの幅で変わるように思います。

もしすでに本番環境やステージング環境を運用しているのであれば、その環境でメトリクス計測ツール(New RelicやHerokuのMetrix機能)で確認するのが確実です。

稼働している本番環境やステージング環境で、実際に合計何スレッドが動作しているのかが分かれば、本番環境で利用しているメモリ量をその合計スレッド数で割れば、1スレッドで必要なメモリの量を概算することができます(厳密には、スレッド間で共有しているメモリがあるはずです。ここで計算しているのは、あくまで概算値です)。

Railsで設定しているコネクションプールの数

Railsには、コネクションプールの仕組みがあり、以下のように、database.ymlのpoolの値で上限値を指定することができます。

/config/database.yml
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つ割り当てられ、処理が終わるとプールに戻されます。

コネクションプールの値がスレッド数より少ない場合、プール数よりも多いデータベースへのアクセスリクエストが発生した際に、新しいスレッドにデータベースとの接続を割り当てることができなくなり、コネクションの割当待ちのような状態になります。

したがって、この割当待ちによるスループットの低下を防ぎたい場合は、

コネクションプール数≥スレッド数コネクションプール数 \geq スレッド数コネクションプール数≥スレッド数

のような関係を保つように、database.ymlを設定する必要があります。

Herokuの記事では、コネクションプール数とスレッド数を同じ値にすることを推奨しているようです。実際に、これらの値が同じであっても上記の条件は満たされますし、スレッド数よりも過剰に大きいコネクションプール数を作成したとしても、その分サーバー上のメモリが無駄になるため、基本的にはHerokuの推奨のやり方に従うのが良いでしょう。

アプリケーションサーバーとjobワーカーの数

一般的なRailsアプリケーションにおいて、データベースに接続するのは、pumaやunicornのようなアプリケーションサーバーからだけではなく、sidekiqのようなjobワーカーも存在します。

次節で説明するように、データベース側に接続上限数が存在するため、jobワーカーが存在するか、そして、それが存在する場合どれくらいの数があり、合計何スレッドくらい実行されるか、も考慮にいれる必要があります。

データベースの同時接続数

データベース側の同時接続数の上限値も把握しておく必要があります。

データベース側の同時接続数が分かれば、全サーバーの全プロセスで作られるスレッド数の合計値が、その同時接続数を超えないようにパラメータを調整しなければなりません。

したがって、結果的に、データベースの同時接続数は、各サーバーのプロセス数やスレッド数の上限値に影響します。

メモリ以外のパラメータの大小関係

これまでの説明を考慮すると、メモリに関するパラメータを除外すれば、少なくとも以下のような大小関係がなりたつように、各パラメータを設定する必要があります。

全アプリケーションサーバーの合計プロセス数×スレッド数(数式1)\text{全アプリケーションサーバーの合計プロセス数} \times \text{スレッド数} \text{(数式1)}全アプリケーションサーバーの合計プロセス数×スレッド数(数式1)
全jobワーカーの合計プロセス数×スレッド数(数式2)\text{全jobワーカーの合計プロセス数} \times \text{スレッド数} \text{(数式2)}全jobワーカーの合計プロセス数×スレッド数(数式2)
(数式1)+(数式2)≤データベースの同時接続上限数\text{(数式1)} + \text{(数式2)} \leq \text{データベースの同時接続上限数}(数式1)+(数式2)≤データベースの同時接続上限数

ただし、この大小関係を満たすという条件は変わりませんが、実際は、次の節で考慮に入れる利用可能なメモリ量で頭打ちになることが多いように思います。

メモリ関連のパラメータの大小関係

上記の大小関係とは別に、メモリによってもパラメータの大小関係が決まってきます。

これは、単純に、以下になります。

1つのサーバーの合計スレッド数に必要なメモリ量≤利用可能なメモリ量\text{1つのサーバーの合計スレッド数に必要なメモリ量} \leq \text{利用可能なメモリ量}1つのサーバーの合計スレッド数に必要なメモリ量≤利用可能なメモリ量

Heroku環境での設定例

最後に、当社で良く利用するHeroku環境の例をもとに、具体的な設定例を考えてみます。

前提とする環境

以下のような架空の本番環境を想定することにします。

項目

値

CPU数(web dynoで使用できるvCPUの数)

今回の想定では、web dynoはpremium Mとする。

vCPUの値は、公式サイトを参照。

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だけ作成することになります。

さて、この前提で、合計のデータベース接続数を念の為に計算すると、

全アプリケーションのサーバーの合計プロセス数×スレッド数=4×2=8\text{全アプリケーションのサーバーの合計プロセス数} \times \text{スレッド数} = 4 \times 2 = 8全アプリケーションのサーバーの合計プロセス数×スレッド数=4×2=8

となるため、データベースの接続上限数の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の株式会社プレセナ・ストラテジック・パートナーズエンジニア公式アカウントで発信しています。ご確認ください。

Railsを6.1系から7.0系へアップグレードした時に調査したこと

先日、Rails6.1系で動いていたシステムをRails7.0系にアップグレードしました。

そのシステムは

  • Rails製APIアプリケーション

  • コード量・利用者ともに小規模

  • テストコードが充実している

と比較的アップグレードしやすかったこともあり、無事に完了しました。

この記事ではアップグレードの際に調べたことをまとめておきます。

なお、作業はピクシブ株式会社さんの永久保存版Railsアップデートガイドを参考にしながら進めました。ありがとうございました。

リリースノートやRailsガイドのアップグレードガイドを読む

Rails6.1から7.0へアップグレードを計画した段階で、Railsの公式BlogRails7.0系のリリースノートを読みました。

また、Railsガイドのアップグレードガイドの Rails 6.1からRails 7.0へのアップグレードました。

資料を読み終えたところで、対象のシステムは問題なさそうと判断し、準備を進めることにしました。

各gemがRails7.0対応しているか確認する

Rails7.0がリリースされた直後は、使っているgemがRails7.0対応していないものがありました。

そこで、定期的に各gemのリポジトリを見に行き、Rails7.0対応に関するissueやPull Requestを確認しました。

その後、使用しているすべてのgemがRails7.0対応がなされているのが確認できたため、具体的な作業に入りました。

各gemをバージョンアップする

今回は小規模だったこともあり、

  • 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さんの記事が参考になりました。ありがとうございました。

ここでは、今回気になった点とその対応について以下にまとめます。

app/models/application_record.rb

RailsDiffでの差分を引用します。

class ApplicationRecord< ActiveRecord::Base
-  self.abstract_class = true
+  primary_abstract_class
end

TechRachoさんの記事を読むと primary_abstract_class の方が良さそうでしたので、差し替えました。

config/environments/development.rb

RailsDiffでの差分のうち、気になった点は以下でした。

+ config.server_timing = true

- config.assets.debug = true

- config.file_watcher = ActiveSupport::EventedFileUpdateChecker

TechRachoさんの記事を読むと、server timing ミドルウェアは便利そうでしたので追加することにしました。

一方、

  • config.assets.debug は使っていないシステムだった

  • config.file_watcher はDockerを使っていると影響ありそうな情報が散見されるものの、今回の環境では不要そうだった

ため、これらは設定ファイルから削除することにしました。

config/environments/production.rb

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 にしておきました。

他の項目については、対象のシステムで無効にしていた設定だったため、対応は不要でした。

config/environments/test.rb

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の記事で触れられていなかったものについて調査しました。それらを以下にまとめます。

config.active_support.disable_to_s_conversion: true

Railsガイドには

ある種のRubyコアクラスに含まれる#to_sメソッドの上書きを無効にします。この設定は、アプリケーションでRuby 3.1の最適化をいち早く利用したい場合に使えます。

とあります。

今回は小規模なシステムだったこともあり影響がなかったため、デフォルト値の変更を受け入れました。

action_dispatch.return_only_request_media_type_on_content_type: false

edgeのRailsガイド に詳細な記載がありました。

TechRachoさんの記事 を読み、今回のシステムには影響しなかったため、デフォルト値の変更を受け入れました。

active_storage.multiple_file_field_include_hidden: true

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の株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。

schema.rbで差分が発生する事例とその復旧について

Railsには、スキーマファイルと呼ばれる schema.rb があります。

Railsガイドには

Active Recordはマイグレーションの時系列に沿ってスキーマを更新する方法を知っているので、履歴のどの時点からでも最新バージョンのスキーマに更新できます。Active Recordは db/schema.rb ファイルを更新し、データベースの最新の構造と一致するようにします。

とあるように、schema.rb はデータベース関連の機能で使われます。

その schema.rb ですが、チームで開発をしている時に

  • 自分の作業を中断して、プルリクエストのレビューをする

  • 自分の作業に戻って、開発を続ける

を繰り返している中で、意図しない差分が schema.rb に発生していると気づき、戸惑うことがあるかもしれません。

そこで、schema.rb に差分が発生してしまうことについて

  • 発生する事例

  • 差分が発生していると何が起こるか

  • どう復旧するか

を記事としてまとめます。

発生する事例

発生する事例として、「どのような流れで発生してしまうのか」を体験できるチュートリアルを用意しました。

チュートリアルは以下の流れで進めます。なお、Railsのバージョンは 7.0.4.2 を想定しています。

  1. mainブランチで、Railsのモデルを作成

  2. mainから派生した第2のブランチ(feature/add_unique_index)で、モデルの変更を伴うマイグレーションを適用

  3. mainから派生した第3のブランチ(feature/add_column)で、モデルの変更を伴うマイグレーションを適用

1. mainブランチで、Railsのモデルを作成

最小構成で 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"

2. mainから派生した第2のブランチ(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"

3. mainから派生した第3のブランチ(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制約付きでテスト用のデータベースが作成されてしまった結果、テストが失敗しました。

どう復旧するか

今回は、以下の流れで復旧していきます。

  1. UNIQUE制約を追加した第2のブランチ(feature/add_unique_index)へ切り替え、UNIQUE制約のマイグレーション適用をロールバックする

  2. 第3のブランチ(feature/add_column)へ切り替え、 db:migrate を実行する

順に見ていきます。

1. UNIQUE制約を追加した第2のブランチ(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

2. 第3のブランチ(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の株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。

tmux + overmind を利用して、複数システムを1コマンドで起動できるよう設定する

はじめに

当社の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/
    ...

用語について

tmuxとは

terminal multiplexer と呼ばれるソフトウェアのうちの1つです。

1つのターミナルの画面を、複数に分割して利用します。

overmindとは

Procfile ベースのプロセスマネージャーです (Githubリポジトリ)。

Herokuで使う Procfile と同じ書式で定義することで、定義したプロセスを管理できます。

参考: The Procfile | Heroku Dev Center

チュートリアル

tmux + overmind でシステム全体を起動できるようにする

tmux + overmind をセットアップする

overmindのREADMEに従い、 tmux と overmind をインストールします。

% brew install tmux

% brew install overmind

tmuxの設定を行う

tmuxはデフォルト設定のままでも問題なく使えます。

ただ、慣れないうちはマウス操作はできたほうが便利なため、 tmux の設定を追加します。

~/.tmux.conf ファイルを追加し、以下を記載します。

set-option -g mouse on

Procfileを作成する

チュートリアルのルートディレクトリである 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

tmuxにてovermindを起動する

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レスポンスを返したことが分かります。

JSONレスポンス返した時の各システムの様子

動作確認ができたため、いったん Ctrl + C にて overmind での実行を停止します。

1つのプロセスをtmuxの別ペインで表示する

現在はtmuxの1つのペインに、 Procfile で起動したすべてのプロセスのログが表示されています。

ただ、この状態のままでは各プロセスのログを追いづらいです。

そこで、別ペインで表示するよう設定します。

このチュートリアルでは、ウィンドウを上下ペインに分けます。

上ペインはここまで通り overmind のログを表示します。

一方、下ペインでは backend_app のログのみを表示するようにします。

別ペインで表示するための準備

以下の準備を行います。

  • tmuxで Ctrl + b + " を入力し、水平ペインを開く

  • 上ペインにて、以下の操作を行う

    • overmind s を実行し、各システム・ワーカーを起動する

  • 下ペインにて、以下の操作を行う

    • Procfile のあるディレクトリに移動する

    • overmind connect backend_app を実行し、overmindで実行している backend_app のプロセスに接続する

      • 参考: overmindの公式ドキュメント

動作確認

再びcurlで frontend_app にアクセスしてみます。

すると、上ペインでは、各システム・ワーカーのログが出力されています。

一方、矢印部分の下ペインでは、 backend_app のログのみ表示されています。

下ペインには backend_app のログのみ表示される

RubyMineにて、overmindで起動したプロセスのデバッグを行う

今までの操作にて、各システム・ワーカーを overmind s だけで起動できるようになりました。

ただ、何か不具合があった時には、各システムをデバッグしたくなるかもしれません。

もしRubyMineを使っている場合は、 overmind で起動したプロセスにアタッチ・デバッグできます。

参考: Attach to process | RubyMine

このチュートリアルでは、RubyMineを使って backend_app のプロセスにアタッチしてみます。

RubyMineでの設定

以下の順番で設定を行います。

  • RubyMineにて、プロセスにアタッチしたいシステムのリポジトリを開く

    • このチュートリアルでは backend_app リポジトリを開きます。

  • RubyMineのメニューにて、 Run > Attach to Process を選択する

  • 実行しているプロセスが表示されるため、 backend_app のプロセスを選択する

    • 下部のイメージ参照

  • RubyMineでブレークポイントを設定する

プロセス選択の様子

以上で、デバッグの準備が整いました。

動作確認

curlで frontend_app にアクセスしてみます。

すると、RubyMineで設定したブレークポイントで停止します。

実行時の各変数の内容も表示され、デバッグできていることが分かります。

overmindで起動したプロセスをデバッグできた

まとめ

tmux + overmind を利用して、連携する一連のシステムやワーカーを起動できるようにしたことにより、より開発を効率的に行うことができるようになりました。

今後も開発を効率的に行う方法をTechBookにて共有していこうと思います。

本サイトの更新情報は、Twitterの株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。

Rails Migrationチートシート

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の株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。

GithubのプライベートリポジトリをGemfileで参照する方法

背景

当社では、社内で共通に使いたい機能を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時に参照する方法について記載します。

Personal Access Tokenを使う方法について

こちらのドキュメントに記載があるように、BUNDLE_GITBHUB__COM の環境変数にgithubのPersonal Access Token(PAT)を登録することでbundle install時にプライベートリポジトリを参照する方法があります。しかし、GithubはPATの利用を推奨していません。ドキュメントに非推奨の「GitHub recommends that you use fine-grained personal access tokens instead」といった言及がされています。したがって、当記事ではPAT以外を利用した方法について記載します。

ローカル端末の場合

Github CLIを使う方法

まずCLIをインストールしておきます。Macの場合はbrewコマンドでインストールできます。

% brew install gh

次に以下のコマンドを実行するとブラウザが開きますので、Githubの認可を行います。

% gh auth login

これで、bundle installが成功します。

git configを使う方法

以下のコマンドを実行します。参考

% git config url.git@github.com:.insteadOf https://github.com/

そうすると、sshで参照するようになるためbundle installが成功します。

CI/CD環境の場合

github actionsやAWS codebuildなどのCI/CD環境について記載します。

Github Appsの作成

まず、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のリポジトリへの導入

Github App 左メニューのInstall Appを選択し、歯車アイコンをクリックします。

必要なリポジトリを選択し、Saveします。

Github Actions

控えておいた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

AWS CodeBuild

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メソッドの注意点

to_jsonメソッドの注意点

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メソッドでは、オプションを指定することで、出力を絞り込むことができます。

option
内容

only

特定の属性のみに絞り込む場合に使います。

except

特定の属性を除外したい場合に使います。

include

特定のassociation(has_manyやbelongs_toで指定しているような別のモデル)を出力に含めたい場合に使います。

methods

特定のメソッドを呼び出した結果を含めたい場合に使います。

methodsオプションは、特定の属性を絞り込むというよりかは、追加で情報を出力する用途に使いますが、関連する機能なので併せて説明します。

以下、各オプションの使用例を示します。

only

さきほどの例で、onlyを指定すると、以下のように指定した属性だけが出力されます。

> user.to_json(only: [:id])
=> "{\"id\":1111111}"

except

さきほどの例で、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

さきほどの例で、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

さきほどの例で、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)の株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。

危険なJSON出力を禁止するRuboCopカスタムルールの作成方法

背景

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

ASTの出力

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)の株式会社プレセナ・ストラテジック・パートナーズエンジニア公式で発信しています。ご確認ください。