【Rails】sandbox_by_defaultのPRを読みながらRailsの設定の仕組みを少し読んだ

はじめに

先日、Railsガイドを読んでいたら、sandbox_by_defaultという設定がRails7.1から追加されたことを知りました。 本番環境での操作ミスを低減できるので良いなと感じ、早速業務で使っているアプリでもこの設定を有効にしました。

この設定ってどういうコードで実現しているんだろうというのを疑問に思ったので、sandbox_by_defaultのPRを見ながらざっくりRailsのコードリーディングしてみました。

github.com

(まとめ)PRと関連のRailsコードを読んでわかったこと

  • Railsでconfigを管理しているのはRails::Application::Configurationクラス
    • config.xxxで書くような設定値はこのクラスのインスタンスのアクセサメソッド(attr_accessor)で管理されている
  • コンソールでのオプション管理を行うのはRails::Command::ConsoleCommandクラス
    • class_optionrailsコマンドのオプションを設定可能
    • そのうち、オプションの値を管理しているのはThor::Optionsクラス
  • 機能としてはただ単に設定を追加しただけに見えるが、PRを見ると、明示的にオプションをfalseで指定した時にはオプションを優先するといった考慮がされているのがわかった

コードリーディング内容

1. /rails/application/configuration.rb への変更

Rails::Application::Configurationクラスのattr_accessorsandbox_by_defaultが追加されている。

Rails::Application::Configurationクラスとは?
=> config/application.rbの中にconfig.xxxの形で設定を書くが、このconfigRails::Application::Configurationクラスのインスタンス

initializeの中で以下を設定し、デフォルトはfalseとして設定する。

@sandbox_by_default = false

config.sandbox_by_default = trueとすることで値を設定できるのは、 Rails::Application::Configurationクラスのインスタンスのアクセサメソッドが定義されているので、falseをtrueに書き換えているということ。

2. /rails/commands/console/console_command.rb への変更

class_optionへの変更

class_optionとは?の説明はRailsガイドにあった。

railsguides.jp

default: falseというのを見て気づいたが、--sandbox-s)のオプションには引数でbooleanが渡せる。

rails c -s false
#=> sandboxモードで起動しない

rails c -s true
#=> sandboxモードで起動する
疑問: 何も渡さない場合はtrueになるのはなんで?どこで制御している?

(1) railsコンソールを起動すると、class_optionを定義しているRails::Command::ConsoleCommandクラスのinitializeメソッドが呼ばれる

  • ruby/3.1.0/gems/railties-7.1.3.3/lib/rails/commands/console/console_command.rb
  • local_optionsという引数に ["-s", "false"]["-s"]というかたちで値が渡されている
  • その中でsuperでRails::Command::Baseinitializeが呼ばれる
    • ruby/3.1.0/gems/thor-1.3.0/lib/thor/base.rb
module Command
  class ConsoleCommand < Base
    # 略
    class_option :sandbox, aliases: "-s", type: :boolean, default: nil,
      desc: "Rollback database modifications on exit."

    def initialize(args = [], local_options = {}, config = {})
      # 略
      super(args, local_options, config) # local_options => ["-s", "false"]や["-s"]
    end
  end
end

(2) Rails::Command::Baseinitializeの中のopts.parse(array_options)でオプションのパースを行っている

  • array_optionsには ["-s", "false"]["-s"]の値が入っている
  • optsThor::Optionsインスタンス
module Base
  # 略
  def initialize(args = [], local_options = {}, config = {})
    # 略
    if local_options.is_a?(Array)
      array_options = local_options
      hash_options = {}
    else
      # 略
    end

    opts = Thor::Options.new(parse_options, hash_options, stop_on_unknown, disable_required_check, relations)

    self.options = opts.parse(array_options)
    # 略
  end
end

