【Rails】transactionのrequires_newの挙動について整理する

先日、Railsのtransactionのrequires_newオプションを知りました。

techracho.bpsinc.jp

tech.smarthr.jp

記事などを見れば理解できるのですが、(非推奨の):joinableと混同して「どっちだっけ?」となるので自分なりに表にまとめて整理しました。

まとめ

No. requires_new 例外の種類 ロールバックする処理
1 false false AR:Transaction 子の処理もロールバックしない
2 true false AR:Transaction 子の処理もロールバックしない
3 false true AR:Transaction 子の処理のみロールバックする
4 true true AR:Transaction 子の処理のみロールバックする
5 false false AR:Transaction以外 親の処理までロールバックする
requires_newは無関係

<補足>

  • 検証バージョン: Rails.8.0
  • requires_newfalseがデフォルトです
  • AR::RollbackActiveRecord::Rollbackの略です
  • 「親の処理」「子の処理」は以下のイメージです
ActiveRecord::Base.transaction do
  Car.create! # ←「親の処理」と呼んでいます
  ActiveRecord::Base.transaction do
    Bike.create! # ←「子の処理」と呼んでいます
    # ここで例外が起きるイメージ
  end
end

検証用コード

上記の表No.1~No.5について、検証用のRSpecの結果を書いておきます。

No.1

it do
  expect do
    ActiveRecord::Base.transaction do
      Car.create!
      ActiveRecord::Base.transaction do
        Bike.create!
        raise ActiveRecord::Rollback
      end
    end
  end.to change(Car, :count).by(1)
    .and change(Bike, :count).by(1)
end

# ↓結果
# 1 example, 0 failures

No.2

it do
  expect do
    ActiveRecord::Base.transaction(requires_new: true) do
      Car.create!
      ActiveRecord::Base.transaction do
        Bike.create!
        raise ActiveRecord::Rollback
      end
    end
  end.to change(Car, :count).by(1)
    .and change(Bike, :count).by(1)
end

# ↓結果
# 1 example, 0 failures

No.3

it do
  expect do
    ActiveRecord::Base.transaction do
      Car.create!
      ActiveRecord::Base.transaction(requires_new: true) do
        Bike.create!
        raise ActiveRecord::Rollback
      end
    end
  end.to change(Car, :count).by(1)
    .and change(Bike, :count).by(0)
end

# ↓結果
# 1 example, 0 failures

No.4

it do
  expect do
    ActiveRecord::Base.transaction(requires_new: true) do
      Car.create!
      ActiveRecord::Base.transaction(requires_new: true) do
        Bike.create!
        raise ActiveRecord::Rollback
      end
    end
  end.to change(Car, :count).by(1)
    .and change(Bike, :count).by(0)
end

# ↓結果
# 1 example, 0 failures

No.5

it do
  expect do
    ActiveRecord::Base.transaction do
      Car.create!
      ActiveRecord::Base.transaction do
        Bike.create!
        nil.foo # NoMethodError
      end
    end
  end.to raise_error(NoMethodError)
    .and change(Car, :count).by(0)
    .and change(Bike, :count).by(0)
end

# ↓結果
# 1 example, 0 failures

【Rails】debug_exception_log_levelが効かない?と思ったらbetter_errorsが制御していた

前回に引き続き、better_errors gemによって想定とは違う挙動をしていた体験をしたので、記事にしました。

↓参考: 前回の記事

blog.m-ito27.com

↓better_errors

github.com

背景: Rails7.1から例外のデフォルトログレベルがWARNになった

先日、とあるWebアプリのRailsアップグレード(Rails7.1→Rails8.0)をしていました。
config.load_defaultsが古かったので、Railsに合わせて引き上げる作業をしていたのですが、Rails7.1からconfig.action_dispatch.debug_exception_log_levelのデフォルトの値が従来の:fatalから:warnに変更されました。

railsguides.jp

この設定が変更されたPRを確認すると、「Ruby LoggerのドキュメントではFATALは『プログラムのクラッシュを引き起こす、処理不能なエラー』と定義されており、DebugExceptionがエラーを処理している現状と合わない。」という旨の説明がされています。

