public
Description: Adds finite state machine behaviour to Ruby classes. Meant as an alternative to acts_as_state_machine/AASM.
Homepage:
Clone URL: git://github.com/zuk/golem_statemachine.git
README.rdoc

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

  1. A Trivial Example: The ON/OFF Switch
  2. The DSL Syntax: A Tutorial
  3. A Real-World Example: Seminar Registration
  4. Installation
  5. Gollem vs. AASM

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

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:

  class Monster
    include Golem
    define_statemachine do

    end
  end

Now to create some states (we skip the include Golem call in subsequent code, but it should still be there!):

  class Monster
    define_statemachine do
      state :HUNGRY
      state :SATIATED
    end
  end

And an event:

  class Monster
    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
    def likes?(food)
      return true if food.kind_of?(Human)
    end

    define_statemachine do
      state :HUNGRY do
        on :eat do
          transition :to => :SATIATED, :if => Proc.new{|obj, food| obj.likes?(food)}
          transition :to => :HUNGRY, :action => Proc.new{|obj| puts "BLAH!!"}
        end
      end
      state :SATIATED
    end
  end

Here the monster becomes SATIATED only if it likes the food that it has been given. The :if option (a.k.a. a ‘guard’ condition) takes a Proc 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

  food = Banana.new

  monster.eat!(food)           # ==> "BLAH!!"
  puts monster.current_state   # ==> :HUNGRY

  food = Human.find(:first)

  monster.eat!(food)
  puts monster.current_state   # ==> :SATIATED

In the interest of cleaner, prettier code, we can re-write the same definition as follows:

  class Monster
    def likes?(food)
      food.kind_of?(Human)
    end

    define_statemachine do
      state :HUNGRY do
        on :eat do
          transition :to => :SATIATED, :if => :likes?
          transition do |obj|
            puts "BLAH!!"
          end
        end
      end
      state :SATIATED
    end
  end

Note that :if (i.e. the transition guard condition) can take a Symbol instead of a Proc. Unless there’s a good reason to do so, you should probably avoid using Procs and instead refer to methods within your class definition by name. This makes it easier to keep your business logic clean and organized. Also, if a transition does not cause the object to change states (i.e. we self-transiton from HUNGRY back to HUNGRY), you can skip the :to part. Note too that you can use a block to specify a transition action (instead of using the :action option).

Finally, every state can have an enter and exit action. This can take the form of a callback (via a Symbol), a Proc, or a block:

  class Monster
    def likes?(food)
      food.kind_of?(Human)
    end

    define_statemachine do
      state :HUNGRY do
        on :eat do
          transition :to => :SATIATED, :if => :likes?
          transition do |obj|
            puts "BLAH!!"
          end
        end
      end
      state :SATIATED do
        enter do
          puts "BURP!!!"
        end
      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).

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):

  $: << File.expand_path(File.dirname(__FILE__)+"/../lib")
  require 'golem'

  class Seminar
    attr_accessor :status
    attr_accessor :students
    attr_accessor :waiting_list
    attr_accessor :max_size

    def initialize
      @students = [] # list of students enrolled in the course
      @max_class_size = 5
    end

    def seats_available?
      @students.size < @max_class_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| puts "#{student}: waiting list is closed!"
    end

    def notify_students_that_the_seminar_is_cancelled
      (@students + @waiting_list).each{|student| puts "#{student}: the seminar has been cancelled!"
    end

    include Golem

    define_statemachine do
      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 :if => :seats_available? do |seminar, student|
            seminar.students << student
          end
          transition :to => :full, :if => Proc.new{|seminar, student| not seminar.student_is_enrolled?} do |seminar, student|
            seminar.add_student_to_waiting_list(student)
          end
        end
      end

      state :full do
        on :move_to_bigger_classroom, :to => :open_for_enrollment
        on :drop_student do
          transition :to => :open_for_enrollment,
            :if => Proc.new{|seminar, student| seminar.student_is_enrolled?(student) && seminar.waiting_list_is_empty?} do
              seminar.students.delete student
            end
          transition :if => :student_is_enrolled? do |seminar, student|
            seminar.students.delete student
            seminar.enroll_student! seminar.waiting_list.shift
          end
        end
        on :enroll_student do
          transition :if => :seats_available? do |seminar, student|
            seminar.students << student
          end
          transition :action => :add_student_to_waiting_list
        end
        on :close, :to => :closed_to_enrollment
        enter :create_waiting_list
      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

      initial_state :proposed
      current_state_from :status

      on_all_transitions Proc.new{|obj, event, transition, event_args| puts "Transitioning from #{transition.from.name.inspect} to #{transition.to.name.inspect}"}
    end
  end

  s = Seminar.new
  s.schedule!
  s.open!
  s.enroll_student! "bobby"
  puts s.inspect
  s.enroll_student! "eva"
  puts s.inspect
  s.enroll_student! "sally"
  puts s.inspect
  s.enroll_student! "matt"
  puts s.inspect
  s.enroll_student! "karina"
  puts s.inspect
  s.enroll_student! "tony"
  puts s.inspect
  s.enroll_student! "rich"
  puts s.inspect
  s.enroll_student! "suzie"
  puts s.inspect
  s.enroll_student! "fred"
  puts s.inspect
  s.drop_student! "sally"
  puts s.inspect
  s.drop_student! "bobby"
  puts s.inspect
  s.drop_student! "tony"
  puts s.inspect
  s.drop_student! "rich"
  puts s.inspect
  s.drop_student! "eva"
  puts s.inspect

There are a few things to note in the above code:

  1. We use `current_state_from` to tell Golem that the current state will be stored in the `status` column (by default the state is stored in the `state` column).
  2. We log each transition by specifying a callback function for `on_all_transitions`. The Seminar object’s `log_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.

Installation

Install as a Rails plugin:

  script/plugin install git://github.com/zuk/golem_statemachine.git

If 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. If you want to store the state in a different column, use current_state_from like this:

  define_statemachine do
    current_state_from :status

    # ...
  end

For plain old Ruby classes, everything works the same way, except the state is not persisted, only stored in the object’s member variable (@state, by default).

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.