(3) opts.parseの中のparse_peek(switch, options)の中で実際にパースしている

  • ruby/3.1.0/gems/thor-1.3.0/lib/thor/parser/options.rb
  • parseの中でparse_peekを呼ぶ
  • parse_peekの中でparse_booleanが呼ばれて、オプションのキー(--sandbox)に対する値が渡されているかどうかを見て、値が渡されている場合は値をパースする
class Thor
  class Options < Arguments
    def parse(args)
      # 略
      result = parse_peek(switch, option)
      assign_result!(option, result)
      # 略
      assigns
    end

    private

    def parse_peek(switch, option)
      # 略
      send(:"parse_#{option.type}", switch) # option.typeはbooleanなのでparse_booleanメソッドが呼ばれる
    end

    def parse_boolean(switch)
      if current_is_value?
        if ["true", "TRUE", "t", "T", true].include?(peek)
          shift
          true
        elsif ["false", "FALSE", "f", "F", false].include?(peek) # 'false'という文字をfalseにする
          shift
          false
        else
          @switches.key?(switch) || !no_or_skip?(switch)
        end
      else # 値がない場合は下記の処理をする
        @switches.key?(switch) || !no_or_skip?(switch) # --sandboxがkeyに含まれているのでtrueを返す
      end
    end
  end
end

sandbox?メソッドへの変更

class_optionの変更にあるように、デフォルト値がnilになっている。 つまり、--sandboxのオプションを使用せずrailsコンソール起動した場合、Rails7.0までは、options[:sandbox]falseだったが、Rails7.1からはnilを返すことになる。

この修正によって、以下の2点がわかる。

  • rails c --sandbox falseのように「オプションを明示的にfalseで指定した時」とrails cのように「オプションを指定しないとき」の区別がつくようになる。
  • オプションを明示的で指定したときは、configの設定に関わらずオプションの方を優先するようになる。(rails c --sandbox falseだとsandboxモードは無効にする。)
# config.sandbox_by_default = true の設定を書いている時

$ rails c
#=> sandboxモードで起動

$ rails c --sandbox false
#=> sandboxモードは無効

ちなみに、この設定は本番環境で間違ってDB更新をすることを避けるために作られているため、developmenttest環境では無効となっている。 この制御をするために使われているRails.env.local?もRails7.1から入ったメソッド。

techracho.bpsinc.jp

『オブジェクト指向設計実践ガイド』を2回目読んだら今度は理解できた


以前こんなブログを書きました。

blog.m-ito27.com

「勉強にはなった部分がたくさんあるが、意味が分からない点も多かったのでもう少し力をつけてまた読みたい」といったことを書きました。

ということで、約1年半経ったのでリベンジで読んでみました。
すると、今度はすんなり理解できる部分が多くなっていて、少しは成長できているな、と嬉しくなりました。

なるほどと思った点と感想を簡単にまとめました。

第1章 オブジェクト指向設計

世界をあらかじめ決められた手続きの集まりと考えるのではなく、オブジェクト間で受け渡されるメッセージの連続としてモデル化する。

読書メモ: Aが何かをして、Bがこうなった、という事象を、決まった流れとして考えるのではなく、「Aが何かをする」「Bがこうなる」というそれぞれは別物として考え、そのやりとりを考えるのがオブジェクト指向設計というもの、という理解をしました。(うまく書けないですが)

設計の目的は「あとにでも」設計をできるようにすること

読書メモ: 面白い表現ですが、確かにこういう言語化もできるなと納得しました。ストンと腹落ちする表現だと感じました

第2章 単一責任のクラスを設計する

クラスが単一責任かどうかを見極めるには、

  • クラスの持つメソッドを質問に言い換えたときに意味を成す質問になっているか
  • 1文でクラスを説明できるか(「それと」が含まれていると、 複数の責任を負っている可能性あり。「または」が含まれるなら、互いに関係のない責任を負っている可能性あり)

  • 書籍の例では、Gearの中でWheel.newするのではなく、wheelインスタンスを渡すようリファクタリングしている。
    これにより、GearWheelというクラス名を知っていることや引数の数や順番を知らない状態にできる。

  • ポイントとしては、Gearクラスは、「Wheelインスタンスdiameterメソッドを持っていることを知っている」のではなく、「単に@wheeldiameterメソッドを持っていることを知っている」のみということ。(ダックタイピングの思考)

