私がこの記事を執筆している間、テキサス州とオクラホマ州は、長かったアイス・ストームの季節を終わろうとしています。凍った道路を恐れるだけではなく、その道路を走る短気なテキサス人ドライバーにも恐れをなしていた運転者達が、再び道路に出始めています。そして私の生活も、3 日間の休息の後、うわべは平常に戻ろうとしています。私は Java から Ruby に切り替えた後、氷による凍結とは別の短時間のフリーズを体験しました。私が Java プロジェクトで作業する際には、ちょっとしたニッチの問題があっても、必ずそれを解決するための特別な Spring ライブラリー、あるいは Eclipse コンポーネントを見つけることができました。しかし Ruby on Rails がまだ新しかった頃には、そうしたライブラリーやコンポーネントを自分自身で作成する必要があったのです。幸いなことに、雪解けと共に、私が体験したフリーズも見事に解消されつつあります。それは、何千人もの人達が Rails を拡張するために使ってきた、効果的なプラグイン・アーキテクチャーのおかげなのです。
これまで少しでも Rails を経験したことのある人であれば、まず間違いなく ActiveRecord の中の acts_as コマンドに気付いたはずです。ActiveRecord はパーシスタンスを処理するためのものですが、データベースでの保管、取得の他にもクラスに動作を追加したいことがよくあるものです。例えば acts_as_tree を使うと、parent_id 属性を持つクラスにツリーのような振る舞いを追加することができます。ActiveRecord モデルの中で acts_as_tree 以外のことを何も指定しなければ、例えば親レコードあるいは子レコードを取得するためのメソッドなど、ツリーを管理するためのメソッドを動的に追加することができます。私はこの 1 ヶ月間で、投票処理やバージョン管理、Ajax、複合キーなど、基本的な Rails ではサポートされない、あらゆる種類の機能を持った Rails 用プラグインを見つけることができました。
Rails の拡張モデルは Ruby 言語の機能の上に構築されていますが、Java 言語のモデルとは大幅に異なって見えます。この記事では、拡張モデルを内側から見られるように、acts_as プラグインについて調べることにします。ここではお遊びのエンド・ツー・エンドのシナリオを作成する代わりに、より広く内容をカバーし、また実際のプラグインの様子と、それらが実際の稼働コードの中でどう使われているかを実感できるように、実稼働システムの一部を例として示すことにします。
多くの皆さんが知っているとおり、ステート・マシンはシステムの状態を数学的に表現したものです。ステート・マシンには、状態や状態間の遷移を表現するノードが混在しています。いかなる瞬間においても、ステート・マシンにはアクティブな状態 (カレント・ステートとも言われます) が 1 つあり、イベントによって状態間の遷移がトリガーされます。この概念を説明するために、私が現在日々行っている仕事から例を引用しましょう。仕事というのは、非営利団体と寄付者のためのマーケットである CTP (ChangingThePresent.org) の開発と維持管理です (「参考文献」を参照してください)。CTP は非営利団体に対して、その団体の組織に関する情報と必要としている寄付の内容 (例えば 1 人の癌研究者の 1 時間、あるいは 1 人の生徒への何冊かの本など) を提出させます。寄付者は簡単なショッピング・カートを使って別の名前で慈善寄付を行えます。そうしたすべての情報を収集しようとすると非常に手間がかかるため、私はステート・マシンを使ってワークフローを単純化することにしました。
この問題を解決するために、Scott Barron が作成した、acts_as_state_machine (「参考文献」を参照) というサードパーティーのプラグインを使うことにします。acts_as_state_machine は多くの Rails プラグインと同様、Ruby の機能と Rails 独自の機能を組み合わせることによって、ライブラリーのみならず DSL (domain-specific language: ドメイン固有言語) も提供でき、ユーザーに素晴らしいエクスペリエンスを与えることができます。
ある顧客が、コンテンツを CTP に送信します (submitted 状態)。そうすると CTP 管理者はそのコンテンツを受信し、場合によってはそれを編集します (processing 状態)。もし CTP が編集を行う場合、非営利団体はそうした変更を承認できる必要があります (nonprofit_reviewing 状態)。CTP あるいは非営利団体がコンテンツを承認すると、CTP はそのコンテンツをサイトに表示することができます (accepted 状態)。図 1 は、このステート・マシンを図で示したものです。
図 1. CTP のステート・マシン
このプラグインを使うと、クラス・オブジェクトを直接修飾することができます。つまりさまざまな状態や状態間の遷移、そうした遷移を起動するイベントを、DSL を使って表現することができます。リスト 1 は、私が CTP で非営利団体を管理するために使用している、単純化したバージョンのステート・マシンを示しています。
リスト 1: ステート・マシンの例
class Nonprofit < ActiveRecord::Base
acts_as_state_machine :initial => :created, :column => 'status'
# These are all of the states for the existing system.
state :submitted
state :processing
state :nonprofit_reviewing
state :accepted
event :accept do
transitions :from => :processing, :to => :accepted
transitions :from => :nonprofit_reviewing, :to => :accepted
end
event :receive do
transitions :from => :submitted, :to => :processing
end
# either a CTP or nonprofit user edits the entry, requiring a review
event :send_for_review do
transitions :from => :processing, :to => :nonprofit_reviewing
transitions :from => :nonprofit_reviewing, :to => :processing
transitions :from => :accepted, :to => :nonprofit_reviewing
end
|
皆さんはこれまでこの Ruby の機能を見たことがないかもしれませんが、この言語はステート・マシンのフローを非常に的確に記述できるのです。それぞれの状態の記述に続いて、ステート・マシンがサポートするイベントの記述があります。また、各イベントのあとには、イベントによって起動される一連の状態遷移を見ることができます。
各ステートメントは、有効な Ruby 構文を表現しています。クラス定義の後に、acts_as_state_machine :initial => :created, :column => 'status' があります。Java 開発者である皆さんは、メソッド定義の代わりにメソッド呼び出しがあるのを見て不思議に思うかもしれません。Ruby では、クラス・レベルのこうしたメソッド呼び出しを、マクロと呼びます。Ruby では、各クラスがロードされる際にマクロを使ってクラスに機能を追加することがよくあります。実際、メソッド定義 (def) は Ruby のマクロに他なりません。
次に、state :submitted など、一連の状態があることがわかります。これらはメソッド呼び出しであり、それぞれ 1 つのシンボルを 1 つのパラメーターとして使います。(シンボルはユーザーが定義する名前です。) event コマンドもメソッド呼び出しであり、シンボル (イベントの名前を定義します) と、遷移を定義するクロージャーを使います。
各遷移はメソッド呼び出しであり、その後にハッシュ・テーブルが続きます。Ruby では、ハッシュ・マップを key => value という対として表現します (各対はカンマで区切られ、また中括弧 { } で囲まれます)。ハッシュ・マップを関数コールの最後のパラメーターとして使う場合には、中括弧はオプションです。状態や遷移、イベントなどのメソッドは、クロージャーやハッシュ・マップと組み合わせることで、便利な DSL になることがわかります。
ステート・マシンを使うためには、Nonprofit オブジェクトをインスタンス化し、このオブジェクトに対してイベントごとにメソッドをコールし、その後に ! を付けます (リスト 2)。
リスト 2. ステート・マシンを操作する
>> np = Nonprofit.find(2)
=> ...
>> np.current_state
=> :submitted
>> np.receive!
=> true
>> np.accept!
=> true
>> np.current_state
=> :accepted
|
Rails の仕様では、「!」があると属性の修正と保存を 1 つのステップで行います。さて、ステート・マシン・プラグインに対する要件が明確になりました。つまり下記が必要なのです。
- ステート・マシン・コードを置くための便利な場所
- クラス・メソッドを指定するための方法 (DSL 用に必要です)
- インスタンス・メソッドを、Nonprofit あるいは他のターゲット・クラスに加えるための方法
この記事のこれから先では、このプラグインを詳細に説明します。実際にコードを追ってみたい方は、acts_as_state_machine プラグインをダウンロードしてください (「参考文献」にある Scott Barron のサイトへのリンクを参照し、そのリンク先にある指示に従って、Subversion を使ってプラグインを入手してください)。trunk/lib までナビゲートすると、acts_as_state_machine.rb ファイルがあります。また、trunk/init.rb には初期化コードがあります。必要なものは、この 2 つのファイルのみです。
原則として、すべての acts_as プラグインは同じように動作します。acts_as モジュールをビルドするためには、必ず下記の手順に従います。
- モジュールを作成します。クラスのメソッド名 (初期化マクロ) は acts_as_ で始めます。
- 初期化コードの中で ActiveRecord ベース・クラスを開き、acts_as_ モジュールを追加します。
- acts_as_ 関数 (例えば acts_as_state_machine など) の中のターゲット・クラスの動作を拡張します。
リスト 3 に示す init.rb の初期化コードを見てください。
リスト 3. acts_as_state_machine 用の初期化コード
require 'acts_as_state_machine'
ActiveRecord::Base.class_eval do
include ScottBarron::Acts::StateMachine
end
|
このコードでは、コアとなる ActiveRecord クラス (ActiveRecord::Base) を開いて、acts_as_state_machine を追加しています。class_eval メソッドによって、このクラスが開かれ、このクラスのコンテキストで次のクロージャーを実行します。こう書くと大げさですが、実際の概念は単純です。つまりこのコードは ActiveRecord ベース・クラスを開いて、ScottBarron::Acts::StateMachine モジュールと混合します。Ruby では、任意のクラスを開いて迅速に再定義することができるのです。
この機能によって柔軟性が高まるため、この機能は Ruby の最大の強みの 1 つです。しかし強みは弱みでもあります。柔軟性を持たせすぎると、コードは理解しにくく、維持管理しにくくなりがちなため、注意する必要があります。今度は acts_as_state_machine.rb ファイルを開き、どんなコードが混合されるのかを見てみましょう。
ここで、ステート・マシンの実装の詳細から離れ、ステート・マシンへのインターフェースをプラグインによって公開する方法について説明します。リスト 4 は、モジュールの定義と、ステート・マシン自体のインターフェースの一部を示しています。
リスト 4. モジュールの構造
module Acts #:nodoc:
module StateMachine #:nodoc:
class InvalidState < Exception #:nodoc:
end
class NoInitialState < Exception #:nodoc:
end
def self.included(base) #:nodoc:
base.extend ActMacro
end
module SupportingClasses
class State
attr_reader :name
def initialize
...
end
def entering
...
end
...
end
class StateTransition
attr_reader :from, :to, :opts
def initialize
...
end
def perform
...
end
...
end
class Event
...
def fire
...
end
def transitions
...
end
...
end
|
リスト 4 の先頭に、ネストしたモジュール定義があります。モジュールにはメソッド定義がありますが、ベースとなる、継承の階層構造がありません。その代わり、既存の任意の Ruby のクラスにモジュールを加えることができます。この概念が初めての人は、モジュールのことを、インターフェースにインターフェースの実装を加えたものと考えてください。モジュールの良いところは、既存の任意の Ruby のクラスにモジュールの機能を加えることができ、しかもいくらでも加えることができるところです。また、クラスの既存機能を活用することもできます。この方法は Mix-in と呼ばれます。C++ も多重継承を使って同様の機能を実現することはできますが、非常に醜悪で面倒な事態が起きます。Java を作った人達は、そうした事態を避けるために多重継承をなくしました。しかしモジュールを使えば、面倒な事態を起こさずに多重継承の利点の一部を利用することができます。Smalltalk や Python などの言語も、Mix-in による継承をサポートしています。
リスト 4 の残りの部分は、ステート・マシンを実装するための、ありふれた詳細処理の一部を示しています。ここでは、これらのクラスによって、ステート・マシンのスタンドアローン実装が行われることを知っておけば十分です。それ以外のコードは、そのステート・マシンのインターフェースをプラグインのクライアントに公開する処理を行っているため、ずっと興味深い部分と言えます。
プラグインの作成者には、実装を配置する場所と DSL (クラス・メソッド) を公開する方法、そしてステート・マシンのインスタンス・メソッドを公開する方法、という 3 つが必要なことを思い出してください。これらの中には、リスト 3 のアクションの中で見たイベント・メソッドが含まれます。リスト 4 は実装を配置する場所を提供しました。次のコード片では DSL を処理します。
acts_as プラグインのアーキテクチャーには、acts_as マクロという 1 つのアンカー・ポイントがあります。acts_as プラグインのクライアントは、このメソッドを、ターゲット・クラス内のメソッド呼び出しを使って導入します。私の場合では、リスト 1 の acts_as を、下記のコード行を使って Nonprofit クラスから呼び出します。
acts_as_state_machine :initial => :created, :column => 'status' |
今度はリスト 5 を見てください。これは acts_as_state_machine のための ActMacro です。このクラスはモジュールの属性を処理し、さまざまなクラスやインスタンス・メソッドを導入します。
リスト 5. acts_as を追加する
module ActMacro
# Configuration options are
#
# * +column+ - specifies the column name to use for keeping the state (default: state)
# * +initial+ - specifies an initial state for newly created objects (required)
def acts_as_state_machine(opts)
self.extend(ClassMethods)
raise NoInitialState unless opts[:initial]
write_inheritable_attribute :states, {}
write_inheritable_attribute :initial_state, opts[:initial]
write_inheritable_attribute :transition_table, {}
write_inheritable_attribute :event_table, {}
write_inheritable_attribute :state_column, opts[:column] || 'state'
class_inheritable_reader :initial_state
class_inheritable_reader :state_column
class_inheritable_reader :transition_table
class_inheritable_reader :event_table
self.send(:include, ScottBarron::Acts::StateMachine::InstanceMethods)
before_create :set_initial_state
after_create :run_initial_state_actions
end
end
|
リスト 5 のモジュールには、acts_as_state_machine という 1 つのメソッドがあります。このメソッドは、下記の 5 つのタスクを行います。
- クラス・メソッドを導入する
- ステート・マシンの例外を処理する
- 属性を管理する
- インスタンス・メソッドを導入する
- フィルターの前後を処理する
acts_as_state_machine メソッドは、最初にクラス・メソッドを導入します。(こうしたメソッドの詳細はリスト 6 を見るとわかります。) 次に、このメソッドは例外を処理します。この場合では、唯一の例外は、クライアントが初期状態を指定しない場合に発生します。ちょっと継承の属性をスキップして、次に進みましょう。self.send メソッドはインスタンス・メソッドを導入します (リスト 7 はその詳細です)。最後に、before フィルターと after フィルターは ActiveRecord マクロであり、ActiveRecord がレコードを作成する前と後に set_initial_state と run_initial_state_actions をコールします。
write_inheritable_attribute マクロと class_inheritable_reader マクロに戻りましょう。なぜこのモジュールが単純な継承を使わないのか、不思議に思う人がいるかもしれません。その理由は単純です。このモジュールは、継承の階層構造を独自に持っているのです。こうしたマクロによって、モジュールは属性をターゲット・クラス (この例では Nonprofit) に加えることができるのです。最も重要な属性は、state_column と、状態とイベントと遷移を含む一連の遷移表です。さて今度は、DSL を形成するクラス・メソッドを追加します。
リスト 6 を見ると、DSL を導入するという魔術が、ようやくわかります。
リスト 6. acts_as_state_machine のためのクラス・メソッド
module ClassMethods
def states
read_inheritable_attribute(:states).keys
end
def event(event, opts={}, &block)
tt = read_inheritable_attribute(:transition_table)
et = read_inheritable_attribute(:event_table)
e = et[event.to_sym] = SupportingClasses::Event.new(event, opts, tt, &block)
define_method("#{event.to_s}!") { e.fire(self) }
end
def state(name, opts={})
state = SupportingClasses::State.new(name.to_sym, opts)
read_inheritable_attribute(:states)[name.to_sym] = state
define_method("#{state.name}?") { current_state == state.name }
end
...
|
先ほど触れたとおり、event マクロと state マクロは単純なメソッドであり、ClassMethods と呼ばれるモジュールの中で定義されます。event メソッドは遷移表の属性を読み取り、続いてイベント表の属性を読み取ります。このメソッドは、イベント表にイベントを追加した後、そのイベントに対するメソッドを動的に定義し、新しいメソッドを event の fire メソッドに接続します。
このモジュールは event メソッドを定義した後、state メソッドを定義します。state メソッドは状態表を読み取り、新しい状態を追加します。そして次にターゲット・クラスにコンビニエンス・メソッドを追加し、そのインスタンスがカレント・ステートであれば true を返します。例えば nonprofit.submitted? は、もし状態フラグが submitted であれば true を返します。これで DSL が完全にサポートされました。
インスタンス・メソッドも、クラス・メソッドとまったく同様に動作します。リスト 7 はインスタンス・メソッドを示しています。
リスト 7. acts_as_state_machine のインスタンス・メソッド
module InstanceMethods
def set_initial_state
write_attribute self.class.state_column, self.class.initial_state.to_s
end
...
def current_state
self.send(self.class.state_column).to_sym
end
...
end
|
ActMacro はクラスを開き、インスタンス・メソッドを追加します。属性を使うために read_inheritable_attribute マクロを使う必要はありません。それは、こうした属性は ActiveRecord が定義するクラス・インスタンス変数であるからです。ここでは、初期状態を設定してカレント・ステートを返すメソッドのみを示しています。これ以外のメソッドも、同じように動作します。
リスト 7 の最初のメソッドは、初期状態を設定し、既存の ActiveRecord 列を更新します。ActMacro を呼び出した際に列の名前を設定したことを思い出してください。current_state メソッドは、単純にインスタンス変数の値を返します。send メソッドは、1 つのシンボル・パラメーターによる名前 (この場合は state_column の名前) を持ったメソッド名を呼び出します。
皆さんは、単にステート・マシンを作成し、それをライブラリーとして使った方が簡単だと思うかもしれません。しかし acts_as プラグインは、それよりももっと便利なのです。acts_as を使うことで、データベースにステート・マシン列を効果的に追加することができます。また、他のプラグインを使えば、バージョン管理や監査履歴の作成、画像の処理、その他何百という単純作業を、そうした作業があたかも Rails 環境とデータベースとをシームレスに統合するかのように行うことができます。
皆さんは Java 言語を使って Eclipse プラグインや Ant タスク、あるいは Spring ライブラリーをコード・ベースに統合する、あるいは EJB コンポーネントを導入するといった経験があるかもしれません。Java コミュニティーから生まれた多くの概念によって、拡張に対する開発者の考え方が変わったのです。ここでは Rails の acts_as プラグインを駆け足で紹介しながら、拡張に対する新しい考え方を説明しました。私は Ruby 言語の柔軟性を見たことで、拡張に対する考え方を変えました。acts_as プラグインによって、新世代の開発者達は実際に拡張を作成することができます。そしてその結果、Rails 用の新しい拡張が大量に生まれています。こうした方法の多くは、アスペクト指向プログラミングやバイトコード・エンハンスメントによって、Java 開発者にも利用できるのです。
次回はこのシリーズの最終回として、困難な問題を Ruby を使って解決する場合と、Java プラットフォームでの私の経験とを詳細に比較します。それまでの間、境界を越え続けてください。
-
『Java To Ruby: Things Every Manager Should Know』(2006年、Pragmatic Bookshelf 刊) は、この記事の著者による本です。Java プログラミングから Ruby on Rails に切り替える意味があるのは、いつ、どんな場合か、そしてその方法について解説しています。
-
『Beyond Java』(2005年、O'Reilly刊) も、この記事の著者による本です。Java 言語の台頭と停滞について、また、一部のニッチな領域で Java プラットフォームに対抗しうる技術について解説しています。
- Rails プラグインのアーキテクチャーについての資料、Plugins in Ruby on Rails を調べてみてください。
- Rails のプラグイン、acts_as_state_machine を利用すると、ActiveRecord モデルをステート・マシンとして機能させることができます。
- この記事で実例として引用した Changing The Present は、Ruby on Rails で構築された非営利のマーケットです。
- 多重継承を処理する必要があるとすると、クラス変数とインスタンス変数の処理は面倒です。この記事、「Class and instance variables in Ruby」(John Nunemaker 著、RailsTips.org、2006年11月) は、継承可能な属性と呼ばれる方法の例を、順を追って解説しています。
-
Java technology ゾーンには Java プログラミングのあらゆる側面を網羅した記事が豊富に用意されています。
-
developerWorks blogs から developerWorks のコミュニティーに加わってください。
