I had a requirement to create a state machine for a product at work. The product is a project management tool and therefore has the concept of a job which based on the users' actions moves from one state to another. There was also the additional requirement that the state machine should be configurable by/for different customers. Essentially, the state machine is a large flow diagram but the implementation needed to allow for completely ripping up the first customers flow diagram and replacing it with a completely different one for the next customer.
My first thought was to implement this by modelling the state machine as meta data in the database. However, even just considering the first customers' state machine, it was apparent that the number of edge cases would make this difficult and probably require code changes as soon as the product was sold to different customers, based on their own edge cases. I had already coded part of the state machine in C# so inspired by a Dot Net Rocks podcast featuring Oren Eini, I came up with the idea of coding the state machine in C#, storing the code in the database and then compiling it at runtime. However, I was further inspired by another Dot Net Rocks podcast featuring Michael Foord, talking about IronPython. Michael briefly mentioned embedding the IronPython interpreter in .NET code and on further investigation, the use cases for doing this are pretty much my requirements of being able to write different code for different customers. Michael has also written IronPython in Action which I have bought and can recommend - it helped me get my head round IronPython language and how to go about using it with C#.
From the C# prototype I created, I had a number of helper methods for doing things to manipulate my job business entity and even create a project in project server! I didn't really want to throw this away, so I didn't - I created an abstract
class and then implemented the subclass in IronPython. The abstract
class is below:
namespace Sharpcoder.BusinessLogic
{
public abstract class StateMachine
{
public StateMachine()
{
}
public abstract void Transition(Job job);
/creates a project in project server
}
As I said, the sub class is implemented in IronPython, to do this I needed to import the classes from my C# code which I will be using (such as Job
) and then subclass my StateMachine abstract
class and implement the Transition abstract
method. You will notice the reference to self in the Transition
methods parameter list which basically means that it is an instance method.
from Sharpcoder.BusinessEntities import (
Job, JobTask
)
from Sharpcoder.BusinessLogic import StateMachine
class Customer1StateMachine(StateMachine):
# Moves a job through the stage gate process.
# param job
def Transition(self, job):
return None
# Transition - End
In order to be able to use the IronPython implementation of the state machine, I needed to read in the code, essentially compile it and then keep a handle to the state machine so that I could use it at some point in the future. There is a slight performance hit in doing all of this, so I decided to use a service locator, implemented as a singleton which due to what happens on start up of the application, gets created at start up. The interesting parts of this class - the bits that create the state machine object - are shown below. Because I need to reference some of the classes from the main part of my application (not least the StateMachine
subclass), I need to load the assemblies where these are defined. I do this with the calls to runtime.LoadAssembly()
- this code could definitely be refactored to be implemented in a nicer way. The code also assumes that the IronPython code will define a class that extends the StateMachine abstract
class, instantiate an instance of this new class and then assign a reference to it, to a variable called machine
- we can then use the machine
variable to get a handle on the bespoke state machine.
namespace Sharpcoder.BusinessLogic
{
public class StateMachineLocator
{
private void InitFactory()
{
ScriptEngine engine = Python.CreateEngine();
ScriptRuntime runtime = engine.Runtime;
ScriptScope scope = engine.CreateScope();
runtime.LoadAssembly(typeof(Job).Assembly);
runtime.LoadAssembly(GetType().Assembly);
ScriptSource script =
engine.CreateScriptSourceFromString
(GetStateMachineSource(), SourceCodeKind.Statements);
code.Execute(scope);
scope.TryGetVariable<StateMachine>("machine", out _machine);
}
public StateMachine StateMachine
{
get { return _machine; }
}
}
}
GetStateMachineSource()
can be implemented however you see fit. For my prototype, I put the IronPython code in a file which I set to be an embedded resource. Eventually, I will change this to read the code from the database therefore facilitating the requirement of being able to have different state machines for different customers.
private String GetStateMachineSource()
{
Assembly assembly = Assembly.GetExecutingAssembly();
using (Stream stream =
assembly.GetManifestResourceStream("Sharpcoder.Job.Customer1StateMachine.py"))
{
using (StreamReader reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
}
At this point, we have all of the code required to create the state machine and use it in the application. The only thing left, is to flesh out the state machine so that it actually does something useful! Part of the code for my state machine is below (this is still work in progress, you will notice hard-coded user ids, etc.):
class Customer1StateMachine(StateMachine):
# Various constants
# Transitions job to state 1.007
# param job
def _Event_1007(self, job):
self.UpdateJobStage(job, self.JOB_STAGE_1_ID)
self.UpdateJobState(job, self.JOB_STATE_1007_ID)
self.UpdateJobStatus(job, self.JOB_STATUS_OPEN_AWAIT_CUST_QUAL_ID)
# Hardcoded to be steff for now, will eventually need to do
# some lookup to find the
# ???
self.CreateJobTask(job, self.JOB_TASK_TYPE_CUST_PRE_QUAL_ID, self.STEFF_USER_ID)
self.CreateProject(job)
# _Event_1007 - End
# Transitions job to state 1.010
# param job
def _TransitionToState_1010(self, job):
self.UpdateJobState(job, self.JOB_STATE_1010_ID)
self.UpdateJobStatus(job, self.JOB_STATUS_OPEN_AWAIT_QUAL_ID)
# Hardcoded to steff for now, will eventually need to do some lookup to find the
# manager
self.CreateJobTask(job, self.JOB_TASK_TYPE_CUST_INFO_ID, self.STEFF_USER_ID)
# _TransitionToState_1010 - End
# Transitions job to state 1.010
# param job
def _Event_1009(self, job):
self.TransitionToState_1010(job)
# _Event_1009 - End
# Transitions job to state 1.010
# param job
def _Event_1010(self, job):
self.UpdateJobStage(job, self.JOB_STAGE_1_ID)
self._TransitionToState_1010(job)
#self.CreateProject(job)
# _Event_1009 - End
# Performs the state transitions for a new job
# param job - The new job.
def _NewJobStateTransition(self, job):
if job.JobType.Id is self.WINDFARMS_JOB_TYPE:
self._Event_1007(job)
else:
self._Event_1010(job)
# _NewJobStateTransition - End
# Moves a job through the stage gate process.
# param job
def Transition(self, job):
if not isinstance(job, Job):
raise Exception("Transition must be called with an object of type Job")
if job.Id == 0:
self._NewJobStateTransition(job)
elif job.JobState == None:
raise Exception("Job must have a state")
# Transition - End
# Important!!! Stage Gate State Machine locator relies on the machine variable being set
machine = Customer1StateMachine()