依存方向の管理

「自身より変更されないものに依存しなさい」

  • GearWheelクラスのことや引数の順番、数を知っているのは具象的なコードに依存している状態。 一方で、diameterメソッドを持っているオブジェクトである、ことを知っている状態は抽象化されたものに依存している状態と言える。

第4章 柔軟なインターフェースをつくる

  • ドメインオブジェクトから考えて、何をさせるか、を考えるのではなく、オブジェクト間で交わされるメッセージに注意を向けることが必要であることが必要。(書籍の例で言えば、Customersuitable_tripsを送ること自体はなんら問題なく、考えるべきはその受け手だ、という話)

第5章 ダックタイピングでコストを削減する

  • ダックタイピングはまさに「メッセージに注目した」技術。 書籍の例だと、特定のクラスのインスタンスに依存しているのではなく、prepare_tripメソッドを返すオブジェクトであることに依存している。というもの。

第6章 継承によって振る舞いを獲得する

読書メモ:変数にtypeやcategoryといった類の名前がついているときは、要注意。クラス以内でifでそのカテゴリーごとに処理が分かれる箇所が多数発生している場合、継承という選択が良いかもしれない。

  • 階層構造を作るのはコストがかかるため、重複するコードがいくつか発生することを許しても継承するというコードを遅らせるという判断もあり得る。

  • 継承は、具象クラスから抽象的な振る舞いを抽出して抽象クラスに押し上げる方向で行うこと。逆になると具象的な振る舞いが抽象クラスに置き去りにされ、抽象クラスが信頼できないものになる。

第7章 モジュールでロールの振る舞いを共有する

抽象スーパークラス内のコードを使わないサブクラスがあってはなりません。

読書メモ: これは守れていないことも多そう。一部のサブクラスでしか使わないなら、スーパークラスではなくサブクラスに定義しないと間違った振る舞いを与えるサブクラスが生まれる。

継承する側でsuperを呼び出すようなコードを書くのは避けましょう

読書メモ: 上記にも書いたフックメソッドを使うようにして、スーパークラスアルゴリズムを知っておく責務から解放するべきというもの。super使ってしまうことは多いと思うので、注意して確認してみたい。

第8章 コンポジションでオブジェクトを組み合わせる

  • 継承が親クラスの機能を全て引き継ぐ(is_a)関係なのに対し、コンポジションはあるクラスが他のクラスのオブジェクトを含む(has_a)関係を表す。

  • 継承と違い、各クラスが独立するため、影響範囲の測定のしやすさ等でメリットがある。

一般的なルールとしては、直面した問題がコンポジションによって解決できるものであれば、まずはコンポジションで解決することを優先するべきです。継承の方がより解決法であるとはっきり言い切れないときはコンポジションを使いましょう。

読書メモ: Railsアプリにおけるコンポジションの考え方は前島さんの記事が分かりやすかった。https://tech.medpeer.co.jp/entry/2017/11/08/120000

第9章 費用対効果の高いテストを設計する

テストのセットアップに苦痛が伴うのであれば、コードはコンテキストを要求しすぎています。1つのオブジェクトをテストするために、ほかのオブジェクトをいくつも引き込まなければならないのであれば、そのコードは依存関係を持ちすぎています。

読書メモ: これはRailsのモデルspecを考える上でも参考になりそう

  • プライベートメソッドが多い場合はそれらの処理を別のクラスに切り出せないか疑う。

  • テストを書く際、メソッド内で送信するメッセージについては、副作用のないクエリメッセージの場合はテストしない(受信するクラス側でテストを書くべき)。 ただし、副作用のあるコマンドメッセージの場合は送信することをテストする必要はある。(ただしメソッドの中身までは見ない。これも受信するクラス側でテストを書くべき。)

