Building an External DSL
Over in the Alt.net mailing list, Glenn said:
If I was really being verbose in the way a business user would expect to define this I would say
When customer is preferred and the order exceeds 1000 then apply a .05 discount and apply free shipping.
Now do that in a DSL….
Now, I am sucker for this kind of things, and I can probably use the practice. So I decided to go ahead with this. Just to be clear, this is supposedly a natural language processing, only we are going to cheat and get around that.
First, let us analyze the structure of the statement that we need to handle:
When customer is preferred and the order exceeds 1000 then apply a .05 discount and apply free shipping.
I put strike through all the fluff stuff, the things that doesn't really means anything for the language. Keywords are marked in blue, operators in red.
We have the following keywords:
- When
- And
- Then
And the following operators:
- Is
- Exceeds
- Discount
- Shipping
Everything else is a variable.
This language follows the following syntax:
When [parameter] [operator] [value] (and [parameter] [operator] [value])+ then [parameter] [value] [operator] (and [parameter] [value] [operator] )
Note that the syntax between the when clause and the then clause is different. The operator is the last word there. This is because on the when we would usually do comparisons, while on the then we will perform actions.
Now, to the actual building of the language. We have the following model:
Let us take a look at the parser, it is a stupid, single purpose one, but it works. The main parts are to extract a list of ActionExpressions from the language.
public WhenThenClause SplitToWhenThenClause(string text) { if (text.StartsWith("when", StringComparison.InvariantCultureIgnoreCase) == false) throw new InvalidOperationException("statement should start with a when"); int thenIndex = text.IndexOf("then", StringComparison.InvariantCultureIgnoreCase); if (thenIndex == -1) throw new InvalidOperationException("statement should have a then"); WhenThenClause clause = new WhenThenClause(); string whenClause = text.Substring(4, thenIndex - 4).Trim(); ParseClause(whenClause, delegate(string[] parts) { ActionExpression item = new ActionExpression(); item.Left = parts[0]; item.Operator = parts[1]; item.Right = parts[2]; clause.When.Add(item); }); string thenClause = text.Substring(thenIndex + 4); ParseClause(thenClause, delegate(string[] parts) { ActionExpression item = new ActionExpression(); item.Left = parts[0]; item.Right= parts[1]; item.Operator = parts[2]; clause.Then.Add(item); }); return clause; } private static void ParseClause(string clause, Action<string[]> action) { foreach (string subClauses in clause.Split(new string[] { "and" }, StringSplitOptions.RemoveEmptyEntries)) { if (subClauses.Trim().Length == 0) continue; string[] parts = subClauses.Split(new char[] {' ', '\t'}, StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 3) throw new InvalidOperationException("A clause should have three parts "+
"[object] [operation] [value], found: " + subClauses); action(parts); } }
Now we need to look at the DslExecuter. This is the one that will take an ActionExpression and actually execute it. We are using reflection to avoid having to do all the work ourselves, so we have this:
public class DslExecuter { private readonly Hashtable parameters = new Hashtable(StringComparer.InvariantCultureIgnoreCase); public void AddParameter(string name, object parameter) { parameters[name] = parameter; } public object Invoke(ActionExpression expression) { object obj = parameters[expression.Left]; if (obj == null) throw new InvalidOperationException("Could not find parameter with name: " + expression.Left); MethodInfo method = obj.GetType().GetMethod(expression.Operator, BindingFlags.IgnoreCase
| BindingFlags.Instance | BindingFlags.Public); if (method == null) throw new InvalidOperationException("Could not find method operator " +
expression.Operator + " on " + expression.Left); ParameterInfo[] methodParams = method.GetParameters(); if(methodParams.Length!=1) throw new InvalidOperationException(expression.Operator + " should accept a single parameter"); object converted; Type paramType = methodParams[0].ParameterType; if(paramType.IsEnum) { converted = Enum.Parse(paramType, expression.Right,true); } else { converted = Convert.ChangeType(expression.Right, paramType); } return method.Invoke(obj, new object[] {converted}); } }
Now that we have those two building blocks, it is very easy actually build the DSL itself. Here is the relevant code:
public void Execute() { bool result = true; foreach (ActionExpression expression in parsed.When) { bool clauseResult = (bool) this.executer.Invoke(expression); result &= clauseResult; } if(result) { foreach (ActionExpression expression in parsed.Then) { executer.Invoke(expression); } } }
We build a domain model to aid us here:
Basically this translate directly to the parameters that the DSL needs. This means that all we have left is just the setup of the DSL.
[Test] public void CanExecuteThenStatement() { ExternalDSLDemo dsl = new ExternalDSLDemo(sentence); Customer customer = new Customer(); customer.CustomerStatus = CustomerStatus.Preferred; dsl.AddParameter("customer", customer); Order order = new Order(); order.TotalCost = 5000; order.ShippingType = ShippingType.Fast; dsl.AddParameter("order", order); dsl.AddParameter("apply", new ApplyCommands(order)); dsl.Execute(); Assert.AreEqual(4750, order.TotalCost); Assert.AreEqual(ShippingType.Free, order.ShippingType); }
But I am pretty sure that I explained that this external DSL are very hard. This doesn't seem very hard all.
No, this external DSL is not really hard. But try to do support something like:
when (customer is preferred) and (order exceeds 1000 or order is new) then ...
This is when it gets suddenly much more complex... and why I think that External DSL are rarely worth the time to fully develop them. It is much easier to build on top of an existing infrastructure.
You can get the code for this here:
Comments
Freaking spooky Oren. I built a prototype at work for an external DLS that would allow business users to configure business rules through a GUI that looks like Outlook E-mail Rules (Don't get me started on the "why" of this - I have no influence on whether or not such a thing should be done, only I must display how it can be done). My internal code looks frighteningly similar to yours, especially the Execute() method, where first you're looping through conditions, and then actions.
Neat stuff.
I just read through the code on this (which I hadn't fully before). This is an elegant solution. I like the way you used refleciton to translate the textual expressions into actual invocations on your domain model. I am surprised if you went that far that you didn't just emit the acutal IL.
:)
Take a look at the link for how you can do something close to plain English parsing in Ruby: http://blog.davidchelimsky.net/articles/2007/10/21/story-runner-in-plain-english
Arnon
Arnon,
Yes, I saw that.
This is really nice if you want to do known value replacements, not really good for a language invocation
You might want to have a look at Eclipse openArchitectureWare (http://www.eclipse.org/gmt/oaw/) and its accompanying xText framework (http://www.eclipse.org/gmt/oaw/doc/4.2/html/contents/xtext_reference.html), It's much further advanced than anything similar you will find in the .NET space (even the DSL tools) - and available free of charge. We've been successfully using it on a couple of project so far, both with and without an associated source code generation process (using oAW xPand).
Switching to the ludic matter you can take a look at Inform 7 (http://www.inform-fiction.org/I7/Welcome.html), an interactive fiction natural English-language.
Oren,
My team has one question about this: how would you go about putting this in the hands of business users to define business rules? Or would you? I have my own opinion on this matter, but I'm curious to see what you think.
It is a matter of who your users are.
I am generally nervous about giving a user that much power, it require a lot of fore-planning
Error messages, for instance, can completely throw a user off. Then you come and put a comma in and it works.
I believe that they should be able to read it, writing it is nice, but optional.
Comment preview