Singleton パターン — インスタンスを1つだけに
「設定をどこで管理するの?」
入社5ヶ月目。ケンタはStripe(クレジットカード決済)とSendGrid(メール送信)の外部API連携を担当することになった。
まず頭を悩ませたのは設定管理だ。APIキーは本番・ステージング・開発で異なる。コードに直接書くのはセキュリティ上論外だ。でも、どこに置いてどう読み込めばいい?
「山田さん、Stripe APIのシークレットキーとか、SendGridのAPIキーって、どこで管理すればいいんですか?ファイルに書く?DBに入れる?」
「Rails.application.credentials を使えばいい」と山田さんは答えた。「でも、まずSingletonパターンを理解してほしい。Railsの設定管理はSingletonの典型例だから。パターンを知ると、なぜRailsがこういう設計になっているかが腑に落ちる。」
Singleton パターンとは
Singleton パターンは、クラスのインスタンスが1つだけであることを保証し、そのインスタンスへのグローバルなアクセスポイントを提供するパターンだ。
日常の比喩は国の憲法だ。日本国憲法は1つしかない。「新しい憲法を別に作る」ことはできない。日本中のどこからでも「日本国憲法」にアクセスできる(グローバルアクセスポイント)。そして、誰もが同じ憲法を参照している(同一インスタンス)。
もう一つの比喩:プリンターの管理ソフトだ。オフィスで10台のPCがあっても、プリンタードライバーのスプーラー(印刷キューを管理するプロセス)は通常1つだ。複数の印刷要求を1か所で管理することで、印刷の順番が保証される。
いつSingletonが必要か:
- アプリ全体で設定を共有したい(設定オブジェクト)
- データベース接続プールのように1つだけ存在すべきもの
- ログ出力先のように共有リソースへのアクセス
- キャッシュストアのような共有ストレージ
Rubyでのシンプルな実装
まずSingletonを自前で実装してみよう。理解のための練習だ。
# app/services/app_config.rb
class AppConfig
@instance = nil
@lock = Mutex.new
# new を private にして直接インスタンス化を防ぐ
# AppConfig.new を呼ぶとNoMethodError(privateメソッドのエラー)
private_class_method :new
def self.instance
# ダブルチェックロッキング — スレッドセーフかつ高速
return @instance if @instance
@lock.synchronize do
# ロック取得後にもう一度チェック
@instance ||= new
end
end
def initialize
# 初期化処理(アプリ起動時に1回だけ実行される)
@config = load_config
validate_required_keys!
end
def stripe_secret_key
@config[:stripe_secret_key]
end
def stripe_publishable_key
@config[:stripe_publishable_key]
end
def sendgrid_api_key
@config[:sendgrid_api_key]
end
def slack_webhook_url
@config[:slack_webhook_url]
end
private
REQUIRED_KEYS = %i[stripe_secret_key sendgrid_api_key].freeze
def load_config
{
stripe_secret_key: ENV.fetch("STRIPE_SECRET_KEY", nil),
stripe_publishable_key: ENV.fetch("STRIPE_PUBLISHABLE_KEY", nil),
sendgrid_api_key: ENV.fetch("SENDGRID_API_KEY", nil),
slack_webhook_url: ENV.fetch("SLACK_WEBHOOK_URL", nil),
}
end
def validate_required_keys!
missing = REQUIRED_KEYS.select { |key| @config[key].nil? }
if missing.any?
raise "必須設定が不足しています: #{missing.join(', ')}"
end
end
end# 使う側
key = AppConfig.instance.stripe_secret_key
# 何度呼んでも同じインスタンス(同一性の確認)
first = AppConfig.instance
second = AppConfig.instance
first.equal?(second) # => true(同じオブジェクトID)
# AppConfig.new は呼べない
AppConfig.new # => NoMethodError: private method 'new' calledWARNING
RubyでMutexを使わないとマルチスレッド環境で複数インスタンスが生成されることがある。Puma(スレッドモデルのWebサーバー)を使うRailsでは、複数スレッドが同時に @instance を確認し、どちらも nil だと判断して new を呼ぶ「競合状態(Race Condition)」が起きる可能性がある。Mutex はこの問題を防ぐ「ロック機構」だ。
Rubyの Singleton モジュール
わざわざ自前実装しなくても、Rubyには標準ライブラリに Singleton モジュールがある。これを使うのが実用的だ。
require "singleton"
# app/services/app_config.rb
class AppConfig
include Singleton # これだけで private_class_method :new と .instance が実装される
attr_reader :stripe_secret_key, :sendgrid_api_key, :slack_webhook_url
def initialize
@stripe_secret_key = Rails.application.credentials.dig(:stripe, :secret_key) ||
ENV.fetch("STRIPE_SECRET_KEY")
@sendgrid_api_key = Rails.application.credentials.dig(:sendgrid, :api_key) ||
ENV.fetch("SENDGRID_API_KEY")
@slack_webhook_url = ENV.fetch("SLACK_WEBHOOK_URL", nil)
end
# 設定値が有効かどうかを確認するヘルパー
def stripe_configured?
!@stripe_secret_key.nil? && @stripe_secret_key.start_with?("sk_")
end
def sendgrid_configured?
!@sendgrid_api_key.nil?
end
end
# 使う側(.instance で取得)
config = AppConfig.instance
Stripe.api_key = config.stripe_secret_keyinclude Singleton するだけで、以下が自動的に実装される:
private_class_method :new(直接インスタンス化の防止)instanceクラスメソッド(唯一のインスタンスへのアクセス)- スレッドセーフな初期化
Rails での Singleton の実例
Railsのコアにはシングルトン的なオブジェクトが多数ある。Railsを使っているだけで、毎日Singletonパターンに触れている。
Rails.application ——アプリケーション設定の司令塔
# config/application.rb
module MyApp
class Application < Rails::Application
# アプリ全体の設定(Singletonなので全コードから同じオブジェクトにアクセスできる)
config.time_zone = "Tokyo"
config.default_locale = :ja
config.active_job.queue_adapter = :sidekiq
config.active_storage.service = :amazon
end
end
# どのファイルからでも同じオブジェクトにアクセスできる
Rails.application.config.time_zone # => "Tokyo"
Rails.application.config.default_locale # => :ja
# Rails.applicationはRailsアプリ起動時に1度だけ生成される
Rails.application.equal?(Rails.application) # => trueRails.logger ——全ログの集約点
# どのファイルからでも同じloggerに書き込める
# ログファイルが複数に分散しないのは、loggerがSingletonだから
class OrderService
def complete(order)
Rails.logger.info "[OrderService] 注文完了処理開始: order_id=#{order.id}"
# ...
Rails.logger.info "[OrderService] 注文完了処理完了: order_id=#{order.id}"
rescue => e
Rails.logger.error "[OrderService] エラー発生: #{e.message}"
raise
end
endログの書き込み先は1か所にまとめる必要がある。Rails.logger がそのSingletonだ。もし Rails.logger がSingletonでなければ、複数のloggerインスタンスが存在し、ログが断片化してしまう。
Rails.cache ——キャッシュストアへの単一窓口
# config/environments/production.rb
config.cache_store = :redis_cache_store, {
url: ENV["REDIS_URL"],
pool_size: 10,
pool_timeout: 5
}
# 使う側(どこからでも同じキャッシュストアにアクセス)
class ProductsController < ApplicationController
def index
@products = Rails.cache.fetch("products/all", expires_in: 30.minutes) do
Product.active.includes(:category).order(:name).to_a
end
end
end
class Product < ApplicationRecord
after_commit :clear_cache
private
def clear_cache
# 同じRails.cacheに書き込まれたキャッシュを削除
Rails.cache.delete("products/all")
end
endRedisへのTCP接続は高コストだ。Rails.cache がSingletonで接続プールを管理することで、各リクエストで新しい接続を張らずに済む。
Rails.application.credentials ——機密情報の安全な管理
# 認証情報の読み込み(Rails標準のSingleton)
stripe_key = Rails.application.credentials.stripe[:secret_key]
sendgrid_key = Rails.application.credentials.sendgrid[:api_key]
# 暗号化されたcredentials.yml.encから読み込む
# 実際のコードを書くとき
class ApplicationController < ActionController::Base
before_action :configure_external_services
private
def configure_external_services
# Singletonとして管理されているので、リクエストごとに初期化されない
# 起動時に1度だけロードされる
end
endINFO
Rails.application.credentials は暗号化されたファイル(credentials.yml.enc)から設定を読み込む。鍵ファイル(master.key)はGit管理外に置き、本番環境ではENV変数で渡す。この仕組みで、GitHubに機密情報を公開せずにチームで設定を共有できる。
Singletonの問題点とアンチパターン
Singletonはしばしば「アンチパターン」として批判される。なぜか?
# テストが難しい例
class OrderService
def complete(order)
# AppConfig.instanceはどこからでも同じオブジェクト——テストで値を変えにくい
fee_rate = AppConfig.instance.late_fee_rate
late_fee = order.overdue_days > 0 ? (order.total_price * fee_rate).ceil : 0
order.update!(late_fee: late_fee)
end
end
# RSpecでのテスト — AppConfig.instanceの値に依存してしまう
RSpec.describe OrderService do
it "遅延料金を設定する" do
order = create(:order, total_price: 10_000)
# AppConfig.instanceの late_fee_rate が 0.05 だと決め打ちになってしまう
# テスト環境で値を変えるには?
# → AppConfig.instance.late_fee_rate を直接変更すると他のテストに影響する
service = OrderService.new
service.complete(order)
# この期待値はAppConfig.instanceの値に依存している——脆いテスト
expect(order.reload.late_fee).to eq(500)
end
endSingletonの問題点:
1. テストの難しさ:グローバル状態はテスト間で影響を与え合う。AppConfig.instance の値を1つのテストで変えると、他のテストも影響を受ける。テストの独立性が損なわれる。
2. 依存関係が隠れる:関数の引数を見ただけでは、その関数が AppConfig.instance を使っているとわからない。「隠れた依存」はコードを追跡しにくくする。
3. 並行処理の問題:Singletonが状態を持つ場合(値の書き換えができる場合)、複数スレッドから同時にアクセスすると競合が起きる可能性がある。
より良い代替案:依存性注入(DI)
# 改善版: configを外から注入する
class OrderService
def initialize(config: Rails.application.config)
@config = config
end
def complete(order)
fee_rate = @config.late_fee_rate
late_fee = order.overdue_days > 0 ? (order.total_price * fee_rate).ceil : 0
order.update!(late_fee: late_fee)
end
end
# テストではモックを渡せる
RSpec.describe OrderService do
it "遅延料金を5%で計算する" do
mock_config = double("config", late_fee_rate: 0.05)
service = OrderService.new(config: mock_config)
order = create(:order, total_price: 10_000, overdue_days: 3)
service.complete(order)
expect(order.reload.late_fee).to eq(500)
end
it "遅延がない場合は料金なし" do
mock_config = double("config", late_fee_rate: 0.05)
service = OrderService.new(config: mock_config)
order = create(:order, total_price: 10_000, overdue_days: 0)
service.complete(order)
expect(order.reload.late_fee).to eq(0)
end
endDIを使うことで、テストでは任意の設定オブジェクトを渡せる。本番では Rails.application.config がデフォルトとして使われる。依存関係が明示的になり、テストが書きやすくなる。
実用的なパターン: ActiveSupport::Configurable
Railsアプリで設定管理にSingletonを使う最もRails的な方法:
# app/services/payment_config.rb
class PaymentConfig
include ActiveSupport::Configurable
# デフォルト値付きの設定アクセサ
config_accessor :stripe_secret_key
config_accessor :stripe_publishable_key
config_accessor :late_fee_rate do 0.05 end # デフォルト5%
config_accessor :max_retry_count do 3 end # デフォルト3回
config_accessor :payment_timeout do 30 end # デフォルト30秒
end# config/initializers/payment_config.rb
PaymentConfig.configure do |config|
config.stripe_secret_key = Rails.application.credentials.dig(:stripe, :secret_key)
config.stripe_publishable_key = Rails.application.credentials.dig(:stripe, :publishable_key)
# 本番環境のみ設定を上書き
if Rails.env.production?
config.late_fee_rate = 0.08 # 本番は8%
end
end# 使う側
PaymentConfig.stripe_secret_key # => "sk_live_..."
PaymentConfig.late_fee_rate # => 0.05(開発)or 0.08(本番)
PaymentConfig.max_retry_count # => 3
# テストでの上書きも可能
RSpec.describe PaymentService do
before do
PaymentConfig.configure do |c|
c.late_fee_rate = 0.10 # テスト用に10%に変更
end
end
after do
PaymentConfig.configure do |c|
c.late_fee_rate = 0.05 # テスト後に元に戻す
end
end
endActiveSupport::Configurable を使うことで、Railsらしいブロック形式の設定APIが実現できる。
AWSでのSingleton的発想
AWSにも「1つしか存在しない」リソースがあり、Singletonの制約を反映している。
Internet Gateway はVPCに1つしかアタッチできない。これはSingleton制約だ。2つ目をアタッチしようとするとエラーになる。「1つのVPCには1つのインターネット接続口」というアーキテクチャ上の決定がSingletonとして表現されている。
AWS Systems Manager Parameter Store と Secrets Manager はアプリ設定のSingleton的な管理場所だ。
# app/services/aws_parameter_store_config.rb
require "singleton"
class AwsParameterStoreConfig
include Singleton
def initialize
@ssm = Aws::SSM::Client.new(region: ENV.fetch("AWS_REGION", "ap-northeast-1"))
@cache = {}
@cache_ttl = 5.minutes
@cached_at = {}
end
def get(key)
if cache_expired?(key)
@cache[key] = fetch_from_ssm(key)
@cached_at[key] = Time.current
end
@cache[key]
end
def stripe_secret_key
get("/#{app_env}/stripe/secret_key")
end
def sendgrid_api_key
get("/#{app_env}/sendgrid/api_key")
end
private
def fetch_from_ssm(key)
@ssm.get_parameter(name: key, with_decryption: true).parameter.value
rescue Aws::SSM::Errors::ParameterNotFound
raise "Parameter not found: #{key}"
end
def cache_expired?(key)
@cached_at[key].nil? || Time.current - @cached_at[key] > @cache_ttl
end
def app_env
ENV.fetch("APP_ENV", "development")
end
end
# 使う側(1つのSSMクライアントと接続を使い回す)
config = AwsParameterStoreConfig.instance
Stripe.api_key = config.stripe_secret_keySSMへのAPI呼び出しは有料(1000リクエストあたり$0.05)だ。Singletonでキャッシュを管理することで、同じキーへの重複呼び出しを抑制できる。
ECS のタスク定義 も同様の考え方だ。同じタスク定義から複数のECSタスクが生成されるが、タスク定義自体は1つ(特定のリビジョン)だ。
Singletonを使うべき場面、避けるべき場面
山田さんはホワイトボードに表を書いた。
| 使うべき場面 | 避けるべき場面 |
|---|---|
| アプリ設定(変更されない値) | ビジネスロジック |
| ログ出力先 | ユーザー固有の状態 |
| DB接続プール | テストで値を変えたいもの |
| 外部API接続クライアント | リクエストごとに変わるもの |
| キャッシュストア | 並行処理で書き換えが発生するもの |
# 良い例:接続クライアントのSingleton
# 接続の確立は高コスト。1つのクライアントを使い回す。
class StripeClientSingleton
include Singleton
def initialize
Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key)
@client = Stripe::StripeClient.new
end
def client
@client
end
end
# 避けるべき例:ユーザー設定のSingleton
# ユーザーごとに設定が違うのに、Singletonにしたら全ユーザーが同じ設定を共有してしまう
class UserConfigSingleton # ← これはダメ!
include Singleton
attr_accessor :locale, :theme
def initialize
@locale = :ja
@theme = "dark"
end
end
# UserConfigSingleton.instance.locale = :en にすると全ユーザーが英語になる!ケンタの気づき
「Singletonパターンって使いどころを選ぶんですね。設定やロガーには使うべきだけど、ビジネスロジックには避けるべき。」
「そうだ」と山田さんは言った。「Singletonが正当化されるのは、本当に唯一性が必要なリソースだけだ。設定、ロガー、接続プール。でもビジネスロジックでSingletonを使い始めたら、テストが地獄になる。」
「依存性注入のほうが良いと?」
「多くの場合はね。でもRailsのRails.loggerやRails.cacheはSingletonで正解だ。1プロセスに1つのログ出力先、1つのキャッシュストアがあれば十分だし、逆に複数あったら混乱する。一番大事なのは意図して選択すること。パターンを盲目的に適用しないことだ。」
「『このクラスのインスタンスは本当に1つでなければならないか?』と自問することですね。」
「まさに。その問いに明確に『はい』と答えられるときだけSingletonを使う。迷ったら依存性注入を選べ。」
ケンタはノートにメモした。
Singletonパターン = インスタンスを1つに制限する。設定・ロガー・接続プールには有効。ビジネスロジックには避け、テスト可能性を優先する。「本当に1つでなければならないか?」を自問してから使う。
INFO
この章のまとめ
- Singletonはインスタンスが1つだけであることを保証する
- Rubyの
Singletonモジュールでinclude Singletonするだけで安全に実装できる Rails.application、Rails.logger、Rails.cacheはSingletonの実例——Railsを使うと必ずSingletonに触れているActiveSupport::ConfigurableでRailsらしい設定管理を実現できる- グローバル状態はテストを困難にするため、ビジネスロジックでの乱用は避ける
- ビジネスロジックには依存性注入(DI)のほうが柔軟で安全——コンストラクタで設定を渡す
- AWSではInternet Gateway(VPCに1つ)やParameter Store(アプリ設定の一元管理)に同じ考え方がある
- 判断基準:「本当に1つでなければならない理由があるか?」を自問してから使う