はじめに

今回は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点です。

  • Pluginクラス内でプラグインファイルをinstance_evalすることによりメソッドを追加
  • コールバック系プラグインを<%%= xxx_proc %>とrhtmlに書き、ERBを1回目は普通に処理、2回目はタグを取り除いた形のソースを得てPluginオブジェクト内でeval

率直な感想として、「たださんすげぇ!」の一言です。 それではみなさんもよいコードリーディングを。


*1 複数の日記を書く場合に実体はどこかに置いといて各日記のディレクトリにはindex.rbとupdate.rbのシンボリックリンク、とtdiary.confを置くという運用も考慮されているのがすばらしいところです
*2 カレントディレクトリにtdiary.confがない場合エラーになりますが

トップ   編集 凍結解除 差分 バックアップ 添付 複製 名前変更 リロード   新規 一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2006-07-09 (日) 10:19:21 (4908d)