mybook

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か所で管理することで、印刷の順番が保証される。

Loading diagram...

いつ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' called

WARNING

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_key

include 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)  # => true

Rails.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
end

Redisへの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
end

INFO

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
end

Singletonの問題点:

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
end

DIを使うことで、テストでは任意の設定オブジェクトを渡せる。本番では 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
end

ActiveSupport::Configurable を使うことで、Railsらしいブロック形式の設定APIが実現できる。

AWSでのSingleton的発想

AWSにも「1つしか存在しない」リソースがあり、Singletonの制約を反映している。

Loading diagram...

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_key

SSMへの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.loggerRails.cacheはSingletonで正解だ。1プロセスに1つのログ出力先、1つのキャッシュストアがあれば十分だし、逆に複数あったら混乱する。一番大事なのは意図して選択すること。パターンを盲目的に適用しないことだ。」

「『このクラスのインスタンスは本当に1つでなければならないか?』と自問することですね。」

「まさに。その問いに明確に『はい』と答えられるときだけSingletonを使う。迷ったら依存性注入を選べ。」

ケンタはノートにメモした。

Singletonパターン = インスタンスを1つに制限する。設定・ロガー・接続プールには有効。ビジネスロジックには避け、テスト可能性を優先する。「本当に1つでなければならないか?」を自問してから使う。


INFO

この章のまとめ

  • Singletonはインスタンスが1つだけであることを保証する
  • RubyのSingletonモジュールで include Singleton するだけで安全に実装できる
  • Rails.applicationRails.loggerRails.cache はSingletonの実例——Railsを使うと必ずSingletonに触れている
  • ActiveSupport::Configurable でRailsらしい設定管理を実現できる
  • グローバル状態はテストを困難にするため、ビジネスロジックでの乱用は避ける
  • ビジネスロジックには依存性注入(DI)のほうが柔軟で安全——コンストラクタで設定を渡す
  • AWSではInternet Gateway(VPCに1つ)やParameter Store(アプリ設定の一元管理)に同じ考え方がある
  • 判断基準:「本当に1つでなければならない理由があるか?」を自問してから使う