In my last blog I talked about the Single Responsibility Principle. SRP it is an easy concept to understand but breaking down a system into the “correct” objects is difficult to do well. It is imprecise and there is a lot of room for opinions. You seldom know the correct breakdown until you are finished. In this blog I want to take a look at the OCP or Open/Closed Principle. The Open/Closed principle states that Software entities (classes, modules, functions etc.) should be open for extension but closed for change. (“Agile Principles, Patterns, And Practices in C#” Robert C Martin and Micah Martin). In other words this means that we would like to be able to change the behavior of existing entities without changing the code or binary assembly. This concept is not as easy to understand. Did I just say to change something without changing it?
Let’s use a configurable rule engine as an example. Assume the simple rule engine has 2 types of entities, a Condition and an Action. The Action is simple, execute some logic and return true on success or false on failure. The condition is also simple, it checks some binary condition and returns true or false. The rules engine takes a list of conditions and actions and executes them. If an Action returns false, the rules stop executing and a rollback is performed. If a condition returns true, the next action is executed. If the condition is false, the next action is skipped and the following action is executed. Pretty simple so let’s look at some sample code:
Define our Conditions, Actions, and Rule Type with enums
- public enum Actions
- {
- CreateOrder,
- CreateBackorder,
- CloseOrder,
- ShipOrder,
- StoreOrder,
- ReduceInventory
- }
- public enum Conditions
- {
- IsComplete,
- IsInStock,
- CanShip
- }
- public enum RuleType
- {
- Condition,
- Action
- \
Here is a very crude implementation of a rule engine
- public class RuleEngine
- {
- public void ExecuteRules(int rulesId)
- {
- //Gather all the conditions and actions
- //assume we have a Ruleset table that looks like this:
- //ID - Int
- //Index - Int
- //RuleType - Int (corresponds to RuleTypeEnum)
- //Rule - Int (corresponds to Conditions or Actions enum)
- //Note, transaction logic, creating the parameter
- //Error handling, and safe DataReader.Read() logic
- //has been omitted for brevity
- using (SqlConnection connection = new SqlConnection("ConnectString"))
- {
- using ( SqlCommand cmd = new SqlCommand
- ("SELECT RuleType, Rule FROM Ruleset where ID = ? ORDER BY Index"
- , connection))
- {
- SqlDataReader rules = cmd.ExecuteReader();
- bool executing = true;
- while (executing)
- {
- rules.Read();
- if ((int)rules[0] == (int)RuleType.Action)
- {
- executing = ExecuteAction((int)rules[1]);
- }
- else
- {
- if(ExecuteCondition((int)(rules[1])))
- {
- executing = true;
- }
- else
- {
- rules.Read();
- executing = true;
- }
- }
- //Implement some exit strategy here
- }
- }
- }
- }
- public bool ExecuteCondition(int theCondition)
- {
- switch (theCondition)
- {
- case (int)Conditions.IsComplete:
- //Is the order complete?
- return true; //or false
- case (int)Conditions.CanShip:
- //Can we ship the order?
- return true; //or false
- case (int)Conditions.IsInStock:
- //Is this item in stock?
- return true; //or false
- default:
- throw new Exception("Unsupported Condition");
- }
- }
- public bool ExecuteAction(int theAction)
- {
- switch (theAction)
- {
- case (int)Actions.CreateOrder:
- //Execute order create logic
- return true; //or false
- case (int)Actions.CreateBackorder:
- //Execute backorder logic
- return true; //or false
- case (int)Actions.ShipOrder:
- //Execute shipping logic
- return true; //or false
- case (int)Actions.StoreOrder:
- //send to warehouse
- return true; //or false
- case (int)Actions.CloseOrder:
- //Execute order close logic
- return true; //or false
- case (int)Actions.ReduceInventory:
- //Remove item from inventory
- return true; //or false
- default:
- throw new Exception("Unsupported Action");
- }
- }
- \
Never mind the problems with error handling, transactions, and the lack of support for nested conditions, the point of the blog is OCP, not creating a rule engine. What we have will work but does it satisfy the OCP? No, there are a couple problems with this type of implementation that will make it difficult to maintain. The first problem is that the logic for the Rule Engine, Conditions, and Actions are all in one class and therefore one assembly. Any system that wants to use any of this logic will be tied to all of this logic. The second problem is that any time you want the rule engine to do something new, you have to modify this assembly which is a violation of the Open/Closed Principle.
Let’s take a look at a more robust design.
We will still use an enum to distinguish between Actions and Conditions:
- public enum RuleType
- {
- Condition,
- Action
- \
Now let’s declare an interface
- public interface ISupportRules
- {
- public bool Execute();
- public RuleType TypeOfRule();
- \
We will put both the enum and the Interface in an assembly called Rule.Types.
Now lets add a few classes
- public class CreateOrderAction:ISupportRules
- {
- #region ISupportRules Members
- public bool Execute()
- {
- //Order Create Logic Here
- }
- public RuleType TypeOfRule()
- {
- return RuleType.Action;
- }
- #endregion
- \
We will put this in an assembly called Rule.Actions.CreateOrder.
- public class CanShipCondition:ISupportRules
- {
- #region ISupportRules Members
- public bool Execute()
- {
- //Execute Can Ship Logic here
- }
- public RuleType TypeOfRule()
- {
- return RuleType.Condition;
- }
- #endregion
- \
We will put this class in Rules.Conditions.CanShip.
In fact we will create a separate assembly for each condition and action we defined in the enums.
Here is our new rules engine which goes in the Rule.Engine assembly:
- public void ExecuteRules(List<ISupportRules> rules)
- {
- bool executing = true;
- int ruleIndex = 0;
- while (executing)
- {
- if (rules[ruleIndex].TypeOfRule() == RuleType.Action)
- {
- executing = rules[ruleIndex].Execute();
- ruleIndex++;
- }
- else
- {
- if (rules[ruleIndex].Execute())
- {
- ruleIndex++;
- executing = true;
- }
- else
- {
- ruleIndex += 2;
- executing = true;
- }
- }
- //Implement some exit strategy here
- }
- }
- \
Notice that the ExecuteRules method takes a generic list of type ISupportRules as a parameter but has no reference to any of the conditions or actions. Also notice that the condition and action classes have no reference to each other or the rules engine. This is key to both code reuse and extensibility. the refactored rule engine, condition, and action classes are completely independent of each other. All they share is a reference to Rule.Type. Some other system may use any of these assemblies independent of each other with the only caveat being they will need to reference the Rule.Type assembly. The other thing we gained with this approach is we can now Extend the rule engine (make it execute new conditions and actions) by simply adding a new Condition or Action that implements ISupportRules and passing it into the ExecuteRules method as a part of the generic list. We can do all of this without recompiling the RefactoredRulesEngine which is the goal of the OCP. By the way, this design approach is called the Strategy Pattern.
If you haven’t noticed yet I’m leaving out one major piece of the puzzle. How does the generic list of rules get generated? I’m going to wave my hands here a little bit and save the details for another blog. We would use a creational pattern (one of the factory patterns). If we assume we are consuming the table outlined in our first solution this factory would accept a Ruleset ID and magically return the generic List<IsupportRules> of rules. The implementation of the factory pattern could be written in such a way that each time you add a Condition or an Action the factory would need to be recompiled or we could use a provider pattern and use a configuration file to allow us to create these new Conditions and Actions without a recompile.
To summarize things a bit: conceptually we have this RulesEngine that is relatively complex (much more complex than I have written) and we want to write it, test it, and leave it alone. At the same time though we have this need to enhance the system by adding more rules. By using using the strategy pattern we now have this stable rule execution engine that can execute any condition or action that implements the ISupprotRules interface. Because we inject a list of conditions and rules into the ExecuteRules method we can do all of this without recompiling the refactored rules engine. Another approach we might have taken to satisfy the OCP is the Template Method pattern. In the template method pattern we would make use of an abstract class to define the skeleton of an algorithm, then allow the concrete classes to implement subclass specific operations.
One of my favorite design pattern web sites: http://www.dofactory.com/Patterns/Patterns.aspx
No comments:
Post a Comment