github.com

設定値変えてもログに変化がない?

そこで、この設定の値を :warnにして、ローカル環境で例外を発生させてみたのですが、ログには FATALとして表示されました。 ちなみに、ログレベルはseverityを使って表示できます。

config/environments/development.rb(簡略化しています。)

config.action_dispatch.debug_exception_log_level = :warn
config.log_formatter = proc do |severity, time, progname, msg|
  "[#{time.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} -- #{progname}: #{msg}\n"
end

出力されるログのサンプル

[2025-09-14 23:17:44] FATAL -- : 
RuntimeError - 例外が発生:
  app/controllers/...

あれ?ここがWARNになるってことではないの、、?

better_errorsがログレベルを制御していた

アプリ内のコードを確認しても、特別ログレベルをカスタムしている場所はなかったので、Gemfileを確認すると、better_errorsがあり、怪しそうに思ったので試しにbetter_errorsコメントアウトしてみました。

すると、、、

[2025-09-14 23:45:39] WARN -- :   
RuntimeError (例外が発生):

WARNになりました!

具体的には、以下の箇所で制御されていました。

better_errors/lib/better_errors/middleware.rb

def log_exception
  return unless BetterErrors.logger

  message = "\n#{@error_page.exception_type} - #{@error_page.exception_message}:\n"
  message += backtrace_frames.map { |frame| "  #{frame}\n" }.join

  BetterErrors.logger.fatal message # ←ここ
end

実際にここをwarnに変えたらログはWARNで表示されました。

さいごに

コントリビュートチャンス?と思いましたが、better_errorsは2年前を最後にコミットが追加されておらず、メンテが止まっているようです。
内容が軽微なこともあり、モンキーパッチを当てるなどはせず、「config.load_defaultsに合わせてデフォルト値は変わるが、ローカル環境のログ上はFATALのまま」、というのを許容することにしました。
better_errosの良い代替gemってあるのかなー、、とぼんやり思ったりしています。

【Rails/RSpec】better_errors gemをテスト環境で有効にしていたらハマった

better_errorsは、Railsのデフォルトエラーページよりもエラーを詳細に表示し、デバッグを助けてくれるgemです。

github.com

READMEにある通り、基本的にはdevelopment環境での使用が想定されています。
しかしながら、今回、test環境で有効にしていたことによりハマったことがあったので、記録として残しておきます。

何が起きたか

テストを書いていたら、以下の状況になりました。

「例外を起こすことのテストで、RSpec実際に例外が発生しているはずなのにraise_errorするとテストが失敗するぞ?

↓実際に書いたテストを簡略化したもの

it do
  expect { get root_path }.to raise_error RuntimeError # root_pathにアクセスすると例外が発生するようにしているので、それを確認するテスト
end

↓結果

Failure/Error: expect { get root_path }.to raise_error RuntimeError
       expected RuntimeError but nothing was raised

画面上でアクセスしても例外が発生するのに、テストすると「例外なんて発生していないよ」と言われ、結構混乱しました。

原因: better_errorsがテスト環境にインストールされていた

色々試行錯誤する中で、悪さをしているライブラリがないかとGemfileを見ると、あることに気づきました。

group :development, :test do
  gem 'better_errors'
end

あれ、better_errorsってテスト環境で有効にしなくても良いものでは?と思い、とりあえず消してみました。

↓修正後のGemfile

group :development do
  gem 'better_errors'
end

結果、、、

Finished in 0.02689 seconds (files took 1.18 seconds to load)
1 example, 0 failures

これかー!

better_errorsをtest環境で有効にしてしまっていたのが原因とわかりました。 では、具体的に、なぜbetter_errorsをテスト環境に入れていると例外が捕捉できないのでしょうか?

better_errorsは例外をrescueする

better_errorsのコードを読んだら、例外をrescueして、カスタマイズしたエラー画面を表示していました。 そして例外はそのまま伝播させず、500のレスポンスとして返すようにしていました。

better_errors/middleware.rb

def protected_app_call(env)
  @app.call env
rescue Exception => ex
  @error_page = @handler.new ex, env
  log_exception
  show_error_page(env, ex)