読書メモ: コマンドメッセージの例は、メールの配信で考えられそう。例えばモデルのメソッドの中でメール送信している場合、(モックを使うなどして)メール送信のメソッドを呼ぶこと自体はテストするべきだが、どんなメールが送られれるかは、メールクラスの単体テストでテストされるべき、という話。確かに重複をなくすという点では良さそう

【Rails】paranoiaで論理削除したレコードはユニーク制約バリデーションの対象外になる

paranoiaを使っているモデルでユニーク制約のバリデーションを設定すると、論理削除済みのレコードは制約の対象から外れます。(論理削除済みのアイテムとは重複が許される)
その仕組みを理解するために少しgemのコードを読んで気づきがあったので記載します。

きっかけと概要

paranoiaを使っているモデルでバリデーションのユニーク制約をかけた際に、以下のことを知りました。

  • paranoiaで論理削除しているとユニーク制約の対象外になります
class Book < ApplicationRecord
  validates :title, uniqueness: true

  acts_as_paranoid
end
Book.all
# [#<Book id: 1, title: "Ruby", deleted_at: Fri, 15 Mar 2024 ...>]
Book.create!(title: 'Ruby')
#=> success
  • 一方で、default_scopeだけだと対象範囲内です
class Book < ApplicationRecord
  default_scope -> { where.not(deleted_at: nil) }
  validates :title, uniqueness: true
end
Book.all
# [#<Book id: 1, title: "Ruby", deleted_at: Fri, 15 Mar 2024 ...>]
Book.create!(title: 'Ruby')
#=> Validation failed: Title has already been taken (ActiveRecord::RecordInvalid)

違いが生まれる仕組み

まず、Railsのユニーク制約のバリデーションは以下のコードです。

lib/active_record/validations/uniqueness.rbから一部抜粋

module ActiveRecord
  module Validations
    class UniquenessValidator < ActiveModel::EachValidator
      def validate_each(record, attribute, value)
        finder_class = find_finder_class_for(record)
        value = map_enum_attribute(finder_class, attribute, value)

        relation = build_relation(finder_class, attribute, value)
        if record.persisted?
          if finder_class.primary_key
            relation = relation.where.not(finder_class.primary_key => record.id_in_database)
          else
            raise UnknownPrimaryKey.new(finder_class, "Cannot validate uniqueness for persisted record without primary key.")
          end
        end
        relation = scope_relation(record, relation)

        if options[:conditions]
          conditions = options[:conditions]

          relation = if conditions.arity.zero?
            relation.instance_exec(&conditions)
          else
            relation.instance_exec(record, &conditions)
          end
        end

        if relation.exists?
          error_options = options.except(:case_sensitive, :scope, :conditions)
          error_options[:value] = value

          record.errors.add(attribute, :taken, **error_options)
        end
      end

      private

      def build_relation(klass, attribute, value)
        relation = klass.unscoped
        comparison = relation.bind_attribute(attribute, value) do |attr, bind|
          return relation.none! if bind.unboundable?

          if !options.key?(:case_sensitive) || bind.nil?
            klass.connection.default_uniqueness_comparison(attr, bind)
          elsif options[:case_sensitive]
            klass.connection.case_sensitive_comparison(attr, bind)
          else
            # will use SQL LOWER function before comparison, unless it detects a case insensitive collation
            klass.connection.case_insensitive_comparison(attr, bind)
          end
        end

        relation.where!(comparison)
      end
    end
  end
end
  • uniqueness設定のあるカラムの、今保存しようとしている値の条件で検索して一致するレコードがあればエラーを追加する、という処理の流れです
  • build_relationの最初にunscopeしているので、default_scopeはここで一度リセットされます。(paranoiaを使っていても、論理削除済みレコードも関係なく取得される)

上記のうち、build_relationがポイントです。

paranoia側で、モジュールを用意していて、RailsUniquenessValidatorの方にprependしているので、先にparanoia側のメソッドがメソッド探索で見つかるようになっています。

