本記事は筆者の体験をもとに生成 AI を活用して作成しています
失敗談
Rails アプリケーションにおいて機能実装をしていたときのことです。
以下は例ですが、Settings を使った定数取得の処理を追加しました。
API_KEY = Settings.external.service.api_key
今回、設定ファイル(config/settings/development.yml)に新たに追加したのは .service.api_key の部分でした。
開発環境ではうまく動作していたので、そのままコミット。ところが CI でのテストでは失敗...
NoMethodError: undefined method `api_key' for nil
原因は単純でした。test環境の設定ファイル(config/settings/test.yml)には .service.api_key を追加していなかったのです。
この経験から、ネストされた設定値へのアクセスでは「存在しないかもしれない」という前提を持つことの重要性を学びました。
(補足) Settings とは
本記事で扱うSettingsは、Rails アプリケーションで YAML 設定ファイルを簡単に扱うための Gem(configやrails-settings-cachedなど)を指します。
以下の YAML ファイルの場合、よくある方法として Settings.external.service.api_key のようにドット記法(メソッドチェーン)で値にアクセスできる便利な仕組みです。
# config/settings.yml
external:
service:
api_key: "your_api_key_here"
クイズで学ぶ:各パターンの挙動
Settings で定義された変数値のようにネストされた値へのアクセス方法や、それらの挙動についてクイズ形式で学んでいきましょう。
🕵🏻♂️ Quiz 1: メソッドチェーンは安全?
次のコードはどうなるでしょう?
前提:
# config/settings.yml
external:
service:
api_key: "your_api_key_here"
# timeout: 30 # これが定義されていない場合
コード:
Settings.external.service.timeout
💡 正解を見る
答え: nilが返されます
解説: config gem は存在しないキーに対しても安全にnilを返します。メソッドチェーンの最後のキーが存在しない場合、エラーにはなりません。
Settings.external.service.timeout
# => nil (NoMethodErrorは発生しない)
🕵🏻♂️ Quiz 2: エラーが出ないのはどれ?
次の 6 つのコードを実行した場合、エラーが発生しないものはどれでしょう?
前提:
# config/settings.yml
external:
service:
api_key: "your_api_key_here"
timeout: 30
# internal: # これが定義されていない場合
コード:
# A. メソッドチェーン
Settings.internal.service.api_key
# B. Safe Navigation Operator
Settings.internal&.service&.api_key
# C. []チェーン
Settings[:internal][:service][:api_key]
# D. tryチェーン
Settings.try(:internal).try(:service).try(:api_key)
# E. dig
Settings.dig(:internal, :service, :api_key)
# F. fetch
Settings.fetch(:internal, :service, :api_key)
💡 正解を見る
答え: B. Safe Navigation、D. try チェーン、E. dig がエラーを発生させません
解説: 今回の場合、メソッドチェーンはNoMethodErrorを発生させます。Quiz 1 では末端キーの未定義でnilが返されましたが、今回は途中の階層(internal)自体が存在しないため、この違いが生まれます。
dig と fetch は使い方は似ていますが、エラー処理のアプローチが根本的に異なります。
# A. メソッドチェーン
Settings.internal.service.api_key
# => NoMethodError: undefined method `service' for nil:NilClass ❌
# B. Safe Navigation Operator
Settings.internal&.service&.api_key
# => nil ✅
# C. []チェーン
Settings[:internal][:service][:api_key]
# => NoMethodError: undefined method `[]' for nil:NilClass ❌
# D. tryチェーン
Settings.try(:internal).try(:service).try(:api_key)
# => nil ✅
# E. dig
Settings.dig(:internal, :service, :api_key)
# => nil ✅
# F. fetch
Settings.fetch(:internal, :service, :api_key)
# => KeyError: key not found: :internal ❌
🕵🏻♂️ Quiz 3: 空文字列の扱い
次のコードの返り値は何でしょう?
前提:
# config/settings.yml
external:
service:
api_key: "your_api_key_here"
timeout: 30
description: "" # 空文字列
コード:
Settings.dig(:external, :service, :description)
💡 正解を見る
答え: ""(空文字列)
解説: digは値をそのまま返すため、空文字列の場合は""が返ります。空文字列も有効な値として扱われます。
また、Quiz 2 で登場した他のパターンでの空文字列が返る結果も確認してみましょう。
# 前提: Settings.external.service.description = ""
# A. メソッドチェーン
Settings.external.service.description
# => "" (空文字列をそのまま返す)
# B. Safe Navigation Operator
Settings.external&.service&.description
# => "" (空文字列をそのまま返す)
# C. []チェーン
Settings[:external][:service][:description]
# => "" (空文字列をそのまま返す)
# D. tryチェーン
Settings.try(:external).try(:service).try(:description)
# => "" (空文字列をそのまま返す)
# E. dig
Settings.dig(:external, :service, :description)
# => "" (空文字列をそのまま返す)
# F. fetch
Settings.fetch(:external, :service, :description)
# => "" (空文字列をそのまま返す)
🕵🏻♂️ Quiz 4: ||と.presence ||の違い
次の 2 つのコードの違いは何でしょう?
前提:
# config/settings.yml
external:
service:
api_key: "your_api_key_here"
timeout: 30
description: "" # 空文字列
コード:
# パターンA
Settings.dig(:external, :service, :description) || 'default_key'
# パターンB
Settings.dig(:external, :service, :description).presence || 'default_key'
それぞれ何を返すでしょう?
💡 正解を見る
答え:
- パターン A:
""(空文字列) - パターン B:
'default_key'
解説: ||演算子はnilとfalseのみを falsy 値として扱うため、空文字列はそのまま返ります。一方、.presenceは空文字列や空白文字列をnilに変換するため、デフォルト値が使われます。
パフォーマンス比較
👥 比較対象
前述のクイズで登場した 6 つのアクセスパターンの速度を比較してみました。
| 方法 | 記法例 |
|---|---|
| A. Method chain | Settings.external.service.api_key |
| B. Safe Navigation Operator | Settings.external&.service&.api_key |
| C. [] チェーン | Settings[:external][:service][:api_key] |
| D. try チェーン | Settings.try(:external).try(:service).try(:api_key) |
| E. dig | Settings.dig(:external, :service, :api_key) |
| F. fetch | Settings.fetch(:external, :service, :api_key) |
📃 ベンチマークスクリプト
📊 ベンチマークスクリプト(クリックして展開)
docker run --rm ruby:3.4.1 bash -c "
gem install benchmark-ips activesupport --no-document > /dev/null 2>&1
ruby << 'EOF'
require 'benchmark/ips'
require 'ostruct'
require 'active_support/core_ext/object/try'
# Hashでのテスト
data = {
external: {
service: {
timeout: 30
}
}
}
# OpenStructでのテスト(メソッドチェーン用)
def create_nested_struct(hash)
if hash.is_a?(Hash)
struct = OpenStruct.new
hash.each do |key, value|
struct[key] = create_nested_struct(value)
end
struct
else
hash
end
end
settings_struct = create_nested_struct(data)
puts \"Ruby #{RUBY_VERSION} + Hash/OpenStruct での実測値(Docker環境):\"
puts
# 存在するキーでのベンチマーク
puts \"🔍 存在するキーでのベンチマーク結果\"
Benchmark.ips do |x|
x.config(time: 3, warmup: 1)
x.report('A. method chain (exists): ') { settings_struct.external.service.timeout }
x.report('B. safe navigation (exists):') { settings_struct&.external&.service&.timeout }
x.report('C. [] chain (exists): ') { data[:external][:service][:timeout] }
x.report('D. try chain (exists): ') { settings_struct.try(:external).try(:service).try(:timeout) }
x.report('E. dig (exists): ') { data.dig(:external, :service, :timeout) }
x.report('F. fetch (exists): ') { data.fetch(:external).fetch(:service).fetch(:timeout) }
x.compare!
end
puts
puts \"🔍 存在しないキーでのベンチマーク結果\"
Benchmark.ips do |x|
x.config(time: 3, warmup: 1)
x.report('B. safe navigation (missing):') { settings_struct&.nonexistent&.missing_key }
x.report('C. [] chain (missing): ') { data[:nonexistent][:missing_key] rescue nil }
x.report('D. try chain (missing): ') { settings_struct.try(:nonexistent).try(:missing_key) }
x.report('E. dig (missing): ') { data.dig(:nonexistent, :missing_key) }
x.compare!
end
puts
puts \"⚠️ エラーが発生するパターン\"
puts \"A. method chain (missing): NoMethodError発生\"
puts \"F. fetch (missing): KeyError発生\"
EOF
"
📑 ベンチマーク実行結果
📊 ベンチマーク実行結果(クリックして展開)
Ruby 3.4.1 + Hash/OpenStruct での実測値(Docker環境):
🔍 存在するキーでのベンチマーク結果
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [aarch64-linux]
Warming up --------------------------------------
A. method chain (exists):
520.387k i/100ms
B. safe navigation (exists):
664.181k i/100ms
C. [] chain (exists):
1.442M i/100ms
D. try chain (exists):
141.067k i/100ms
E. dig (exists):
1.178M i/100ms
F. fetch (exists):
1.020M i/100ms
Calculating -------------------------------------
A. method chain (exists):
7.488M (± 1.3%) i/s (133.55 ns/i) - 22.897M in 3.058495s
B. safe navigation (exists):
6.718M (± 1.0%) i/s (148.84 ns/i) - 20.590M in 3.064937s
C. [] chain (exists):
14.665M (± 1.1%) i/s (68.19 ns/i) - 44.694M in 3.048061s
D. try chain (exists):
1.369M (± 9.9%) i/s (730.48 ns/i) - 4.091M in 3.043173s
E. dig (exists):
11.862M (± 1.0%) i/s (84.30 ns/i) - 36.519M in 3.078955s
F. fetch (exists):
10.276M (± 0.8%) i/s (97.32 ns/i) - 31.617M in 3.077042s
Comparison:
C. [] chain (exists): : 14664885.9 i/s
E. dig (exists): : 11861907.2 i/s - 1.24x slower
F. fetch (exists): : 10275628.1 i/s - 1.43x slower
A. method chain (exists): : 7487593.1 i/s - 1.96x slower
B. safe navigation (exists):: 6718468.0 i/s - 2.18x slower
D. try chain (exists): : 1368963.5 i/s - 10.71x slower
🔍 存在しないキーでのベンチマーク結果
ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +PRISM [aarch64-linux]
Warming up --------------------------------------
B. safe navigation (missing):
216.335k i/100ms
C. [] chain (missing):
108.695k i/100ms
D. try chain (missing):
418.918k i/100ms
E. dig (missing):
1.493M i/100ms
Calculating -------------------------------------
B. safe navigation (missing):
2.953M (± 2.9%) i/s (338.66 ns/i) - 8.870M in 3.006661s
C. [] chain (missing):
1.112M (± 2.1%) i/s (898.94 ns/i) - 3.370M in 3.030419s
D. try chain (missing):
4.119M (± 2.0%) i/s (242.76 ns/i) - 12.568M in 3.052107s
E. dig (missing):
14.862M (± 1.3%) i/s (67.29 ns/i) - 44.792M in 3.014377s
Comparison:
E. dig (missing): : 14862041.8 i/s
D. try chain (missing): : 4119317.5 i/s - 3.61x slower
B. safe navigation (missing):: 2952780.8 i/s - 5.03x slower
C. [] chain (missing): : 1112422.2 i/s - 13.36x slower
⚠️ エラーが発生するパターン
A. method chain (missing): NoMethodError発生
F. fetch (missing): KeyError発生
📊 実測結果サマリ
存在するキーでの性能ランキング
-
1 位: C.
[] chain- 14.67M i/s(最速) -
2 位: E.
dig- 11.86M i/s(1.24 倍遅い) -
3 位: F.
fetch- 10.28M i/s(1.43 倍遅い) -
4 位: A.
method chain- 7.49M i/s(1.96 倍遅い) -
5 位: B.
safe navigation- 6.72M i/s(2.18 倍遅い) -
6 位: D.
try chain- 1.37M i/s(10.71 倍遅い)
存在しないキーでの性能ランキング
-
1 位: E.
dig- 14.86M i/s(最速) -
2 位: D.
try chain- 4.12M i/s(3.61 倍遅い) -
3 位: B.
safe navigation- 2.95M i/s(5.03 倍遅い) -
4 位: C.
[] chain- 1.11M i/s(13.36 倍遅い)
重要なポイント
- Ruby 3.4.1 では
[] chainが存在するキーで最速 digは存在しないキーで圧倒的に高速try chainは常に低速(特に存在するキーで 10 倍以上遅い)method chainとfetchは存在しないキーでエラー発生
ユースケース別の推奨方法
ケース 1: シンプルなデフォルト値設定
Settings.dig(:database, :timeout) || 30
使うべき場面:
- 設定値が存在しない場合のみデフォルト値を使いたい
- 空文字列も有効な値として扱いたい
||演算子の動作:
||演算子はnilとfalseのみを falsy 値として扱います。
nil || 'DEFAULT' # => 'DEFAULT'
false || 'DEFAULT' # => 'DEFAULT'
"" || 'DEFAULT' # => "" (空文字列はそのまま)
" " || 'DEFAULT' # => " " (空白もそのまま)
"VALUE" || 'DEFAULT' # => "VALUE"
ケース 2: 空文字列も無効値として扱う
Settings.dig(:external, :service, :api_key).presence || 'default_key'
使うべき場面:
- ユーザー入力や外部設定ファイルの値を扱う
- 空文字列や空白文字列を無効な値として扱いたい
- より厳密なバリデーションが必要
.presence ||の動作:
nil.presence || 'DEFAULT' # => 'DEFAULT'
false.presence || 'DEFAULT' # => 'DEFAULT'
"".presence || 'DEFAULT' # => 'DEFAULT' (空文字列も除外)
" ".presence || 'DEFAULT' # => 'DEFAULT' (空白も除外)
"VALUE".presence || 'DEFAULT' # => "VALUE"
ケース 3: 環境ごとに異なるデフォルト値
Settings.dig(:external, :service, :endpoint).presence ||
case Rails.env
when 'production' then 'https://api.example.com'
when 'staging' then 'https://staging-api.example.com'
else 'http://localhost:3000'
end
使うべき場面:
- 本番、ステージング、開発で異なる設定が必要
- 環境依存の値を管理したい
まとめ
本記事では、Ruby でネストした設定値に安全にアクセスする 6 つの方法を、クイズ形式での学習からパフォーマンス測定まで包括的に検証しました。
実測結果から見えた真実
Ruby 3.4.1 + Hash/OpenStruct での実測により、以下のことが明らかになりました:
存在するキーの場合
-
[] chainが最速(14.67M i/s)だが、存在しないキーでエラーリスク -
digが 2 位(11.86M i/s)で安全性とのバランスが優秀 -
try chainが最遅(1.37M i/s)で 10 倍以上の性能差
存在しないキーの場合
-
digが圧倒的に最速(14.86M i/s) -
[] chainが最遅(1.11M i/s)で 13 倍以上の性能差 -
エラー発生パターン(
method chain、fetch)は論外
結論:状況に応じた適切な選択を
基本方針:dig + .presence
# ✅ 推奨パターン(基本)
api_key = Settings.dig(:external, :service, :api_key)
# ✅ 空文字列も無効にしたい場合
api_key = Settings.dig(:external, :service, :api_key).presence || 'default_key'
なぜdigなのか?
| 観点 | 評価 | 理由 |
|---|---|---|
| 安全性 | ⭐⭐⭐⭐⭐ | 途中のキーが存在しなくてもnilを返す(エラーにならない) |
| パフォーマンス | ⭐⭐⭐⭐⭐ | 存在しないキーで最速、存在するキーでも 2 位の高速性 |
| 読みやすさ | ⭐⭐⭐⭐⭐ | 意図が明確で、誰が見ても理解しやすい |
| 汎用性 | ⭐⭐⭐⭐⭐ | ハッシュ、配列、複雑なデータ構造すべてに対応 |
避けるべきパターン
| パターン | 問題点 | 実測結果 |
|---|---|---|
tryチェーンSettings.try(:external).try(:service)...
|
常に低速(特に存在するキーで 10 倍遅い) | 1.37M i/s / 4.12M i/s |
Safe NavigationSettings&.external&.service&.api_key
|
存在するキーで 2 倍遅い | 6.72M i/s / 2.95M i/s |
[]チェーンSettings[:external][:service][:api_key]
|
存在しないキーで 13 倍遅い、エラーリスク | 14.67M i/s / 1.11M i/s |
重要な例外:メソッドチェーンの戦略的活用
とはいえ、メソッドチェーンにも利点はあります。NoMethodErrorが発生することで、設定値が未定義であることを確実に検知できます。特に本番環境で必須の設定値の場合、起動時にエラーを出して早期に問題を発見できるのは有益です。状況に応じて、意図的にメソッドチェーンを選択する判断も有効でしょう。
# 必須設定値の場合:意図的にエラーを発生させる
API_KEY = Settings.external.service.api_key # 未定義なら即座にエラー
# オプショナル設定値の場合:安全にアクセス
timeout = Settings.dig(:external, :service, :timeout) || 30
開発環境では動いていても、test 環境や本番環境でNoMethodErrorが発生すると大きな問題になります。設定値の性質(必須 vs オプショナル)とチームの方針を考慮し、適切な手法を選択することが最も重要です。
Tips: さらに深掘り
💡 Tip 1: dig の汎用性の高さ
digメソッドは非常に汎用性が高く、様々なデータ構造で安全にアクセスできます。
ハッシュでの使用
Ruby のHashでもdigは使えます。
config = { external: { service: { api_key: 'secret' } } }
# dig を使う
config.dig(:external, :service, :api_key)
# => "secret"
# 存在しないキー
config.dig(:external, :database, :host)
# => nil
配列とハッシュが混在する場合
digは配列インデックスも扱えるため、複雑なデータ構造でも安全にアクセスできます。
data = {
servers: [
{ name: 'server1', ip: '192.168.1.1' },
{ name: 'server2', ip: '192.168.1.2' }
]
}
# 配列の最初の要素のnameを取得
data.dig(:servers, 0, :name)
# => "server1"
# 存在しないインデックス
data.dig(:servers, 5, :name)
# => nil
# ネストした配列とハッシュの組み合わせ
complex_data = {
environments: [
{
name: 'production',
databases: [
{ host: 'prod-db1.example.com', port: 5432 },
{ host: 'prod-db2.example.com', port: 5432 }
]
}
]
}
# 深くネストしたデータも安全にアクセス
complex_data.dig(:environments, 0, :databases, 1, :host)
# => "prod-db2.example.com"
# 存在しないパスでもエラーにならない
complex_data.dig(:environments, 5, :databases, 0, :host)
# => nil
このようにdigは、ハッシュ、配列、そしてそれらが混在する複雑なデータ構造でも一貫した方法で安全にアクセスできる優れたメソッドです。
💡 Tip 2: .presenceの内部実装
参考までに、.presenceメソッドの実装を理解しておくと便利です。
# ActiveSupport::CoreExtensions::Object::Blank
def presence
self if present?
end
def present?
!blank?
end
def blank?
respond_to?(:empty?) ? !!empty? : !self
end
blank?の判定基準:
| 値 | blank? | 説明 |
|---|---|---|
nil |
true |
nil は常に blank |
false |
true |
false も blank 扱い |
"" |
true |
空文字列 |
" " |
true |
空白のみの文字列 |
[] |
true |
空の配列 |
{} |
true |
空のハッシュ |
0 |
false |
⚠️ 数値は blank ではない |
"0" |
false |
⚠️ 文字列の"0"も blank ではない |
この実装を知ることで、.presenceがどのような値をnilに変換するのかが明確になります。
さいごに
本記事ではネストされた設定値へのアクセス方法の紹介とそれらのパフォーマンス比較を行いました。
「どの方法が絶対的に正しい」ということではありません。重要なのは、設定値の性質やチームの開発方針を踏まえ、適切な実装手段を選択することだと思います。
- 必須の設定値であれば、
fetchやメソッドチェーンで意図的にエラーを発生させる - オプショナルな値であれば、
digや.presenceを使用して安全にアクセスする - チームの既存のコーディング規約やパターンを尊重する
状況に応じた適切な選択ができるよう、この記事が誰かの参考になれば幸いです。
最後までご覧いただき、ありがとうございました 🙏✨