end

def show_error_page(env, exception=nil)
  # ...(略)
  [status_code, headers, [content]]
end

だからRSpec側でraise_error XXXしても、例外は発生していない、と言われたんですね。

さいごに

しばらく、better_errorsというところに当たりをつけられず解決まで少し時間を要しましたが、原因がわかってスッキリしました。
このリポジトリbetter_errorsを導入したのは私ではないのですが、おそらくsystem specの失敗時のスクリーンショットからデバッグをしやすくするために入れたのかなーと推測しています。

実はこれ以外にもbetter_errorsによって想定と違う動きをした、という例があるのでまた記事にして残しておきたいと思います。

『暗号技術入門』読書メモ

cr.hyuki.net

少し前に『暗号技術入門』(第1部、第2部)を読んだので読書メモを残しておきます。

第2章

シーザー暗号

  • 文字ずらしで暗号化する方法

  • シーザー暗号をRubyで書くと、、を考えてみた。

    • 自分で考えた方法(nextを使う)
class String
  def encrypt_text(shift_int)
    splited_str = chars
    shift_int.times do
      splited_str.each { _1.next! }
    end
    splited_str.join
  end
end

puts '123あいうえおABC'.encrypt_text(3)
#=> 456ぅぇぉかきDEF
  • chatGPT先生に聞いたところ、アルファベット限定だが、rotatetrを使う方法が返ってきた。なるほどー。
def shift_string(str, shift)
  alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
  shifted_alphabet = alphabet.chars.rotate(shift).join
  str.tr(alphabet, shifted_alphabet)
end

puts shift_string("ABD", 3)
#=> "DEG"

単一換字暗号の解き方

  • 単一換字暗号とは、文字を不規則に1対1で置き換える方法(aはc, bはf, cはiなど)
  • 組み合わせはアルファベットだと26 * 25 * .. * 1の403291461126605635584000000通りあり、一見解析は困難に思える
  • しかし、頻度分析(アルファベットだとtheなどが多く、推測しやすい。また、頻度が少ないものもヒントになる。)を使うと、単純計算では組み早稲が膨大であっても解析される。
  • 感想
    • どこかでこの解法は見た記憶があるが、改めてなるほどーーと感じた。

秘密にするべきは暗号方式ではなく、鍵

  • 暗号化方式は秘密にしていても必ず解析されるため、秘密の暗号化方式を使うことで強化にはならない(むしろ脆弱になる)
  • したがって、秘密にするべきは鍵
  • 暗号化方式がわかっていても(いずれ解析できる)、鍵を秘密にすることで機密性を高めることができる。

第3章

XOR

  • 本筋とは関係ないが、本書で紹介されていたXORの説明が、わかりやすいかもと思ったのでメモ

    • 裏返すor裏返さないの行動を2回行った結果、オセロが最初の状態と比べて裏返されている(1)かと考えるとわかりやすい
      • 2回とも裏返す, 2回とも裏返さない → 元の面なので0
      • どちらか1回だけ裏返す → 裏返っているので1
  • A XOR B の結果を またXOR Bすると、Aに戻るというのはRubyで近しいコードが作れる

require 'set'

set = Set[1, 3, 5, 6, 7, 10]
key = Set[2, 5, 7, 10]

p set ^ key

p (set ^ key) ^ key # setと同じになる。

使い捨てパッド

  • 平文と同じ長さの完全にランダムなビット列(鍵)を用意し、XORをしたものを暗号文とするもの
  • 数学的に解読不可能であることが証明されているが、鍵の配送問題など使いづらい点が多い

AESの公募

  • NISTがAESを公募
  • プログラムが公開され、無料で使えることなどが条件
  • 応募者たちは、自分の提出したものを通すため、他の応募者の暗号方式の弱点を調べ上げることになった => 正しいコンペのかたちが実現された

第5章

RSAの由来

(本筋の話ではないが、あ、そうなの、と思ったのでメモ。)
技術的な名称の略ではなく、3人の開発者(Rivest-Shamir-Adleman)の頭文字。

