Skip to content

Adds finite state machine behaviour to Ruby classes. Meant as an alternative to acts_as_state_machine/AASM.

License

Notifications You must be signed in to change notification settings

zuk/golem_statemachine

Repository files navigation

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

  1. {Installation}[https://github.com/zuk/golem_statemachine#1-installation]

  2. {A Trivial Example: The ON/OFF Switch}[https://github.com/zuk/golem_statemachine#2-a-trivial-example-the-onoff-switch]

  3. {The DSL Syntax: A Tutorial}[https://github.com/zuk/golem_statemachine#3-the-dsl-syntax-a-tutorial]

  4. {Using Golem with ActiveRecord}[https://github.com/zuk/golem_statemachine#4-using-golem-with-activerecord]

  5. {A Real-World Example: Seminar Registration}[https://github.com/zuk/golem_statemachine#5-a-real-world-example-seminar-registration]

  6. {Multiple Statemachines in the Same Class/Model}[https://github.com/zuk/golem_statemachine#6-multiple-statemachines-in-the-same-classmodel]

  7. {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:

  1. 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 the state_attribute declaration.

  2. When an event is fired, upon completion the save or save! method is automatically called (save if you call the regular event trigger, and save! if you use the exclamation trigger: e.g. open and open! respectively).

  3. 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’s errors collection if transition errors occured during event calls.

  4. 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 just open), 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:

  1. 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).

  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.

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.

About

Adds finite state machine behaviour to Ruby classes. Meant as an alternative to acts_as_state_machine/AASM.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages