はじめに †今回はtDiaryのプラグインの仕組みを読んでみたいと思います。といっても今までtDiaryを読んだことがないので1から読んでいきたいと思います。 なお、今回読んだtDiaryのバージョンは2.1.4です。 index.rb †何はなくとも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メソッドを呼ぶことで出力を作っているようです。 TDiary::Plugin †さて、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 とすればよいことになります。プラグインの仕掛けを知らなくてもとにかくメソッドを定義して所定のところに放り込んでおけばよいというとても親切設計ですね :-) 50sp.rb †さて、上のコードでは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も読むということを行ってるようです。 diary.rhtml †それでは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はHOWTO-make-plugin.htmlに説明されているコールバック系プラグインですね。でもto_htmlと違って<%%=となっています。 それでは<%%=の謎解きをしましょう。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で、 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%> ) ) となります。 TDiary::Plugin再び †さて、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 ) プラグインの読み込みの段階でPluginクラスに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を追加しています。 ところで、"日記本文"をevalしたらそんなメソッドなんてありませんとエラーになりますよね。そこら辺はmethod_missingの仕組みを使って回避しているようです。 おわりに †今回はtDiaryのソースをプラグインの実現という部分に注目して見てみました。プラグインを実現するための重要なポイントは次の2点です。
率直な感想として、「たださんすげぇ!」の一言です。 それではみなさんもよいコードリーディングを。 |