RSAの暗号と複合

  • 暗号化の計算式は、暗号文 = 平文のE乗 mod N (EとNが公開鍵)
  • 複合化の計算式は、平文 = 暗号文のD乗 mod N (DとNがプライベート鍵。Nは共通)

暗号化の流れ

  • とある素数p, qをかけた数である、Nを求める
  • p-1とq-1の最小公倍数であるLを求める
  • 1より大きくLより小さい、Lとの最大公約数が1となるEを求める
  • 1より大きくLより小さい、E*D mod L = 1となるDを求める

なぜ安全?

  • 公開鍵情報に含まれるのはEだが、Eを作るために使用したp, q, Lを解読者は知らない
  • N = p * qのため、p, qが求められそう?だが、大きな数の素因数分解を高速にできる方法はまだ発見されていないので、安全と言える
    • 逆にこれが可能になったら解読可能

第6章

  • 疑似乱数発生器で生成した「セッション鍵」を対称暗号方式の鍵として、メッセージ(平文)を暗号化
  • 上記セッション鍵を公開鍵暗号方式で暗号化
  • これにより、公開鍵暗号方式の遅いというデメリットと対照暗号方式の鍵配送問題を解決している

第7章

  • 弱衝突耐性と強衝突耐性
    • 若干言葉と意味に自分的にはズレがあった
    • 弱衝突耐性
    • 強衝突耐性
      • 同じハッシュ値となる、異なる2つのメッセージを求めることが非常に困難であること
  • つまり、弱衝突耐性があれば、強衝突耐性も満たしていることになる

  • SHA-3

    • SHA-1の強衝突耐性が破られたことで新しく公募された
    • KECCAK(ケチャック)というアルゴリズムに決まった
      • そのうち、採用条件には「クリーンな構成で解析しやすいこと」というのがあった
        • 逆じゃないの?と思うかもしれないが、解析しやすい=弱点を見つけやすい→それでも弱点がないなら強いよね、ということみたい
  • 感想

    • ここまで読んでいて、Railsアプリでよく使われるdevise gemは bcryptだけど、SHA-3とかとは違うのだろうか?と思ったので少し調べた
      • bcryptはパスワードのために作られた方式で、ソルトを使った方式がデフォルトだったり、意図的に処理速度を遅くしてパスワード一覧攻撃に強くしていていることが特徴

第8章

  • 上記まででは、メッセージが正しい送信者から送られて来たものか?(認証)ができていない
  • そこで、メッセージ認証コード(MAC)という技術がある
    • 送るメッセージに対して共有鍵を使ってハッシュ化する(MAC値)
    • 送信時、メッセージと共にMAC値を送信する
    • 受信者側で、同じく持っている共有鍵を使ってMAC値を作成し、一致すれば鍵を共有している相手から来たメッセージと判断できる

第9章

  • そのメッセージを送信したのが誰か、を証明できるようにする技術が、デジタル署名
  • 公開鍵暗号方式を逆に使うことで実現している
    • 署名作成側がメッセージをハッシュ化したものを秘密鍵で暗号化
    • 検証側で公開鍵で復号し、検証

第10章

  • デジタル署名の発行元を信頼するための仕組みが、認証局
  • 認証局は、(本人確認ができている)署名作成者の公開鍵を受け取り、認証局秘密鍵でデジタル署名したものを、作成者の公開鍵に付与した上で、受信者に送付する
  • 受信者は、認証局の公開鍵でデジタル署名を検証し、検証できたら受け取った作成者の公開鍵が正当なものであることを確認できる

おわりに

偶然なことに、この本を読んでから実務の方でもAPIの開発で暗号化や署名を付与する実装が必要になり、この本で学んだ内容が役に立ちました。 第3部は読むのに少しパワーが要りそう?で(他にやりたいこともあり)一度置いてしまいましたが、またそのうち読みたいと思います。

外部APIを自社システムに連携するときに気をつけること

先日、自社システムで新しく外部APIの連携を行いました。
その過程で、外部APIを使うときに気をつけることがいくつかあるなーと思ったので記事にします。

1. 外部API側のエラーが起きる前提の実装をし、リカバリーの手段を用意しておくこと

