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>
となる。なるほど。