Golem Statemachine
Golem adds Finite State Machine (FSM) behaviour to Ruby classes. Basically, you get a nice DSL (domain-specific language) for defining the FSM rules, and some functionality to enforce those rules in your objects. Although Golem was designed specifically with ActiveRecord in mind, it should work with any Ruby object.
The Finite State Machine pattern has many potential uses, but in practice you’ll probably find it most useful in implementing complex business logic – the kind that requires multi-page UML diagrams describing an entity’s behavior over a series of events. Golem makes it much easier to implement and keep track of complicated, stateful behaviour, and the DSL you use to define your state machine in Ruby is specifically designed to make translation to and from UML easy.
Contents
-
Installation
-
A Trivial Example: The ON/OFF Switch
-
The DSL Syntax: A Tutorial
-
Using Golem with ActiveRecord
-
A Real-World Example: Seminar Registration
-
Multiple Statemachines in the Same Class/Model
-
Gollem vs. AASM
1. Installation
Install as a Gem:
gem install golem_statemachine
Then, if you’re using Rails 2.3.x, in your environment.rb:
config.gem 'golem_statemachine', :lib => 'golem'
And if you’re using Rails 3.x, add it to your Gemfile:
gem 'golem_statemachine', :require => 'golem'
Or, install as a Rails plugin:
script/plugin install git://github.com/zuk/golem_statemachine.git
If you’re using Golem in an ActiveRecord model:
class Example < ActiveRecord::Base
include Golem
define_statemachine do
# ... write your statemachine definition ...
end
end
Also make sure that the underlying SQL table has a state
column of type string
(varchar). If you want to store the state in a different column, use state_attribute
like this:
define_statemachine do
state_attribute :status
# ...
end
For plain old Ruby classes, everything works the same way, except the state is not persisted, only stored in the object’s instance variable (@state
, by default).
2. A Trivial Example: The ON/OFF Switch
A light switch is initially in an “off” state. When you flip the switch, it transitions to an “on” state. A subsequent “flip switch” event returns it back to an off state.
Here’s the UML state machine diagram of an on/off switch:
And here’s what this looks like in Ruby code using Golem:
require 'golem'
class LightSwitch
include Golem
define_statemachine do
initial_state :OFF
state :OFF do
on :flip_switch, :to => :ON
end
state :ON do
on :flip_switch, :to => :OFF
end
end
end
switch = LightSwitch.new
puts switch.current_state # ==> :OFF
switch.flip_switch
puts switch.current_state # ==> :ON
switch.flip_switch
puts switch.current_state # ==> :OFF
3. The DSL Syntax: A Tutorial
To define a statemachine (inside a Ruby class definition, after including the Golem module), place your definition inside the define_statemachine
block:
require 'golem'
class Monster
include Golem
define_statemachine do
end
end
Now to create some states:
class Monster
include Golem
define_statemachine do
initial_state :HUNGRY
state :HUNGRY
state :SATIATED
end
end
And an event:
class Monster
include Golem
define_statemachine do
state :HUNGRY do
on :eat, :to => :SATIATED
end
state :SATIATED
end
end
The block for each state describes what will happen when a given event occurs. In this case, if the monster is in the HUNGRY
state and the eat
event occurs, the monster becomes SATIATED
.
Now to make things a bit more interesting:
class Monster
include Golem
attr_accessor :state
def initialize(name)
@name = name
end
def to_s
@name
end
def likes?(food)
food.kind_of?(String)
end
define_statemachine do
initial_state :HUNGRY
state :HUNGRY do
on :eat do
transition :to => :SATIATED do
guard do |monster, food|
monster.likes?(food)
end
end
transition :to => :HUNGRY do
action do |monster|
puts "#{monster} says BLAH!!"
end
end
end
end
state :SATIATED
end
end
Here the monster becomes SATIATED
only if it likes the food that it has been given. The guard
condition takes a block of code that checks whether the monster likes the food. To better illustrate how this works, here’s how we would use our Monster statemachine:
monster = Monster.new("Stringosaurus")
monster.eat(12345) # ==> "Stringosaurus says BLAH!!"
puts monster.state # ==> "HUNGRY"
monster.eat("abcde")
puts monster.state # ==> "SATIATED"
Finally, every state can have an enter
and exit
action that will be executed whenever that state is entered or exited. This can be a block, a callback method (as a Symbol), or a Proc/lambda. Also, in the interest of leaner code, we rewrite things using more compact syntax:
class Monster
include Golem
def initialize(name)
@name = name
end
def to_s
@name
end
def likes?(food)
food.kind_of?(String)
end
define_statemachine do
initial_state :HUNGRY
state :HUNGRY do
on :eat do
transition :to => :SATIATED, :if => :likes?
transition :to => :HUNGRY do
action {|monster| puts "#{monster} says BLAH!!"}
end
end
end
state :SATIATED do
enter {|monster| puts "#{monster} says BURP!!"}
end
end
end
For a full list of commands available inside the define_statemachine
block, have a look at the code in golem/dsl
(starting with golem/dsl/state_machine_def.rb
).
4. Using Golem with ActiveRecord
When you include Golem in an ActiveRecord class, several AR-specific functions are automatically enabled:
-
State changes are automatically saved to the database. By default it is expected that your ActiveRecord model has a
state
column, although you can change the column where the state is stored using thestate_attribute
declaration. -
When an event is fired, upon completion the
save
orsave!
method is automatically called (save
if you call the regular event trigger, andsave!
if you use the exclamation trigger: e.g.open
andopen!
respectively). -
When using the regular event trigger, any transition errors are recorded and checked during record validation, so that calling
valid?
will add to the record’serrors
collection if transition errors occured during event calls. -
Event triggers that result in successful transitions return true; unsuccessful triggers return false (similar to the behaviour of ActiveRecord’s
save
method. If using the exclamation triggers (e.g.open!
rather than justopen
), a Golem::ImpossibleEvent exception is raised on transition failure. (This last functionality is true whether you’re using ActiveRecord or not, but it is meant to be useful in the context of standard ActiveRecord usage.)
5. A Real-World Example: Seminar Registration
Monsters and On/Off switches are all well end good, but once you get your head around how a finite state machine works, you’ll probably want to do something a little more useful. Here’s an example of a course registration system, adapted from Scott W. Ambler’s primer on UML2 State Machine Diagrams:
The UML state machine diagram:
The Ruby implementation (see blow for discussion):
require 'golem'
class Seminar
attr_accessor :status
attr_accessor :students
attr_accessor :waiting_list
attr_accessor :max_class_size
attr_accessor :notifications_sent
@@out = STDOUT
def self.output=(output)
@@out = output
end
def initialize
@students = [] # list of students enrolled in the course
@max_class_size = 5
@notifications_sent = []
end
def seats_available
@max_class_size - @students.size
end
def waiting_list_is_empty?
@waiting_list.empty?
end
def student_is_enrolled?(student)
@students.include? student
end
def add_student_to_waiting_list(student)
@waiting_list << student
end
def create_waiting_list
@waiting_list = []
end
def notify_waiting_list_that_enrollment_is_closed
@waiting_list.each{|student| self.notifications_sent << "#{student}: waiting list is closed"}
end
def notify_students_that_the_seminar_is_cancelled
(@students + @waiting_list).each{|student| self.notifications_sent << "#{student}: the seminar has been cancelled"}
end
include Golem
define_statemachine do
initial_state :proposed
state_attribute :status
state :proposed do
on :schedule, :to => :scheduled
end
state :scheduled do
on :open, :to => :open_for_enrollment
end
state :open_for_enrollment do
on :close, :to => :closed_to_enrollment
on :enroll_student do
transition do
guard {|seminar, student| !seminar.student_is_enrolled?(student) && seminar.seats_available > 1 }
action {|seminar, student| seminar.students << student}
end
transition :to => :full do
guard {|seminar, student| !seminar.student_is_enrolled?(student) }
action do |seminar, student|
seminar.create_waiting_list
if seminar.seats_available == 1
seminar.students << student
else
seminar.add_student_to_waiting_list(student)
end
end
end
end
on :drop_student do
transition :if => :student_is_enrolled? do
action {|seminar, student| seminar.students.delete student}
end
end
end
state :full do
on :move_to_bigger_classroom, :to => :open_for_enrollment,
:action => Proc.new{|seminar, additional_seats| seminar.max_class_size += additional_seats}
# Note that this :if condition applies to all transitions inside the event, in addition to each
# transaction's own :if/guard statement.
on :drop_student, :if => :student_is_enrolled? do
transition :to => :open_for_enrollment, :if => :waiting_list_is_empty? do
action {|seminar, student| seminar.students.delete student}
end
transition do
action do |seminar, student|
seminar.students.delete student
seminar.enroll_student seminar.waiting_list.shift
end
end
end
on :enroll_student, :if => Proc.new{|seminar, student| !seminar.student_is_enrolled?(student)} do
transition do
guard {|seminar, student| seminar.seats_available > 0}
action {|seminar, student| seminar.students << student}
end
transition :action => :add_student_to_waiting_list
end
on :close, :to => :closed_to_enrollment
end
state :closed_to_enrollment do
enter :notify_waiting_list_that_enrollment_is_closed
end
state :cancelled do
enter :notify_students_that_the_seminar_is_cancelled
end
# The 'cancel' event can occur in all states.
all_states.each do |state|
state.on :cancel, :to => :cancelled
end
on_all_transitions do |seminar, event, transition, *event_args|
@@out.puts "==[#{event.name}(#{event_args.collect{|arg| arg.inspect}.join(",")})]==> #{transition.from.name} --> #{transition.to.name}"
@@out.puts " ENROLLED: #{seminar.students.inspect}"
@@out.puts " WAITING: #{seminar.waiting_list.inspect}"
end
end
end
s = Seminar.new
s.schedule!
s.open!
puts s.status # ====> "open_for_enrollment"
s.enroll_student! "bobby"
s.enroll_student! "eva"
s.enroll_student! "sally"
s.enroll_student! "matt"
s.enroll_student! "karina"
s.enroll_student! "tony"
s.enroll_student! "rich"
s.enroll_student! "suzie"
s.enroll_student! "fred"
puts s.status # ====> "full"
s.drop_student! "sally"
s.drop_student! "bobby"
s.drop_student! "tony"
s.drop_student! "rich"
s.drop_student! "eva"
puts s.status # ====> "open_for_enrollment"
There are a few things to note in the above code:
-
We use
state_attribute
to tell Golem that the current state will be stored in the@status
instance variable (by default the state is stored in the@state
variable). -
We log each transition by specifying a callback function for
on_all_transitions
. The Seminar object’slog_transition
method will be called on each successful transition. The Event that caused the transition, and the Transition itself are automatically passed as the first two arguments to the callback, along with any other arguments that may have been passed in the event trigger.
6. Multiple Statemachines in the Same Class/Model
It’s possible to define multiple statemachines in the same class:
class Foo
include Golem
define_statemachine(:mouth) do
# ...
end
define_statemachine(:eye) do
# ...
end
end
In this case the state of the “mouth” statemachine can be retrieved using mouth_state
and of the “eye” using nose_state
. You can override the names of these state attributes as usual using state_attribute
declarations under each statemachine.
Event triggers are shared across statemachines, so if both of your statemachines define an event called “open”, triggering an “open” event on an instance of the class will trigger the event for both statemachines.
For an example of a class with two statemachines see examples/monster.rb
.
7. Golem vs. AASM
There is already another popular FSM implementation for Ruby – rubyist’s AASM (also known as acts_as_state_machine). Golem was developed from scratch as an alternative to AASM, with the intention of a better DSL and cleaner, easier to read code.
Golem’s DSL is centered around States rather than Events; this makes Golem statemachines easier to visualize in UML (and vice-versa). Golem’s DSL also implements the decision pseudostate (a concept taken from UML), making complicated business logic easier to implement.
Golem’s code is also more modular and more consistent, which will hopefully make extending the DSL easier.