RubyのGem::Config.settingというコンベンションの実現方法

スタティックなファイルで構成されるWebサイトを構築する「Stasis」というRubyライブラリを少し読んでみた。lib/stasis/gems.rbの冒頭に、次のようなコードがある。

unless defined?(Stasis::Gems)

  require 'yaml'

  class Stasis
    module Gems
      class <<self

        attr_accessor :config
        attr_reader :gemset, :gemsets, :versions

        class SimpleStruct
          attr_reader :hash

          def initialize(hash)
            @hash = hash
            @hash.each do |key, value|
              self.class.send(:define_method, key) { @hash[key] }
              self.class.send(:define_method, "#{key}=") { |v| @hash[key] = v }
            end
          end
        end

        Gems.config = SimpleStruct.new(
          :gemsets => [ "#{File.expand_path('../../../', __FILE__)}/config/gemsets.yml" ],
          :gemspec => "#{File.expand_path('../../../', __FILE__)}/config/gemspec.yml",
          :warn => true
        )

        def activate(*gems)
          begin
            require 'rubygems' unless defined?(::Gem)
          rescue LoadError            puts "rubygems library could not be required" if @config.warn
          end

1行目からして意図が分からない。間違って2重に呼び出したときの予期せぬ動作の防止だろうけど、あんまり見かけないコードという気がする。2重呼び出しなんてミス、紛れ込むのかな。

次に、

  class Stasis
    module Gems
      class <<self
        class SimpleStruct

のようにclassがGems->SimpleStructとネストしてる理由も分からない。少し検索して、あれこれ読んだ結果、これは単にユーティリティ的にclassを定義したかっただけのようだと理解した。SimpleStructは上位のGemsの名前空間に閉じ込められているというだけで、そのほかの挙動は特に変わったことはない。

一番外側がmoduleでなくて、classになっている理由がよく分からない。と思ったけど、bin/stasisを見たら、ふつうにStasis.newとインスタンス化してた。なるほど。Rubyではmoduleもclassもインスタンス化できるかどうかというのが最大の違いというぐらいで同じようなものという理解だけど、何となく一番外側は名前空間としてmoduleを使う例が多いような気がしている。

3つ目のclass << selfは、Gemsモジュールにクラスメソッドを定義するためのもの。Gems.configのように、アトリビュートを持たせていて、そういうことをやるためには、moduleではダメ。

module Abc
  attr_accessor :apple
end

Abc.apple = 5
p Abc.apple

は「NoMethodError: undefined method `apple=' for Abc:Module」となる。class宣言にAbcモジュールをselfとして食わせてやれば、これは動く。

module Abc
  class << self
    attr_accessor :apple
  end
end

Abc.apple = 5
p Abc.apple => 5

で、クラスのネストで定義しているSimpleStructクラスというのは、OpenStruct的なもので、よく設定関連の情報をSomething.config.settingというようにアトリビュート的に見えるメソッドにまとめておく仕組みを提供している。ちょっと抜き出して、以下のように動作を確認してみた。

module Gems
  class <<self
  attr_accessor :config

  class SimpleStruct
    attr_reader :hash

    def initialize(hash)
      @hash = hash
      @hash.each do |key, value|
        self.class.send(:define_method, key) { @hash[key] }
        self.class.send(:define_method, "#{key}=") { |v| @hash[key] = v }
      end
    end
  end

  Gems.config = SimpleStruct.new(
                                 :gemsets => "sets",
                                 :gemspec => "specs",
                                 :one => 1,
                                 :two => 1,
                                 :warn => true
                                 )
  end

end

puts "Gems.class: #{Gems.class}"
puts "Gems.config.class: #{Gems.config.class}"
puts Gems.config.methods - Object.methods

puts Gems.config.one
Gems.config.one = 3
puts Gems.config.one     => 3
Gems.config.three = 3    => NoMethodError

OpenStructとの違いは、初期化後にアトリビュートを追加できないことで、最後の行はエラーになる。なるほど。

Ruby標準添付でMatz謹製のOpenStructのほうを見てみると、動的なメソッド定義によるアトリビュートの設定部分は、

 def new_ostruct_member(name)
    name = name.to_sym
    unless self.respond_to?(name)
      class << self; self; end.class_eval do
        define_method(name) { @table[name] }
        define_method("#{name}=") { |x| modifiable[name] = x }
      end
    end
    name
  end

と書かれている。class << self; self; end.class_evalとしてインスタンス化したOpenStructオブジェクトの特異メソッド定義のためのコンテキストを作り出しているけど、これは、

class << self
  self.class.send(:define_method, name) { @table[name] }

としても同じ。こちらのほうがストレートな書き方に思える。

StasisのSimpleStructは、Rubyで良くある、

Hoge.config.host = "localhost"

Hoge.configure do |config|
  config.host = "localhost"
  config.port = 25
end

というような、内部実装としてはハッシュテーブルを使い、APIとしてはメソッドとして見せるようなコンベンションを実現している。ほかのライブラリはどうかと思って、Omniauthを見てみたら、全然違うアプローチだった。

lib/omniauth.rbの関係がありそうなところを抜き出すと、以下の通り。

module OmniAuth
  module Strategies; end

  class Configuration
    include Singleton

    @@defaults = {
      :camelizations => {},
      :path_prefix => '/auth',
             :
             :

    def self.defaults
      @@defaults
    end

    def initialize
      @@defaults.each_pair{|k,v| self.send("#{k}=",v)}
    end

    attr_writer :on_failure
    attr_accessor :path_prefix, :allowed_request_methods, :form_css, :test_mode, :mock_auth, :full_host, :camelizations
  end

  def self.config
    Configuration.instance
  end

  def self.configure
    yield config
  end

2行目の意味のないmodule宣言が一瞬なんだこれと思ったけど、このライブラリには、こういうモジュールがありますよ宣言なのかな、親切だ。Omniauthは多くのOAuth Providerに対応していて、それらの個別OPが返してくる情報を、統一的なハッシュで見せてくれる。その個別OP対応に使っているコード整理のアプローチが、いわゆるストラテジーパターン。

Omniauthの設定関連ではSingletonライブラリを使っている。RubyのSingletonはclassにincludeするだけで使える。Klass.newメソッドがプライベートメソッドに設定され、直接呼べなくなる。代わりに、Klass.instanceで、唯一のインスタンスを返す。最初に、Omniauth::Configuration.configが呼ばれた時点で、Configuration.insntaceによってインスタンス化されて、それ以降は単にインスタンス化済みのインスタンスを返す。

デフォルトの設定はクラス変数に保持しておいて、初期化時にそれをアトリビュートにコピーしている。

という動作を確認するために以下のコードを書いてみた。

require 'singleton'

class MyTest
  include Singleton

  @@defaults = { hello:10, world:25}

  def initialize
    @@defaults.each_pair{|k,v| self.send("#{k}=",v)}
  end

  def self.config
    MyTest.instance
  end

  attr_accessor :hello, :world
end

p MyTest.config
p MyTest.config.hello      => 10
p MyTest.config.world      => 25
MyTest.config.hello = 17
p MyTest.config.hello      => 17

なるほど。さらに、このMyTestクラスに、

  def self.configure
    yield config
  end

を加えると、

MyTest.configure do |c|
  c.hello = 100
  c.world = 200
end

p MyTest.config  => #<MyTest:0x0000010080b410 @hello=100, @world=200>

となる。なるほど。