2008-11-02

Mvc for Winforms - Mapping the View event to the Controller action Part II

This time I will try to deliver part of the answer to the requirements from my previous post. To recap I would like to be able to connect a component to a controller action by calling the RegisterAction method like below.

Controller.RegisterAction(saveButton, "Save");
Controller.RegisterAction(createBoldTextButton, "CreateText", new {name="Bold", type=4});
Controller.RegisterAction(myTextBox, "ValidateText","Validating", null);
And letting those events call the actions defined below
public void Save() {...}
public void CreateText(string name, int type) {...}
public void ValidateText(CancelEventArgs e, object source) {...}
I will divide the solution in two steps and in this post I will cover the first, that is capturing the event of the object.

We start by creating the RegisterAction method. We get the object which event should be listened to and sometimes also the name of the event that we should listen to. If this argument isn't supplied we need to find the DefaultEvent of the object.

By using reflection we can retrieve the DefaultEventAttribute of the object. The following unit test shows how to get the attribute for a Button object. Notice the true flag on the GetCustomAttributes call. Since the DefaultEventAttribute is not present on the Button class itself, we need to go down in the inheritance chain to look for the attribute. Not until we reach the Control class we find the DefaultEventAttribute.
[Test]
public void GetDefaultEventAttribute() {
object obj = new Button();
DefaultEventAttribute attribute = null;
Attribute[] attributes = obj.GetType().GetCustomAttributes(typeof(DefaultEventAttribute), true) as Attribute[];
if (attributes != null && attributes.Length > 0) {
attribute = attributes[0] as DefaultEventAttribute;
}
Assert.IsNotNull(attribute);
}
When we have the attribute, we just look at the Name property to get the name of the default event.

Now when we have the name of the event (either by parameter or using the DefaultEventAttribute) we should add listener to the event. The listener method is a method in the Controller class, not the controller Action (we will get to that in the next part), but a event hub where all the Views events will pass before they are dispatched to the correct Action. The event hub method is declared as below
public void ExecuteAction(object source, object eventArgs, ActionData actionData)
The ExecuteAction method takes three parameters. The source and arguments of the event that was fired (this is the same values that the original event passes along). The third parameter contains the data for the action that is to take place, like the name of the action and any value parameters (The values that are stated when registering the action).

So we got the object and the name of the event and the target method of the event, but how can we connect them?

My first thought was to generate a delegate to the ExecuteAction and use reflection to get the EventInfo for the event and use the AddEventHandler of the EventInfo class to bind to the ExecuteAction method.
[Test]
public void BindToButtonClickEvent() {
object obj = new Button();
EventInfo info = obj.GetType().GetEvent("Click");
MethodInfo method = GetType().GetMethod("ExecuteAction");
Delegate d = Delegate.CreateDelegate(typeof(MyExecuteAction),method);
info.AddEventHandler(obj,d);
}
private delegate void MyExecuteAction(object source, object eventArgs, ActionData actionData);
public void ExecuteAction(object source, object eventArgs, ActionData actionData) {}
But when I ran this code I got an Exception
System.ArgumentException: Error binding to target method.
Of course this won't work since the Click event cannot directly connect to the ExecuteAction method because the Click event can only add handlers that matches the EventHandler delegate, a method with a void return value and two arguments, object and EventArgs.

The conclusion of this is that if I would like to have a single method that acts as an event hub and it must be able to handle any type of delegate that the event declares (note that events as practice should always return void and take two arguments, object and a instance of an EventArgs derived class), I need to generate this method in runtime.

The first option that comes to mind is using Emit. I have tested this in the past, it has worked but comes not so natural to me. Oren Eini used this technique but since I would like to pass a local variable (the ActionData instance) in the call, I needed to modify this piece of code, and possibly use an external list of ActionData instances if I couldn't pass them along using the dynamic method, I searched a bit more for an alternative (second opinion)...

Finally I came across an answer from Mark Cidade that compiled a method in runtime using lambda expressions and that was fairly easy to modify.
First we need to setup the call to the ExecuteAction method. This is done using a lambda expression and storing it in the Action delegate.
ActionData actionData = new ActionData("Save", null);
//Create the delegate using an lambda expression
Action<object,object> eventHubCall = (source, e) => ExecuteAction(source, e, actionData);
This action will take two parameters and call the ExecuteAction just the way as we would like it to. The problem is that this expression is not typed the correct way as the event is so we need to create a new method using lambda expressions again but with the correct declaration. So we start by getting the information about the event.
Type type = obj.GetType();
EventInfo evt = type.GetEvent("Click");
ParameterInfo[] eventParams = evt.EventHandlerType.GetMethod("Invoke").GetParameters();
Then we create the lambda
ParameterExpression[] parameters = eventParams.
Select(p => Expression.Parameter(p.ParameterType, "x")).ToArray();
MethodCallExpression body = Expression.Call(Expression.Constant(eventHubCall),
eventHubCall.GetType().GetMethod("Invoke"), parameters);
LambdaExpression lambda = Expression.Lambda(body, parameters);
Now we have a method with two parameters of the correct type, the only thing left is to create a delegate that we can use for adding to the event.
Delegate proxy = Delegate.CreateDelegate(evt.EventHandlerType, lambda.Compile(), "Invoke", false);
evt.AddEventHandler(obj, proxy);
Tada!! No more Error binding to target method errors.
The complete test follows.
[Test]
public void BindToButtonClickEvent2() {
ActionData actionData = new ActionData("Save", null);
//Create the delegate using an lambda expression
Action<object,object> eventHubCall = (source, e) => ExecuteAction(source, e, actionData);

object obj = new Button();
//find the exact definition of the event
Type type = obj.GetType();
EventInfo evt = type.GetEvent("Click");
ParameterInfo[] eventParams = evt.EventHandlerType.GetMethod("Invoke").GetParameters();

//create a new lambda expression using the correct parameters
ParameterExpression[] parameters = eventParams.
Select(p => Expression.Parameter(p.ParameterType, "x")).ToArray();
//call the event hub
MethodCallExpression body = Expression.Call(Expression.Constant(eventHubCall),
eventHubCall.GetType().GetMethod("Invoke"), parameters);
//create the expression with the correct parameters to match the event
LambdaExpression lambda = Expression.Lambda(body, parameters);
//and then create the delegate that wraps the lambda expression.
Delegate proxy= Delegate.CreateDelegate(evt.EventHandlerType, lambda.Compile(), "Invoke", false);

evt.AddEventHandler(obj, proxy);
}
Not so dumb ey! All credits goes to Mark Cidade for providing the elegant solution. (So I perhaps my greatest talent is to find solutions that others have done before me, and use them ;)
Anyway, that concludes part II and next we will look how to call the action method of the controller from the ExecuteAction method.

5 comments:

  1. We can't use .NET 3.5 on our clients' boxes and I'm really interested in implementing RegisterAction method. I've tried many different ways of doing it through ILGenerator.Emit with no success. Any help is appreciated.

    Thank you

    ReplyDelete
  2. Well, regarding RegisterAction without using lambda I would use the DynamicMethod/Emit way to create the delegate and method. See Oren Einis article @ http://ayende.com/Blog/archive/2007/10/29/Dynamic-Methods.aspx

    ReplyDelete
  3. Did you ever post the last part of this framework, hot to call the action from the controller

    ReplyDelete
  4. Thanks, this helped me build a WPF/Silverlight ViewModel event listening trigger behavior to facilitate the MVVM pattern.

    ReplyDelete