外部APIは、いつ、どんな事情でエラーが起きるか分かりません。
外部API側でエラーが起きたとき、開発チームに通知するところまでは行うことが多いと思います。
ただし、そこで終わってしまうのは勿体無いです。

開発者に通知するだけでは、実際にエラーが起きたとき初動でできることは、状況を確認し、それを元にAPIの提供側に問い合わせる、という程度に留まってしまい、お客さんに影響が出てしまった場合のリカバリーまで時間がかかることになります。

利用するAPIの性質にもよりますが、可能であれば管理者画面でリトライor手動でリカバリーできる方法を用意しておくこと(サービス運用者が力技で対応できる手段を残しておく)のが良いと思っています。

それば難しい場合は、せめてエラーが起きた際に状況を確認するマニュアルを用意しておく(レコードのどの値をまず確認するべきか、その場合の影響範囲など)くらいはしておくと、リカバリーまでの時間を大幅に早めることができるのでは、と思いました。

実際、今回のAPI導入後、外部API側でエラーが起きましたが、管理画面で対応する手段を実装しておいたことで障害にならず、かつ開発側で特に何もせず乗り越えることができました。

2. API管理画面も熟知しておく

私の反省ですが、外部API連携を進めるにあたって、どうしても実装の方に気を取られてしまい、外部API側の管理画面を隅々まで確認することをしばらく怠ってしまいました。
その結果、後から管理画面でいくつか不具合や不自然な挙動にあたり、慌てて外部API提供側に確認することになりました。

早めに管理画面を一通り触り、疑問点や運用していく上で障害になりそうな点を確認しておくと良さそうです。 例えば、以下のような点です。

  • APIキーの管理の仕方はどのようになっているか
  • 管理画面を触れるユーザーの管理はどのようになっているか
    • ロールがあるなら、それぞれのロールができること、できないことを確認
  • 外部API管理画面内の各種設定や機能を使ってみて、エラーが起きないか
    • 若いAPIサービスだと割とここでつまづくこともあります

利用する外部APIにまつわることは全て自分で知っておく、という気持ちでいるのが大切かなと思いました。

まとめ

今まで業務で外部APIの導入をやったことがなかったので色々と反省と気づきがありました。
次回以降機会があれば、今回の経験を活かしたいなと思います。

『なるほどUNIXプロセス』読書メモ

tatsu-zine.com

自分なりに整理したもの、理解しづらかったので追加で調べて理解したものなどメモです。

forkとCoW(Copy on Write)

  • fork: とあるプロセスから子プロセスを作ること。親プロセスがメモリに持つ情報をすべて持つ。

  • コピーをするためのオーバーヘッドが問題になってしまうことを避けるために、CoWの仕組みで対応

    • fork時にメモリ情報をコピーするのではなく、まずは親と同じメモリを共有。子プロセス側で情報の変更が必要になった場合(配列の中身を変えるなど)に、初めてコピーする。親プロセスのメモリの情報は変えない。
  • 子プロセスを作った親プロセスが死んでも、カーネルは子プロセスを特別視しないので、子プロセスには何も起きない(=一緒に死なない)

    • こうなったプロセスを孤児プロセスという
  • forkは2度返る

    • 親プロセスに対しては子プロセスのidを返し、子プロセスからはnilが返る
    • これを活用するしたコードが、exit if fork
      • 親プロセスを終了させ、子プロセスを孤児プロセスとして継続させる。

Process.wait2

  • 子プロセスのどれか1つの終了を待つ、という役割は同じだが、pidのみ返すのがpidで、pidと終了ステータスを返すのがwait2。

  • また、子プロセスを指定して終了を待つにはProcess.waitpidを使う。

  • Process.waitProcess.waitpidは同じ関数なので、Process.waitで引数に子プロセスを指定すればProcess.waitpidと同じ動きをするが、そのときやりたいことによってメソッドを変えるのが可読性の点で良い。
  • 感想
    • 同じ関数にする必要あったのかな?と感じた。waitは引数取れない、waitpidは引数必須とかだと問題あるのかなあ。

