Get to Know a Gem: Workflow

Intro

In this next series of 'Get to Know a Gem' I take a look at Workflow, a finite state machine library that can be integrated with an Active Record Model very easily. I've used this a few times and it's API is well documented and concise. More about how to integrate this gem and other options for a finite state machine can be found in this Railscast.

Workflow, although simple to implement, is quite sophisticated behind the scenes. After going through the code, it's clear that this sophistication isn't necessitated by any requirements of the features, rather by care of the author to create a well designed and maintanable gem. Additionally, the tests for this gem are outstanding and provides a great example of testing code that interacts with a database/active record

Overview

Workflow has several components in its function that are all incorporated by the main Workflow module that must be included into a class for everything to work. In this post I cover all the pieces of Workflow that fulfill the main functions of declaring states, transitions, and callbacks all within the context of an ActiveRecord model. The gem does have a graphing feature to visually display states and also has support for other ORM's but I'm leaving that out and focusing on the state-machine feature. Additionally, Workflow also has support for Inheritance but for the purposes of explanation I ignore that code. Essentialy, Workflow replaces a subclasses Workflow implementation with that of the superclass and there is code in various places to handle such cases.

From implementation to the internals

To integrate workflow into your project you define transitions and states. For example,

class Order < ActiveRecord::Base
  include Workflow
  workflow do
    state :submitted do
      event :accept, :transitions_to => :accepted      
    end
    state :accepted do
      event :ship, :transitions_to => :shipped
    end
    state :shipped
  end
end

Here the class Order now expects to have three permissable states and two transitions. Workflow begins to work it's magic with the include Workflow statement. Once included Workflow proceeds to do the following:

def self.included(klass)
  klass.send :include, InstanceMethods

  #Inheritance handling code ommited
  
  klass.extend ClassMethods

  if Object.const_defined?(:ActiveRecord)
    if klass < ActiveRecord::Base
      klass.send :include, Adapter::ActiveRecord::InstanceMethods
      klass.send :extend, Adapter::ActiveRecord::Scopes
      klass.before_validation :write_initial_state
    end
  elsif Object.const_defined?(:Remodel)
    if klass < Adapter::Remodel::Entity
      klass.send :include, Remodel::InstanceMethods
    end
  end
 	end

Here Worklow includes InstanceMethods the module for all the state transition events on any instance. Skipping over the code for handling inheritence, the method then extends ClassMethods the module responsible for understanding a classes Workflow implementation and subsequently defining all the necessary instance and class methods. Workflow then checks for whether the class including Workflow is implementing ActiveRecord. If it is the Class is then forced to include two ActiveRecord Adapter Modules. Finally, a before_validation callback is also implemented into the class that is responsible for setting the initial state so that it doesn't need to be manually defined.

Delving in deeper from here, the ClassMethods module is the best place to start. When a class uses Workflow and defines the states with the workflow do block a method in ClassMethods is called:

def workflow(&specification)
  assign_workflow Specification.new(Hash.new, &specification)
end

This method acts as the public interface to Workflow that takes the workflow block and passes it to a new Specification objet. The Specification class is responsible for maintaining the data of all the states and any callback methods. Specification keeps an array, @states, which is a collection of State objects. The State class holds the necessary data for each individual state. Each State object holds a collection of Event objects. As the name reflects, the Event class maintains the data for transitons between states.

Below Workflow

The code for how Specification processes the passed-in block is smartly crafted to be flexible to any number of states and options, beginning with initialize:

def initialize(meta = {}, &specification)
  puts "initializing workflow specification"
  @states = Hash.new
  @meta = meta
  instance_eval(&specification)
end

The use of instance_eval really stood out to me as it cleverly leverages the work the implementor has already done specifying workflow:

workflow do state :open do event :close do |args| end end
end

Specification has a method state and event that are thus triggered by an instance_eval of this block within a Specification object. state, called first, looks like this:

def state(name, meta = {:meta => {}}, &events_and_etc)
  # meta[:meta] to keep the API consistent..., gah
  new_state = Workflow::State.new(name, self, meta[:meta])
  @initial_state = new_state if @states.empty?
  @states[name.to_sym] = new_state
  @scoped_state = new_state
  instance_eval(&events_and_etc) if events_and_etc
end

After initializing the State object, Specification then sets the instance variable of @scoped_state to the current class in anticipation of the state's block being run. The instance_eval(&events_and_etc) then would trigger the event method:

def event(name, args = {}, &action)
  target = args[:transitions_to] || args[:transition_to]

  raise WorkflowDefinitionError.new(
    "missing ':transitions_to' in workflow event definition for '#{name}'") \
    if target.nil?
  @scoped_state.events[name.to_sym] =
    Workflow::Event.new(name, target, (args[:meta] or {}), &action)
end