module ActiveRecord
  module Validations
    module UniquenessParanoiaValidator
      def build_relation(klass, *args)
        relation = super
        return relation unless klass.respond_to?(:paranoia_column)
        arel_paranoia_scope = klass.arel_table[klass.paranoia_column].eq(klass.paranoia_sentinel_value)
        if ActiveRecord::VERSION::STRING >= "5.0"
          relation.where(arel_paranoia_scope)
        else
          relation.and(arel_paranoia_scope)
        end
      end
    end

    class UniquenessValidator < ActiveModel::EachValidator
      prepend UniquenessParanoiaValidator
    end
  end
end

流れとしては、superRails側のbuild_relationでベースとなるリレーションを取得しつつ、relation.where(arel_paranoia_scope)で論理削除済みのレコードを除くクエリをかけています。

まとめ

  • uniquenessを使った場合、Rails側のバリデーションでdefault_scopeは無視するようにしています。
  • 一方、paranoiaacts_as_paranoidを使っているときは、paranoia独自のモジュールが作用することで、追加で論理削除済みのレコードはユニーク判定の対象から外す(重複を許す)よう意図して設計されています。

paranoia以外の論理削除gemはきっと違う動きをする(以前discardを使ったときは論理削除済みのレコードと重複できなかった気がする)と思うので、使っているgemによってきちんと確認する必要がありますね。

違和感に耳を傾ける重要性

雑記です。
最近、「やってしまったなー」という出来事がありました。

違和感に目を瞑ってしまった

とあるPull Request(PR)について、「あれ?あの部分考慮されていないかも?」と違和感を感じたことがありました。
ですが、そのPRは既にマージされていて、自分が実装者やレビュー者にはなっていなかったことで、きちんと実装者なりに確認することを躊躇ってしまいました。
なんなら「横からいまさら口を挟むのは申し訳ないな」という意味のわからない正当化までしていました、、

不具合の発生

この結果、違和感の場所が原因で本番環境で不具合が起きてしまいました。
振り返ると、私が行ったのは、ただの気遣いではなく、明らかな怠慢だなと思います。
また、私は「レビューされているから大丈夫だろう」と思い込んでいました。この思い込みも、問題を未然に防ぐ機会を逸してしまった要因でした。

教訓

この出来事を通じて、違和感を感じたらそれを見て見ぬ振りをしてはならないと強く感じました。

もし違和感を感じたら、自分の直接の責任範囲外であっても、その違和感を解消するように動くべきです。
結果間違ってなかったらそれでOKですし、間違っていたら未然に防ぐことができてファインプレーです。

さいごに

とはいえ、こうして文章で書くのは簡単ですが、実際に仕事をしていると自分の直接の担当外でちらっと見ただけのPRについての疑問をいちいち聞いてる時間はない、など難しい点もあると思います。

したがって結局のところ、この違和感を見逃したら最悪の場合何が起きるか、ということを考えながらその影響と労力とのバランスを意識して上手にやっていく、ということになるのかなーと思った出来事でした。

【git hooks】pre-pushで特定のブランチへの誤pushを防止するようにした

はじめに

リリース時にのみmasterにpushすることがあるんですが、これをリリース時以外にやってしまうとrevert操作などが必要になって結構面倒なことになります。

そこで、git hooksのpre-pushフックを使って特定のブランチへのpushの際に確認メッセージを表示するように設定しました。(chatGPTの力も存分に借りながら)

設定方法

1. .git/hooks/git-pushファイルを作成する
2. 設定を記載する

以下の設定では、masterdevelopにpushするときに、[WARNING] push to ブランチ名, are you sure? (Y/n)といったメッセージが表示され、Yを入力した際にのみpushがされます。

#!/bin/sh

protected_branches="master develop"
current_branch=$(git branch --show-current)

for branch in $protected_branches; do
  if [[ "$current_branch" == "$branch" ]]; then
    echo "[WARNING] push to $branch, are you sure? (Y/n)"
    exec < /dev/tty
    read answer

    if [[ $answer != "Y" ]]; then
      echo "Push canceled"
      exit 1
    fi
  fi