ゾンビプロセス

  • カーネルは終了したプロセスの情報をキューに持っている
  • Process.waitなどされない限り、プロセス情報を破棄できない(ゾンビプロセス)
  • リソースが無駄にならないよう、Process.detachを使うと、プロセスの終了を監視するスレッドを生成することができる。(親プロセスはブロックされない)

docs.ruby-lang.org

  • detachはpidを引数に取る必要があるが、trap(:CHLD)で子プロセスが親プロセスに送信するSIGCHLDを見て、処理を書くことができる。
    • ただし、シグナルはいつ受診するかわからないので、Process.waitする時点ですでに子プロセスがなく、Errno::ECHILDの例外を出すことも。そのため、この例外のrescueをしておくべし。
  • chatGPTに聞いてみた。detachtrap(:CHLD)の使い分けは?
    • Process.detach は 1つの子プロセスを個別に管理するのに適している。
    • trap(:CHLD) は すべての子プロセスを一括管理するのに適している。forkしまくる場合など。

trap

trap(:INT) { puts 'first' }
  old = trap(:INT) { 
  old.call
  puts 'second'
exit
}
sleep 5

↑これだとエラーが起きずに、

system_handler = trap(:INT) {
  puts 'abort to exit!'
  system_handler.call
}

sleep 5

