From: Chuck Remes Date: 2011-06-14T05:25:15+09:00 Subject: Re: state_machine gem On Jun 12, 2011, at 2:06 PM, Chuck Remes wrote: > I'm working on a somewhat complex project that really begs for a state machine to keep the logic all straight. I've been playing with the state_machine [1] gem the last several days to see if it's suitable. I like the syntax and feature set, but I'm having difficulty mapping its capabilities into code. > > The main problem has to do with the relationship between state machines and classes. While a class can have 1 (or more) state machines declared inside of it, a state machine cannot be used across 1 (or more) *classes.* > > If I am correct about that limitation, I am wondering how to structure a large and complex state machine. I like to keep my classes small and simple so that they are easy to understand and debug. In the case where a class has many states and events, the business logic attached to the transitions (in my case) are rather complex. The size of the code in the class very quickly balloons (a few hundred lines). I figured out how to do this. I had to move from the state_machine gem to the statemachine gem (note the lack of underscore in the second one). It has support for a "class context" in which all state actions are executed. It's actually pretty easy to change the context while the machine is running to get new or different behavior from another business logic class. Here's a code example. This example also contains some code to show how to register for events in one context class (as a closure) but still have the event delivered to the correct context class if it changes. require 'rubygems' require 'statemachine' class Registered def on_event &blk @block = blk end def call @block.call end end class Startup attr_accessor :statemachine attr_reader :block def do_init() register_events statemachine.process_event(:started) end def change_context fire puts "change context for next state" statemachine.context = Connected.new(statemachine, @registered_events) end def register_events @registered_events = Registered.new @registered_events.on_event { statemachine.process_event(:data_error) } end def fire @registered_events.call end def on_error puts "error in #{self.class}" end end class Connected attr_accessor :statemachine attr_reader :block def initialize sm, registered @statemachine = sm @registered_events = registered @once = true end def initial_gw_logon fire puts "entered connected" if @once puts "attempting GW logon" @once = false end end def enable_order_modification puts "enable order modification" end def enable_order_entry puts "enable order entry" end def disable_order_entry puts "disable order entry" end def disable_orders puts "disable orders" end def fire @registered_events.call end def on_error puts "#{self.class}#on_error" end end # Connected connection_machine = Statemachine.build do startstate :operational superstate :operational do # we don't set a context within this builder block, so using :starting # as the startstate causes an error; it immediately transitions to :starting # and tries to run #on_init which doesn't exist anyplace yet (no context) startstate :waiting trans :waiting, :startup, :starting event :data_error, :error_mode state :starting do on_entry :do_init event :started, :connected, :change_context end superstate :connected do startstate :gateway_down on_entry :initial_gw_logon state :gateway_down do event :dc_down, :gateway_down event :dc_up, :gateway_down event :gw_down, :gateway_down event :gw_up, :gateway_up end state :gateway_up do on_entry :enable_order_modification on_exit :disable_orders event :dc_down, :gateway_up, :disable_order_entry event :dc_up, :gateway_up, :enable_order_entry event :gw_up, :gateway_up event :gw_down, :gateway_down end end # superstate connected end # superstate operational state :error_mode do on_entry :on_error event :operate, :operational_H, Proc.new { puts "exiting error mode"} event :gw_up, :error_mode end end startup = Startup.new connection_machine.context = startup connection_machine.context.statemachine = connection_machine # after using the business logic in Startup, it switches the machine over to using # the business logic in the Connected class connection_machine.process_event(:startup) connection_machine.process_event(:gw_up) This produces: Event: startup exiting 'waiting' state entering 'starting' state Event: started exiting 'starting' state Event: data_error exiting 'starting' state exiting 'operational' superstate entering 'error_mode' state error in Startup change context for next state entering 'connected' superstate Event: data_error exiting 'gateway_down' state exiting 'connected' superstate exiting 'operational' superstate entering 'error_mode' state Connected#on_error entered connected attempting GW logon entering 'gateway_down' state Event: gw_up