done

exit 0
3. 実行権限を付与

作成したファイルに実行権限を付与します。

$ chmod +x .git/hooks/pre-push

以上で、設定は完了です。

確認

例えば、nを入力するとpushはキャンセルされます。

$ git push
[WARNING] push to master, are you sure? (Y/n)
n
Push canceled

逆に、Yを入力すればpushされます。

おわりに

chatGPTに言われた方法だと、readコマンドを使う方法で、それを元に色々試してみたのですが、うまくいかなかったです。(pre-pushで行うのも関係している、、?と思いましたが深追いはしていません)

ググっていたら以下の記事に出会って、readコマンドではなく、exec < /dev/ttyで行うようにしました。

  • 参考記事

dev.classmethod.jp

Railsガイドにきちんと目を通して新しい知識を得る - Rails のスレッドとコード実行編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、Rails のスレッドとコード実行の章です。

railsguides.jp

ExecutorとReloader

リンクはこちら

理解するのが難しかった(今でも完全には理解できていない)ですが、Kaigi on Rails 2023の動画がわかりやすかったです。

kaigionrails.org

  • Executorはフレームワークとアプリケーションのコードを区別するもの
  • これがあるのでDBコネクションやキャッシュクリアを意識せずに開発できる
  • 例えばアクションを実行する際、Executorは、実行前にto_runを、実行後にto_completeを呼び出す
  • Executorはリソース管理のみ
  • ReloaderはExecutorをwrapした上で、アプリケーションコードを実行する前に最新のコードが読まれているかを確認する
  • 例えば、gemからアプリケーションコードを読むとき、リソース管理とコードの再読み込みをするには、Rails.application.reloader.wrapを使う

permit_concurrent_loads

リンクはこちら

Railsガイドのサンプルコードの理解

Rails.application.executor.wrap do
  th = Thread.new do
    Rails.application.executor.wrap do
      User # 内側のスレッドはここで待機する
           # 他のスレッドが実行中はUserを読み込めない
    end
  end

  th.join # 外側のスレッドは'running'ロックをつかんだままここで待機する
end
  • 「外側のスレッド」: 1行目のRails.application.executor.wrapを実行している
  • 「内側のスレッド」: Thread.newによって生成された新たなスレッド
  • join: 特定のスレッド(今回はth)の実行が完了するまで、それを呼び出したスレッド(外側のスレッド)の実行を一時停止する

=> Userを読み込むためには外側のrunningロックが解放されないといけないが、外側のスレッドはth.joinで内側のスレッドの終了を待って一時停止しているのでデッドロックになる

これに対して、permit_concurrent_loadsを使うことでそのスレッドでは、提供されたブロック内で自動読み込みされた可能性のある定数を参照解決しないことが保証され、ある意その定数の読み取りを他スレッドに「許可」できる。

Railsガイドにきちんと目を通して新しい知識を得る - Rails のキャッシュ機構編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、Rails のキャッシュ機構の章です。

railsguides.jp

条件付きGET

リンクはこちら

stale?を使うことで条件付きGETを実現できます。

stale?を使ってキャッシュしたページを見せる場合、ステータスコード304 Not Modifiedが返ってくることを確認できました。

Started GET "/books/1"
Processing by BooksController#show as HTML
  ...
Completed 304 Not Modified in 40ms (ActiveRecord: 3.1ms | Allocations: 5132)

開発環境のcacheのON/OFF

リンクはこちら

dev:cacheを使うことで、開発環境で自由にcacheをON/OFFできます。

$ rails c
irb> Rails.cache.write('sample_key', 'sample_value')
irb> Rails.cache.read('sample_key')
#=> nil

$ rails dev:cache
#=> Development mode is now being cached.
$ rails c
irb> Rails.cache.write('sample_key', 'sample_value')
irb> Rails.cache.read('sample_key')
#=> "sample_value"