↑これだとエラーが起きる(undefined methodcall' for "DEFAULT":String (NoMethodError)`)理由が最初よくわからなかった。

  • エラーが出ないコードは、最初にtrap(:INT)が設定されていて、old.callではそれを呼んでいる。
  • エラーが出るコードは、他にtrap(:INT)をしていないので、system_hander.callでデフォルトのアクションを呼ぼうとし、失敗。
  • これが、書籍の中の「システムのデフォルトの振る舞いは保存できない」ということと理解した。

  • その他メモ
    • プロセスはいつでもシグナルを受信できる

デーモンプロセス

exit if fork
Process.setsid
exit if fork

このコードの2回目のexit if forkをする理由がよくわからなかったので調べながら以下の理解をした。

①1回目のexit if forkでやっていること
  • 親プロセスを終了して孤児プロセスを作っている
  • プロセスグループ(セッション)は親から引き継いでいる状態
Process.setsidでやっていること
  • ①で作成した子プロセスをプロセスグループリーダー(セッションリーダー)にする
  • 親プロセスから切り離すことができるが、セッションリーダーは端末制御をしようと思えばできる
③1回目のexit if forkでやっていること
  • ①で作成した子プロセス(③の子プロセスの親プロセス)を終了する
  • ここでできた子プロセスはセッションリーダーでないので、端末制御ができず、完全に端末と分離された状態になる。

【Ruby】rails-settings-cached gemのコードリーディング

rails-settings-cached gemについて、設定の読み出し方、値の更新の仕方などが興味があったので読んでみました。

github.com

なお、rails-settings-cachedのバージョンは2.9.4です。

簡単なgemの使い方

  • ユーザーが以下のように設定ファイルを書くとSetting.admin_emailのような形で値を設定できる
  • Setting.admin_email = 'abc'のようにすると値をDBに保存し、今後はその値が使われる
class Setting < RailsSettings::Base
  cache_prefix { "v1" }

  field :admin_email, default: "admin@example.com", type: :string
end

1.fieldの定義で読み出しと上書きのメソッドを提供している仕組み

対応するコード

  • rails-settings-cached-2.9.4/lib/rails-settings/base.rb
def field(key, **opts)
  _define_field(key, **opts)
end

private

def _define_field(key, default: nil, type: :string, readonly: false, separator: nil, validates: nil, **opts)
  key = key.to_s

  raise ProtectedKeyError.new(key) if PROTECTED_KEYS.include?(key)

  field = ::RailsSettings::Fields::Base.generate(
    scope: @scope, key: key, default: default,
    type: type, readonly: readonly, options: opts,
    separator: separator, parent: self
  )
  @defined_fields ||= []
  @defined_fields << field

  define_singleton_method(key) { field.read }

  unless readonly
    define_singleton_method("#{key}=") { |value| field.save!(value: value) }

    if validates
      validates[:if] = proc { |item| item.var.to_s == key }
      send(:validates, key, **validates)
      define_method(:read_attribute_for_validation) { |_key| self.value }
    end
  end

  if type == :boolean
    define_singleton_method("#{key}?") do
      send(key)
    end
  end

  # delegate instance get method to class for support:
  # setting = Setting.new
  # setting.admin_emails
  define_method(key) do
    self.class.public_send(key)
  end
end

コードリーディグしてわかったこと

  • field = ::RailsSettings::Fields::Base.generate(...)の定義は以下
    • typeの定義に応じて、RailsSettings::Fields::XXXインスタンスを作っている
    • rails-settings-cached-2.9.4/lib/rails-settings/fields/base.rb
class << self
  def generate(**args)
    fetch_field_class(args[:type]).new(**args)
  end

  private

  def fetch_field_class(type)
    field_class_name = type.to_s.split("_").map(&:capitalize).join("")
    begin
      const_get("::RailsSettings::Fields::#{field_class_name}")
    rescue StandardError
      ::RailsSettings::Fields::String
    end
  end
end
  • define_singleton_method(key) { field.read }の部分で、fieldに記載したものを読み出しメソッドにしている
  • define_singleton_method("#{key}=") { |value| field.save!(value: value) }の部分で、更新用のメソッドを定義している
    • =field.save!が呼ばれる仕組み
  • ちなみに、真偽値の場合は以下で?メソッドも提供するようにしているのもわかる

2. 設定値をDB→デフォルト値という順番で読み出す箇所のコード

前述の通り、field.readを見れば良い

対応するコード

  • rails-settings-cached-2.9.4/lib/rails-settings/fields/base.rb
def read
  return deserialize(default_value) if readonly || saved_value.nil?

  deserialize(saved_value)
end

def deserialize(value)
  raise NotImplementedError
end

def saved_value
  return parent.send(:_all_settings)[key] if table_exists?

  # Fallback to default value if table was not ready (before migrate)
  puts(
    "WARNING: table: \"#{parent.table_name}\" does not exist or not database connection, `#{parent.name}.#{key}` fallback to returns the default value."
  )
  nil
end

コードリーディグしてわかったこと

  • DBに値が保存されている場合(saved_valueが存在する場合)はその値を引数にして、そうでない場合はデフォルト値を引数にしてdeserializeを呼んでいる
  • deserializeは、fieldの各クラスに実装されている
    • 例えば、RailsSettings::Fields::Hashでは、with_indifferent_accessでkeyに対して文字列でもシンボルでもアクセスできるようにされていることがわかる
    • rails-settings-cached-2.9.4/lib/rails-settings/fields/hash.rb
module RailsSettings
  module Fields
    class Hash < ::RailsSettings::Fields::Base
      def deserialize(value)
        return nil if value.nil?

        return value unless value.is_a?(::String)

        load_value(value).deep_stringify_keys.with_indifferent_access
      end
    end
  end
end
  • DB設定値(_all_settings)からkeyで探し、一致すればそれを返すことになっている

3. 値のキャッシュの仕方

  • saved_valueparent.send(:_all_settings)[key]の中身を見れば良い。
  • parentはユーザー定義のSettingクラス

対応するコード

  • rails-settings-cached-2.9.4/lib/rails-settings/base.rb
def _all_settings
  RequestCache.all_settings ||= cache_storage.fetch(cache_key, expires_in: 1.week) do
    vars = unscoped.select("var, value")
    result = {}
    vars.each { |record| result[record.var] = record.value }
    result.with_indifferent_access
  end
end

コードリーディングしてわかったこと

  • Railsのキャッシュの仕組みを使って、キャッシュされたものがなければすべての値をselectで取得し、そこから値を探している

4. 「=」でDB保存をしているコード

前述の通り、=field.save!が呼ばれる仕組み

def save!(value:)
  serialized_value = serialize(value)
  parent_record = parent.find_by(var: key) || parent.new(var: key)
  parent_record.value = serialized_value
  parent_record.save!
  parent_record.value
end
  • 値を読み込んで、すでに存在するか判断した上でsaveしているというシンプルな作り