CommonsChains
Rubyを使って試すChains
一連の処理を実行する場合の方法論として、たとえばテンプレートメソッドがあるが、ここではコマンドパターンとして複数のクラスに分割した処理と、全体の制御をチェインオブレスポンサビリティとして実装してみる。
といっても元ネタがあって、そのJavaのコードをRubyに置き換えただけのことだ。といっても、元のコードは読んでいないが。だからホンモノはいろいろと例外時の考慮がされているだろうが、ここでは最低限しかしない。
元ネタ
http://www.onjava.com/pub/a/onjava/2005/03/02/commonchains.html?page=1
Jakarta-Commons2004年12月からのメンバーChainsについての解説記事。
ではなぜRuby
元ネタとして挙げた記事ではさらりと書いてあるが、ソースのzipには(readme.txtなども含めて)100以上のファイルが入っている。
動作原理を見るだけには、いささか大げさじゃないか。というわけで、Rubyを使ってみる。もっとも、Rubyでは複数のクラスを1つのファイルへ入れられるから単純な比較はフェアではないが。
また、構成ファイル(カタログと呼ぶ)としてXMLの代わりに、直接Rubyの配列とハッシュを使って単純化している部分もある。
とはいえ、結構、クリアではなかろうか?
実行すべきもの
例は、元ネタとほとんど同じにしてある。車の購入処理フローだ。
下のリストは処理と対応するクラスを示したもの。ただし、WikiNameになることを防ぐために先頭文字を小文字にしてある)
- 顧客情報の取得(getCustomreInfo)
- 試乗(testDriveVehicle)
- 商談(negotiateSale)
- 支払方法の決定(arrangeFinacing)
- 商談成立(closeSale)
を順に実行する。
また、Chainsは終了時に、それまでに実行した処理を逆順に呼び出すことで、退出時処理を可能としている。ここでは、(逆順に呼ばれることを利用して)最初に正常処理時には何もしないかわりに退出時に例外が発生していたらその情報を出力する処理(exceptionHandler) を先頭に置く。これも元ネタと同様である。
アプリケーション
sample.rb
#!/usr/bin/ruby -Ks require 'chain' class ExceptionHandler def execute(c) puts 'Filter.execute' end def post_execute(cx, e) p e if !e.nil? end end class GetCustomerInfo def execute(c) c.customer_name = 'Foo the Man' puts 'GetCustomerInfo' end end class TestDriveVehicle def execute(c) puts 'TestDriveVehicle' end end class NegotiateSale def execute(c) puts 'NegotiateSale' end end class CloseSale def execute(c) puts 'CloseSale' end end class ArrangeFinancing def execute(c) raise RuntimeError, c.customer_name + ' is blacklisted' end end class Context < Hash def initialize() @customer_name = nil end attr_accessor :customer_name end if $0 == __FILE__ c = Catalog.load_catalog('config.rb') chain = c.get_chain("sell-vehicle") chain.execute(Context.new) end
各処理(コマンドと呼ぶ。というかコマンドパターンなのでそのまんまというか)が明示的にtrue(TrueClassのインスタンス)を返すか、例外を投げない限り、指定されたシーケンスに従ってChainsは呼び出しを行う(このシーケンスをチェインと呼ぶ)。
この呼び出しは、executeという名前のメソッドにコンテキストを与えることで行われる。コンテキストは文字通り実行時コンテキストを保持するオブジェクトである。例では、Contextとしてハッシュの派生クラスとして実装している。
ちなみに、DIは、明示的なコンテキストをパラメータとして与える代わりに直接オブジェクトの属性を設定する方法という言い方もできるだろう。コンテキストを利用する場合の問題点は何が入っているかわからなくなること、属性を設定するDIと異なり、書き込みを許すために途中の変更や追加が見えにくくなること、それを防ぐためには明示的なメソッドが必要となり(例では、customer_nameというアクセサで示されている。ここでは単なるアクセサだが、まともなたとえばログ付きメソッドにすれば途中の変更や読み込みを監視することが可能となる)、結果的に頻繁な変更がコンテキスト自身に必要になることなどが挙げられる。(ここは書き方が甘いので追及は不可)
ここでは各クラスのexecuteの実装は基本的に自分のクラス名を出力するだけだ。
呼び出すべきチェインのすべてのコマンドが完了するか、または例外で中断された場合、Chainsは逆順に終了後処理を呼び出す。このメソッドはpost_executeという名称である。Rubyによる実装ではrespond_to?を利用して定義されていれば呼ぶということにしてある。引数はコンテキストと例外オブジェクト。正常に終了した場合、例外オブジェクトにはnilが設定されることになる。
実際のアプリケーションでは、例外時には部分トランザクション(既にコミット済み)の明示的なロールバック処理や、取消メッセージの送信の実行となるだろう。
ちなみに、exceptionHandler(WikiName防御だ。本当はEで始まる)のexecuteで出力されているFilterというのは、CommonsChainsで、post_executeメソッドを持つクラスが実装するインターフェイス名である。
構成ファイル
既に書いたように構成ファイル(カタログ)自身はRubyのスクリプトとして実装されている。
カタログの構成は、トップレベルのカタログ(カタログ名はシンボルnameで示す)の中に、文字列(チェイン名となる)をキーとした複数のチェインだ。また、各チェインは配列として実装され、コマンドを示すハッシュを要素とする。コマンドは実行すべきコマンドクラスのクラス名か、またはネストして呼び出されるカタログのチェイン名を保持する。
config.rb
catalog = { :name => 'auto-sales', 'sell-vehicle' => [ { :id => 'exception_handler', :class => ExceptionHandler, }, { :id => 'get_customer_info', :class => GetCustomerInfo, }, { :id => 'test_drive_vehicle', :class => TestDriveVehicle, }, { :id => 'negotiate_sale', :class => NegotiateSale, }, { :catalog => 'auto-sales', :name => 'arrange-financing', :optional => true, }, { :id => 'close_sale', :class => CloseSale, }, ], 'arrange-financing' => [ { :id => 'arrange_financing', :class => ArrangeFinancing, }, ], }
(解説が必要そうなら後で書くつもり)
この実装では、「支払方法の決定」は別チェインとして示される。
Chains実装
Chainsは2つのクラス(と1つの補助的なクラス)から構成されている。
- Catalog
- カタログを示す。クラスメソッドとしてカタログの読み込み処理(とその保持)であるload_catalogと、指定されたカタログのインスタンスを返すget_catalogを持つ。インスタンスメソッドには、チェインのインスタンスを返すget_chainだけである。なお、ここではCatalogはあるカタログのインスタンスのファクトリを兼ねさせているためコンストラクタそのものはプライベートとして実装している。
- Chain
- チェインを示すクラスである。ネストしたチェインの場合、例外の動作や退出呼び出しの動作が異なるため、それを示す属性を持つ。
- Command
- チェインの内部クラスとして、コマンドのインスタンス化を担当する。
chain.rb
class Catalog @@catalog = {} def get_chain(name, nested = false) chain = @catalog[name] if chain.nil? raise RuntimeError, name + ' not found' end Chain.new(chain, nested) end def self.load_catalog(catfile) catalog = nil File.open(catfile, 'r') do |f| eval(f.read) end if catalog.nil? || catalog[:name].nil? raise RuntimeError, catfile + ' is not a valid catalog' else @@catalog[catalog[:name]] = catalog end Catalog.new(catalog) end def self.get_catalog(name) Catalog.new(@@catalog[name]) end private def self.new(c) super(c) end def initialize(cat) @catalog = cat end end class Chain class Command def initialize(c) @obj = c[:class].new @id = c[:id] end def execute(ctx) @obj.execute(ctx) end def post_execute(cx, e) @obj.post_execute(cx, e) if @obj.respond_to?(:post_execute) end end def initialize(cmd, nested) @nested = nested @commands = [] cmd.each do |c| if Class === c[:class] @commands << Command.new(c) else jump = nil if !c[:catalog].nil? cat = Catalog.get_catalog(c[:catalog]) if !cat.nil? && !c[:name].nil? jump = cat.get_chain(c[:name], true) end end if jump.nil? if !c[:ooptional] raise RuntimeError, c.inspect + ' is not valid chain' end else @commands << jump end end end @bt = nil end def execute(cx) e = nil result = nil @bt = [] @commands.each do |c| @bt << c begin result = c.execute(cx) break if result == true rescue StandardError => e raise e if @nested break end end post_execute(cx, e) unless @nested result end def post_execute(cx, e) @bt.reverse_each do |x| begin x.post_execute(cx, e) if x.respond_to?(:post_execute) rescue # ignore exception end end end end
実行例
もちろん、こうなる。各コマンド実装クラスの中身を替えたりして試してみよう。たとえば、trueを返すようにするとか。
C:\Home\arton\test\chain>ruby sample.rb ruby sample.rb Filter.execute GetCustomerInfo TestDriveVehicle NegotiateSale #<RuntimeError: Foo the Man is blacklisted>
ソースファイル
このzipの中身はちょっと古い。Command#executeとCommand#initializeが異なる(@btの初期化が1回だけのため、複数回のexecute呼び出しに対応できない)。
Keyword(s):
References: