Golem Statemachine¶ ↑
Golem adds Finite State Machine (FSM) behaviour to Ruby classes. Basically, you get a nice DSL 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’s DSL is specifically designed to have close correspondence with UML diagrams. Golem also includes the ability to automatically generate UML sequence diagrams from statemachines using GraphViz.
Contents¶ ↑
-
{Installation}[https://github.com/zuk/golem_statemachine#1-installation]
-
{A Trivial Example: The ON/OFF Switch}[https://github.com/zuk/golem_statemachine#2-a-trivial-example-the-onoff-switch]
-
{The DSL Syntax: A Tutorial}[https://github.com/zuk/golem_statemachine#3-the-dsl-syntax-a-tutorial]
-
{Using Golem with ActiveRecord}[https://github.com/zuk/golem_statemachine#4-using-golem-with-activerecord]
-
{A Real-World Example: Seminar Registration}[https://github.com/zuk/golem_statemachine#5-a-real-world-example-seminar-registration]
-
{Multiple Statemachines in the Same Class/Model}[https://github.com/zuk/golem_statemachine#6-multiple-statemachines-in-the-same-classmodel]
-
{Gollem vs. AASM}[https://github.com/zuk/golem_statemachine#7-golem-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.