今回はtDiaryのプラグインの仕組みを読んでみたいと思います。といっても今までtDiaryを読んだことがないので1から読んでいきたいと思います。
なお、今回読んだtDiaryのバージョンは2.1.4です。
何はなくともindex.rbを見てみましょう。早速興味深いコードがあります。
if FileTest::symlink?( __FILE__ ) then org_path = File::dirname( File::readlink( __FILE__ ) ) else org_path = File::dirname( __FILE__ ) end $:.unshift( org_path.untaint ) require 'tdiary'
$:はファイルのロードパスです。そこにindex.rb*1が置かれているディレクトリを登録することでカレントディレクトリがどこから実行されてもindex.rbと同じディレクトリにあるtdiary.rbをロードできるようにしています。*2
次にTDiary::Configオブジェクトを作ることでtdiary.confのロードを行っています。さらっと長そうと思ったのですがまた興味深いコードがありました。TDiary::Config.initializeメソッドにおいて次のコードがあります。
instance_variables.each do |v| v.sub!( /@/, '' ) instance_eval( <<-SRC def #{v} @#{v} end def #{v}=(p) @#{v} = p end SRC ) end
インスタンス変数からgetterメソッドとsetterメソッドを動的に生成しています。う〜ん、Rubyならではですね。
その後、渡された引数に応じてTDiary::TDiaryBaseから派生したクラスのオブジェクトを作成、eval_rhtmlメソッドを呼ぶことで出力を作っているようです。
さて、eval_rhtmメソッドl(実体はprotectedなdo_eval_rhtmlメソッド)ですが初めにプラグインをロードしているようです。というわけでその部分を見てみましょう。
Dir::glob( "#{plugin_path}/*.rb" ).sort.each do |file| plugin_file = file load_plugin( file ) @plugin_files << plugin_file end
pluginディレクトリは00default.rb, 05referer.rb, 10spamfilter.rbという「数字2桁+名前.rb」でファイルが置かれているわけですがsortをかけることで必ず番号の小さいファイルから読まれるようにしています。globが番号の小さい順にしてくれる保証はありませんからね。参考になります。
個々のプラグインは次のコードで読み込まれています。
def load_plugin( file ) @resource_loaded = false begin res_file = File::dirname( file ) + "/#{@conf.lang}/" + File::basename( file ) open( res_file.untaint ) do |src| instance_eval( src.read.untaint, "(plugin/#{@conf.lang}/#{File::basename( res_file )})", 1 ) end @resource_loaded = true rescue IOError, Errno::ENOENT end File::open( file.untaint ) do |src| instance_eval( src.read.untaint, "(plugin/#{File::basename( file )})", 1 ) end end
興味深いところは2つあります。言語名ディレクトリにあるプラグインファイル名と同名のリソースファイルを読み込んでいるところとプラグインファイルの内容をinstance_evalしているところです。表示メッセージなどの文字列を別ファイルにすることで言語を切り替えやすくなります。また、instance_evalすることで各プラグインはいちいち
module TDiary class Plugin def foo ... end end end
としなくても
def foo ... end
とすればよいことになります。
さて、上のコードではtdiaryディレクトリ直下のpluginディレクトリにあるプラグインしか読み込まれません。しかし、現在のtDiaryにはmisc/pluginディレクトリにあるプラグインから使いたいプラグインを選択するという機能があります。これはどのように実現されているのでしょうか?どうやらその仕事をしているのは50sp.rbのようです。50sp.rbの最後に以下のコードがあります。
# Finally, we can eval the selected plugins as tdiary.rb does if sp_option( 'selected' ) then sp_option( 'selected' ).untaint.split( /\n/ ).collect{ |p| File.basename( p ) }.sort.each do |filename| @sp_path.each do |dir| path = "#{dir}/#{filename}" if File.readable?( path ) then begin load_plugin( path ) @plugin_files << path rescue Exception raise PluginError::new( "Plugin error in '#{path}'.\n#{$!}" ) end break end end end end
@data_path/tdiary.confを見るとoptions2配列にsp.selectedというキー名で選択したプラグインが列挙されていることがわかると思います。@sp_pathは通常[misc/plugin]なので、これで選択したプラグインが読み込まれることになります。
ところで、いつ@data_path/tdiary.confを読んでいるんだろう?と疑問に思っていたのですが、カレントディレクトリのtdiary.confの最終行に、
load_cgi_conf
という行があります。これにより、カレントディレクトリのtdiary.confを読むと同時に@data_path/tdiary.confも読むということを行ってるようです。
それではdo_eval_rhtmlメソッドに戻りましょう。do_eval_htmlメソッドはプラグインを読み込んだ後、HTMLを生成するrhtmlをERBで処理しています。その際、
r = ERB::new( rhtml.untaint ).result( binding ) r = ERB::new( r ).src
としています。何故2回ERBにかけているのでしょう?サンプルとしてデフォルトで表示されるlatest.rhtmlを見てみましょう。本物とは若干違いますがわかりやすさのために加工しています。
<% latest( @conf.latest_limit ) do |diary| %> <%= diary.eval_rhtml( param, PATH ) %> <hr class="sep"> <% end %>
またeval_rhtmlメソッドが呼ばれています。メソッド名が同じなので混乱してしまったのですがどうやらこのeval_rhtmlはTDiary::DiaryBaseモジュールのeval_rhtmlのようです。
さて、DiaryBaseモジュールをincludeしているのは誰なのでしょうか?tdiary.rb内を探しても見つからないのでgrepしたところ、各記述スタイルを提供するファイルで定義されているXXXDiaryがincludeしているようです。ということは上のdiary.eval_htmlメッセージはXXXDiaryオブジェクトが受け取りそうです。
さてと、DiaryBase.eval_rhtmlメソッドを見てみましょう、すると、diary.rhtmlがERBにかけられていることがわかります。diary.rhtmlで気になる部分は以下のところです。
<%%= body_enter_proc( Time::at( <%=@date.to_i%> ) ) %> <%= to_html( opt ) %> <%%= body_leave_proc( Time::at( <%=@date.to_i%> ) ) %>
to_htmlで日記本文が出力されそうです。ではbody_enter_procとbody_leave_procは何なのでしょうか?以下にもフックメソッドっぽくてプラグインのにおいがしますね。でもその前に<%%=の謎解きをすませましょう。erb.rbを参照すると以下のように書かれています。
<%% or %%> -- replace with <% or %> respectively
つまり、ERBを一回かけると
<%= body_enter_proc( Time::at( <%=@date.to_i%> ) ) %> 日記本文 <%=foo%> <%= body_leave_proc( Time::at( <%=@date.to_i%> ) ) %>
となるようです(本文中にfooプラグインを呼び出していたとします)。何かからくりがわかってきた気がします。そして二回目のERBですがresultメソッドではなくsrcメソッドを呼び出しています。srcメソッドは@srcのgetterで、
#pre{ class ERB
def initialize(str, safe_level=nil, trim_mode=nil, eoutvar='_erbout') @safe_level = safe_level compiler = ERB::Compiler.new(trim_mode) set_eoutvar(compiler, eoutvar) @src = compiler.compile(str) @filename = nil end
end }
という処理が行われるため、srcメソッドの返値は・・・
body_enter_proc( Time::at( <%=@date.to_i%> ) ) 日記本文 foo body_leave_proc( Time::at( <%=@date.to_i%> ) )
となります。
さて、do_eval_rhtmlメソッドに戻ります。ERBを二度がけした後、
# apply plugins r = @plugin.eval_src( r.untaint, @conf.secure ) if @plugin
となっています。
Plugin.eval_srcでは(セキュリティのためのコードを除くと)単純に以下のことを行っています。
eval( src, binding, "(TDiary::Plugin#eval_src)", 1 )
プラグインの読み込みの段階でfooメソッドが定義されているため、evalによりfooメソッドの返値が日記に埋め込まれます。また、body_enter_procは、
def body_enter_proc( date ) r = [] @body_enter_procs.each do |proc| r << proc.call( date ) end r.join end
と登録されているprocを順次呼び出すことで本文の前に出力したい文字列を出力しているようです。procの登録は何となく推測がつくと思いますがadd_body_enter_procで行っています。例えば、今日のお天気を出力するweather.rbプラグインの場合、
add_body_enter_proc do |date| weather( date ) end
というprocを追加しています。