Rails3でstate_machineでもにゃりたいのでちょろっと調査。
もにゃりたいってのが、こう、状態をとりあえずDBにぶっこんで それをいじることでコードいじらずに状態を増やしたりできないかなあなどと。
ってことでとりあえずプロジェクトを立ち上げる。
$ rails new state_machine
別に名前はなんでもいいけどこれがわかりやすいでしょう。 proto_ とか付けてもよかったな。まあいいか。
で、プロジェクトの中に移動してGemfileを開いて
gem "state_machine"
を追加。 そして
$ bundle install
準備はとりあえずこんなもんでいいか。 この際rspecなんて、テストなんて要らぬ! (この手の調査系の記事って、見る側としてはテストが書かれてないのが悲しかったりするけどめんどい)
で、とりあえずなんか状態がありそうなモデルでも考える。
自動販売機でいいか。よく状態遷移マシンの題材になる気がするし。 他にもオブジェクト思考の話でわりとよく出る気がするな。 そっちだと、Animalクラスを継承したDogとCatを鳴かせたらワンとかニャーの方が印象強いけど。
で、「自動販売機」で調べたら「coin machine」って言葉もあるのな。それを使おう。
$ bundle exec rails g model CoinMachine state
めんどくさいからstateだけあればいいよね。 あとめんどくさいからmodelだけでいいよね。
さて、これ以上進む前にそもそもどういう状態があって どういう状態に遷移するのか考えようか。
電源をONにしたりOFFにしたりコイン入れたり何か買ったりする感じかな。 投入額については無視。めんどい。
ってことでstate_machineを使った場合のstaticな定義の書き方で書いてみると
state_machine :initial => "動いてない" do
event "電源オン" do
transition "動いてない" => "動いている"
end
event "コイン投入" do
transition "動いている" => "金入ってる!!"
end
event "ボタン押す" do
transition "金入ってる!!" => "動いている"
end
after_transition "金入ってる!!" => "動いている", :do => :sell_out
event "電源オフ" do
transition all => "動いてない"
end
end
def sell_out
puts "ほらよ。くれてやる。このいやしんぼめっ"
end
コイン投入して電源落としたら吸われてなくなる素敵仕様。 っていうか返却機能がない。気にしない。ガチャガチャと一緒だ。
あとはこの情報をDBから持ってくるから
$ bundle exec rails g model Transition event from to
って感じで適当にモデルでも作っておく。
あと地味にafter_transitionもあるので
$ bundle exec rails g model Hook timing from to do
timingは、beforeかafterかを入れるぐらい。 このあたりがあればきっとなんとか耐えられるだろう。
ってところで
$ bundle exec rails c
とか適当にやってみ……たかったけどTerminalから日本語打つのがなんかダメなので、 "transitions.rb"とかいうファイル名で
# -*- coding: utf-8 -*- Transition.create!(event: "電源オン", from: "動いてない", to: "動いている") Transition.create!(event: "コイン投入", from: "動いている", to: "金入ってる!!") Transition.create!(event: "ボタン押す", from: "金入ってる!!", to: "動いている") Transition.create!(event: "電源オフ", from: "all", to: "動いていない") Hook.create!(timing: "after", from: "金入ってる!!", to: "動いている", do: "sell_out")
とか用意して
$ bundle exec rake db:migrate
しておいて
$ bundle exec rails runner transitions.rb
でTransitionとHookの投入。
それで、次はそれらを元に動的にCoinMachineのあれこれを定義するわけだ。
その中身は大体こんな感じ。
# -*- coding: utf-8 -*-
c = CoinMachine.new
p c
c.send("電源オン")
p c
c.send("コイン投入")
p c
c.send("ボタン押す")
p c
c.send("電源オフ")
p c
で、CoinMachineモデルの方も調整。
# -*- coding: utf-8 -*-
class CoinMachine < ActiveRecord::Base
attr_accessible :state
state_machine :initial => "動いてない" do
Transition.all.each do |tr|
event tr.event do
transition (tr.from == "all" ? all : tr.from) => tr.to
end
end
# after_transition
Hook.find_all_by_timing("after").each do |hook|
after_transition hook.from => hook.to, :do => hook.do
end
end
def sell_out
puts "ほらよ"
end
end
allだけ特別扱いしているけど 基本的にTransitionのレコードからそのまんま動的にeventを書いて、 同じようにHookもやってるって感じ。
まあ https://github.com/pluginaweek/state\_machine/ の中の "Static / Dynamic definitions"の定義がごちゃごちゃしているから 適当に簡略化してみただけ。
でまあ実際に実行してみると
$ rails r state_machine_test.rb #<CoinMachine id: nil, state: "動いてない", created_at: nil, updated_at: nil> #<CoinMachine id: 6, state: "動いている", created_at: "2012-08-14 13:50:12", updated_at: "2012-08-14 13:50:12"> #<CoinMachine id: 6, state: "金入ってる!!", created_at: "2012-08-14 13:50:12", updated_at: "2012-08-14 13:50:12"> ほらよ。くれてやる。このいやしんぼめっ #<CoinMachine id: 6, state: "動いている", created_at: "2012-08-14 13:50:12", updated_at: "2012-08-14 13:50:12"> #<CoinMachine id: 6, state: "動いていない", created_at: "2012-08-14 13:50:12", updated_at: "2012-08-14 13:50:12">
で、ここでDBに入れる利点として 後からeventだの状態だのを追加できるってのがあるよね。 っていうか当初の目的それだった。
ってことでやってみる。
# -*- coding: utf-8 -*-
c = CoinMachine.new
p c
c.send("電源オン")
p c
c.send("コイン投入")
p c
c.send("ボタン押す")
p c
Transition.create!(event: "蹴る", from: "all", to: "壊れた")
c.reload
c.send("蹴る")
p c
c.send("電源オフ")
p c
ちょっとまって。 このコードを毎回実行すると、 CoinMachineが増え続けるのはともかく Transitionのレコードも増え続けるぞ。 それは良くない。 いっそ一旦全部消して、生成から削除までするようなコード書こう。
# -*- coding: utf-8 -*-
ActiveRecord::Base.transaction do
Transition.create!(event: "電源オン", from: "動いてない", to: "動いている")
Transition.create!(event: "コイン投入", from: "動いている", to: "金入ってる!!")
Transition.create!(event: "ボタン押す", from: "金入ってる!!", to: "動いている")
Transition.create!(event: "電源オフ", from: "all", to: "動いてない")
Hook.create!(timing: "after", from: "金入ってる!!", to: "動いている", do: "sell_out")
c = CoinMachine.new
p c
c.send("電源オン")
p c
c.send("コイン投入")
p c
c.send("ボタン押す")
p c
Transition.create!(event: "蹴る", from: "all", to: "壊れた")
c.reload
c.send("蹴る")
p c
c.send("電源オフ")
p c
Transition.delete_all
Hook.delete_all
CoinMachine.delete_all
end
はうう。これってもしかしてUnitTestなりRSpecなり書いた方がよくね? まあいっか。実行。 これでイベントと状態が動的に追加できますように!
% rails r state_machine_test.rb
#<CoinMachine id: nil, state: "動いてない", created_at: nil, updated_at: nil>
#<CoinMachine id: 7, state: "動いている", created_at: "2012-08-14 13:51:14", updated_at: "2012-08-14 13:51:14">
#<CoinMachine id: 7, state: "金入ってる!!", created_at: "2012-08-14 13:51:14", updated_at: "2012-08-14 13:51:14">
ほらよ。くれてやる。このいやしんぼめっ
#<CoinMachine id: 8, state: "動いている", created_at: "2012-08-14 13:52:11", updated_at: "2012-08-14 13:52:11">
/Users/mugijiru/.rvm/gems/ruby-1.9.3-p125/gems/activemodel-3.2.6/lib/active_model/attribute_methods.rb:407:in `method_missing': undefined method `蹴る' for #<CoinMachine:0x007fdedbafa728> (NoMethodError
) from /Users/mugijiru/.rvm/gems/ruby-1.9.3-p125/gems/activerecord-3.2.6/lib/active_record/attribute_methods.rb:149:in `method_missing'
from state_machine_test.rb:19:in `<top (required)>'
from /Users/mugijiru/.rvm/gems/ruby-1.9.3-p125/gems/railties-3.2.6/lib/rails/commands/runner.rb:51:in `eval'
from /Users/mugijiru/.rvm/gems/ruby-1.9.3-p125/gems/railties-3.2.6/lib/rails/commands/runner.rb:51:in `<top (required)>'
from /Users/mugijiru/.rvm/gems/ruby-1.9.3-p125/gems/railties-3.2.6/lib/rails/commands.rb:64:in `require'
from /Users/mugijiru/.rvm/gems/ruby-1.9.3-p125/gems/railties-3.2.6/lib/rails/commands.rb:64:in `<top (required)>'
from script/rails:6:in `require'
from script/rails:6:in `<main>'
うむ。やはり動的追加はできなかった。 そうだよなーRailsってClassは初めで全部読んでおっしまーいだもんなー。 だからGithubの例はあんなに複雑なんだな!
ってことで上のコードを書いて失敗したこともあって、 Githubの例についての理解が深まったので そっちに書き換えてみる。
まずはmachineメソッドを用意。
# -*- coding: utf-8 -*-
class CoinMachine < ActiveRecord::Base
attr_accessible :state, :machine
attr_writer :machine
def machine
coin_machine = self
@machine ||= Machine.new(coin_machine, initial: "動いてない") do
Transition.all.each do |tr|
event tr.event do
transition (tr.from == "all" ? all : tr.from) => tr.to
end
end
# after_transition
Hook.find_all_by_timing("after").each do |hook|
after_transition hook.from => hook.to, :do => hook.do
end
after_transition do
coin_machine.state = coin_machine.machine.state
coin_machine.save!
end
end
end
end
適当にGithubの例のと自分のコードをmergeしてみた感じ。
そしてMachineクラスを生成。 これはGithubのほぼそのままで、 hook のメソッドをとりあえずこっちに持ってきた。
class Machine
def self.new(object, *args, &block)
machine = Class.new do
def definition
self.class.state_machine
end
def sell_out
puts "ほらよ。くれてやる。このいやしんぼめっ"
end
end
machine.state_machine(*args, &block)
machine.new
end
end
で、この変更に伴いテスト用のコードも変更。
# -*- coding: utf-8 -*-
ActiveRecord::Base.transaction do
Transition.create!(event: "電源オン", from: "動いてない", to: "動いている")
Transition.create!(event: "コイン投入", from: "動いている", to: "金入ってる!!")
Transition.create!(event: "ボタン押す", from: "金入ってる!!", to: "動いている")
Transition.create!(event: "電源オフ", from: "all", to: "動いてない")
Hook.create!(timing: "after", from: "金入ってる!!", to: "動いている", do: "sell_out")
c = CoinMachine.new
p c.state
c.machine.send("電源オン")
p c.state
c.machine.send("コイン投入")
p c.state
c.machine.send("ボタン押す")
p c.state
Transition.create!(event: "蹴る", from: "all", to: "壊れた")
c.machine = nil
c.machine.send("蹴る")
p c.state
c.machine.send("電源オフ")
p c.state
Transition.delete_all
Hook.delete_all
CoinMachine.delete_all
end
ちなみにreloadじゃあダメだったのでこっそりとc.machineにnilを放り込んだりしている。
そして結果。
$ rails r state_machine_test.rb nil "動いている" "金入ってる!!" ほらよ。くれてやる。このいやしんぼめっ "動いている" "壊れた" "動いてない"
初めにnilって言われるのが若干気にはなるがまあこんなもんでいいや。

