概要
Gemのコードリーディングで、広く使われている高品質なコードからテクニックや定石を学んでいきます。
はじめに
いきなりRailsとかRspecを読みにいくのもツラそうなので、ここでは初心者向けとウワサのsettingslogicを読んでいくことにします。Gemを読む際の難易度の目安としては、 初めてのGemの読み方を参考にさせて頂きました.
私自身は、いくつかのOSSで、放置されているissue(テストやドキュメンテーション)をお片付けしたことはありますが、普段、そんなにOSSにガッツリcommitしている訳でもありません。そんな私にとって、
- メソッドの命名規則
- メモ化、method chainの実現方法
- コメントの書き方
など、Rubyのお作法を学ぶ題材として、またOSSのコードリーディングのきっかけとして、非常に良い題材だったと思います。
version
2.0.9を使用します。
settingslogicの使い方
ソースコードを読む前に、Settingslogicの使い方を確認しておきましょう。
設定ファイルの作成
まずは、管理する設定ファイルをYAML形式で記述します。
language:
haskell:
paradigm: functional
smalltalk:
paradigm: object oriented
設定内容にアクセスするクラスの定義
次に、設定ファイルの内容にアクセスするためのクラスを定義します。
class Settings < Settingslogic
source "#{File.dirname(__FILE__)}/settings.yml"
end
設定内容にアクセス
設定内容にアクセスするには、以下の様に記述します。
>Settings.language.haskell.paradigm
=> 'functional'
ただのHashよりも、扱いやすいインタフェースで設定内容にアクセスすることができます。
どうして、こんな事が可能になるのでしょうか?
上記のコードの裏側を理解することが今回のゴールです。
構成
ここは、軽く眺めるくらいで良いと思います。
$ tree -L 2 .
├── Gemfile
├── Gemfile.lock
├── LICENSE
├── README.rdoc
├── Rakefile
├── lib
│ └── settingslogic.rb
├── settingslogic.gemspec
├── spec
│ ├── settings.rb
│ ├── settings.yml
│ ├── settings2.rb
│ ├── settings3.rb
│ ├── settings4.rb
│ ├── settings_empty.rb
│ ├── settings_empty.yml
│ ├── settingslogic_spec.rb
│ └── spec_helper.rb
└── vendor
└── bundle
コードリーディング
設定ファイルのパスを設定
まずは設定ファイル(YAML)のパス設定を行っている部分のソースコードをみてみます。
class Settings < Settingslogic
source "#{File.dirname(__FILE__)}/settings.yml"
end
class SettingsInst < Settingslogic
end
def source(value = nil)
@source ||= value
end
sourceメソッドの中で、@source
にYAMLファイルのパスが格納されます。
@source
はメモ化されているようです。
この辺は読みやすいですね。
単なるHashよりも扱いやすいインタフェースで設定内容にアクセスする
では、いよいよ本題の部分の処理をみていきましょう。
Settings.languageメソッドが指定され、method_missingに処理が移る。
Settings.language.haskell.paradigm
>> 'functional'
上記のコードが実行されたとき、実際にはSettings.languageメソッドは存在しないので、method_missingが呼び出されます。
method_missingの引数であるnameには:language
が渡されます。
def method_missing(name, *args, &block)
instance.send(name, *args, &block)
end
次はinstanceメソッドの中身を見てみましょう。
YAMLファイルに記述された内容を読み込んでインスタンス作成
instanceメソッドでは、Settingsクラスのインスタンスであるオブジェクトを作成して返却します。
def instance
return @instance if @instance
@instance = new
create_accessors!
@instance
end
initializeメソッドの中身も少しのぞいてみます。
def initialize(hash_or_file = self.class.source, section = nil)
#puts "new! #{hash_or_file}"
case hash_or_file
when nil
raise Errno::ENOENT, "No file specified as Settingslogic source"
when Hash
self.replace hash_or_file
else
file_contents = open(hash_or_file).read
hash = file_contents.empty? ? {} : YAML.load(ERB.new(file_contents).result).to_hash # (1)
if self.class.namespace
hash = hash[self.class.namespace] or return missing_key("Missing setting '#{self.class.namespace}' in #{hash_or_file}")
end
self.replace hash # (2)
end
@section = section || self.class.source # so end of error says "in application.yml"
create_accessors!
end
注目するのは (1) と (2) の部分です。
__(1)__では、YAMLファイルに記述した内容をhashに変換しています。
そして (2) ではselfの内容をhashで置き換えます。
このとき、selfの中身を確認すると、こうなっています。
{
"language"=>{
"haskell"=>{"paradigm"=>"functional"},
"smalltalk"=>{"paradigm"=>"object oriented"}
}
}
今度は、initializerの中で呼ばれているcreate_accessors!を詳しくみていきます。
動的にアクセサを作成
このcreate_accessors!メソッドで、動的にアクセサを作成しています。
def create_accessors!
self.each do |key,val|
create_accessor_for(key)
end
end
selfの実体は、こんなHashでした。
{
"language"=>{
"haskell"=>{"paradigm"=>"functional"},
"smalltalk"=>{"paradigm"=>"object oriented"}
}
}
ですから、create_accessors!メソッドの引数のkeyには"language"
が渡されます。
処理内容を理解する上で、このメソッドが最も重要な部分です。
def create_accessor_for(key, val=nil)
return unless key.to_s =~ /^\w+$/ # could have "some-setting:" which blows up eval
instance_variable_set("@#{key}", val)
self.class.class_eval <<-EndEval
def #{key}
return @#{key} if @#{key}
return missing_key("Missing setting '#{key}' in #{@section}") unless has_key? '#{key}'
value = fetch('#{key}') # (3)
@#{key} = if value.is_a?(Hash)
self.class.new(value, "'#{key}' section in #{@section}") # (4)
elsif value.is_a?(Array) && value.all?{|v| v.is_a? Hash}
value.map{|v| self.class.new(v)}
else
value # (5)
end
end
EndEval
end
着目すべきはclass_evalの部分です。class_evalを用いることで、クラスメソッドの定義を動的に行っています。引数keyに'language'を当てはめて考えると、こうなります。
self.class.class_eval <<-EndEval
def language
return @language if @language
return missing_key("Missing setting 'language' in #{@section}") unless has_key? 'language'
value = fetch('language')
@lanugage = if value.is_a?(Hash)
self.class.new(value, "'language' section in #{@section}")
elsif value.is_a?(Array) && value.all?{|v| v.is_a? Hash}
value.map{|v| self.class.new(v)}
else
value
end
end
EndEval
Settings.languageにアクセスすると、 (3) の実行結果として、valueの中には、以下のような形でlanguage配下の情報が格納されます。
{"haskell"=>{"paradigm"=>"functional"}, "smalltalk"=>{"paradigm"=>"object oriented"}}
これはHashですから、 (4) が実行され、再びinitializeメソッドが実行され、その結果作成したインスタンスを返却します。ここで重要なことは、initializeメソッドの中では、再びHashのkey(ここでは"haskell"と"smalltalk")を元にアクセサを定義します。従って、Settings.languageで返却されるインスタンスには haskell
, smalltalk
というメソッドが定義されています。
確認してみましょう。
>Settings.language.methods
=>[
(略)
:haskell,
:smalltalk,
:method_missing,
ちゃんと定義されていますね。そして、
Settings.language.haskell
が返すインスタンスは、{"paradigm"=>"functional"}
を元に生成しますので、またまたparadigmメソッドが動的に定義されており、Settings.language.haskell.paradigm
を呼ぶと、今度は (5) のコードが実行され、
>Settings.language.haskell.paradigm
=> 'functional'
という結果になります。
総括
Hashとclass_evalを組み合わせることで、YAMLで記述した設定内容に対して、より扱いやすいインタフェースでアクセスする方法を学びとることができました。
間違いなどございましたら、ご指摘ください
軽い気持ちで始めてみたGemのコードリーディングですが、これからも時間を見つけて投稿していきたいと思います。