This method is essentially responsible for adding an Event object to @scoped_state's list of events.

Return to Workflow

So after Workflow::ClassMethods hands off the workflow block to Specification it then assigns the returned object to @workflow_spec and proceeds to take the formatted data of Specification to build methods onto the class that initially included Workflow

def assign_workflow(specification_object)

  #Omitted inheritance code 
  
  @workflow_spec = specification_object

  @workflow_spec.states.values.each do |state|
    state_name = state.name
    module_eval do
      define_method "#{state_name}?" do
        state_name == current_state.name
      end
    end

    state.events.values.each do |event|
      event_name = event.name
      module_eval do
        define_method "#{event_name}!".to_sym do |*args|
          process_event!(event_name, *args)
        end

        define_method "can_#{event_name}?" do
          return self.current_state.events.include?(event_name)
        end
      end
    end
  end
end

The code here is straightforward as to what it's accomplishing, definining instance methods to a class that allow for the transition method event! along with instrospective methods to check whether a certain state is active or event is possible. What needs to be looked at further is process_event.

The process_event method is responsible for transitioning between states and is where the main logic for Workflow occurs after initialization. It is a fairly lengthy method:

def process_event!(name, *args)
  event = current_state.events[name.to_sym]
  raise NoTransitionAllowed.new(
    "There is no event #{name.to_sym} defined for the #{current_state} state") \
    if event.nil?
  @halted_because = nil
  @halted = false

  #Checks that the state that its transitioning to is actually a permitted state                                                    
  check_transition(event)

  from = current_state
  to = spec.states[event.transitions_to]
  #Run before transition
  run_before_transition(from, to, name, *args)
  return false if @halted

  begin
    return_value = run_action(event.action, *args) || run_action_callback(event.name, *args)
  rescue Exception => e
    run_on_error(e, from, to, name, *args)
  end

  return false if @halted

  run_on_transition(from, to, name, *args)

  run_on_exit(from, to, name, *args)

  transition_value = persist_workflow_state to.to_s

  run_on_entry(to, from, name, *args)

  run_after_transition(from, to, name, *args)

  return_value.nil? ? transition_value : return_value
end

This method runs similar logic to an ActiveRecord action as it allows for callbacks to be messaged before and after a transition occurs. The transition itself is conducted at ` transition_value = persist_workflow_state to.to_s. Above and below that line Workflow calls all its available callbacks with the the from and to state and any other arguments. The numerous callbacks are all invoked in a similar fashion using instance_exec. For example the run_action` method:

def run_action(action, *args)
  instance_exec(*args, &action) if action
end

One other nice feature of Workflow::InstanceMethods that's on display in process_event is the @halted instance variable. The Workflow API allows for the method halt! or halt to be called in any transition or callback. The halt! method simply sets the @halted flag to true and raises an error:

def halt!(reason = nil)
  @halted_because = reason
  @halted = true
  raise TransitionHalted.new(reason)
end

The alternative method halt does the same except it doesn't raise a the TransitionHalted exception, thus checks are made to @halted in process_event in case @halted is ever true. I find this technique of error handling simple in it's implementation but wonderfully extensible in its implementation. A user of Workflow using callbacks or transitions can thus add extensive logic on his/her end and call halt! at any time to stop the entire transaction. I imagine the author of Workflow took inspiration or example for ActiveRecord callbacks.

The entire process_event is an artful demonstration of the power behind Ruby blocs and metaprogramming. Workflow remains decoupled form implementation details that the user may have and simply relies on the convention of its API.

My own of Workflow has been limited to simply defining states and transiton events. Therefore, I didn't expect Workflow to be as feature rich as I found it to be. Despite the numerous features beyond simple state mechanics, such as callbacks, meta information, graphing, etc, Workflow remains thoroughly clear in its code and intent. The heavy use of blocs and instance_eval or instance_exec was a profound lesson for me in applied metaprogramming that went beyond method_missing. After going through the code of Workflow, the logic behind the state mechanics itself is quite simple, but what really stands out is the way in which Workflow is organized and the Object Oriented approach taken to implementing states and events.

Conclusion

It was a real journey and pleasure going through the code of Workflow. I learned a lot about crafting a DSL that is simple but allows for great expansion. The code is confident in its ability to handle any implementation of Workflow that sticks to the few points of convention, and is clean in its error handling. Overall, it's a great example of how to develop a module that provides a DSL, interacts with the Db/ActiveRecord, and makes use of metaprogramming for defining both class and instance methods.

There is a lot more to Workflow than what is covered in this post, and I recommend taking a look at the code.

Next up: Annotate

About Me

I'm Ruby Developer working in the Bay Area with previous experience in C#, and Java. I have a particular interest in software design principles, and metaprogramming.

  • kavinderd@gmail.com
  • RSS Feed
comments powered by Disqus