Friday, April 8, 2011

Jounce Part 15: Asynchronous Sequential Workflows

One common complexity developers face when writing Silverlight applications is the fact that most operations are forced to asynchronous. When using the Asynchronous Programming Model (APM) or an event-driven model, these operations force the developer to consider the "chain of events" and provide the right code to correctly manage the sequences. Microsoft will address the issue with the async CTP and there is always the Reactive Extensions (Rx) library. Jounce provides a very simple and lightweight solution to fill the gap between what is readily available and what you will receive from future libraries and updates.

The Jounce support for asynchronous workflows uses the concept of a "coroutine." To be completely transparent, this is a technique that I learned from Rob Eisenberg and is available in his Caliburn.Micro framework. This is a more simplified version that is intended to be extremely lightweight and specifically address the scenario of handling sequential workflows with asynchronous steps.

The understand how it works, take a look at the IWorkflow interface.

public interface IWorkflow
{
    void Invoke();
    Action Invoked { get; set; }
}

It's a very straightforward interface. The invoke method is called to start the process. When the process is finished, invoked is called. That process from start to finish is a single asynchronous step.

To manage the workflow, Jounce relies on the fact that iterators in C# function as state machines. You can access this state machine through the enumerator. In order to create a workflow, you chain together a sequence of IWorkflow nodes using an enumerable method. A more detailed explanation of how this works can be read in my coroutines blog post, but the net process ends up looking like this (taken directly from a quick start included with the Jounce source):

private IEnumerable<IWorkflow> Workflow()
{
    Button.Visibility = Visibility.Visible;
    Text.Text = "Initializing...";
    Button.Content = "Not Yet";
    Button.IsEnabled = false;

    yield return new WorkflowDelay(TimeSpan.FromSeconds(2));

    Button.IsEnabled = true;
    Text.Text = "First Click";
    Button.Content = "Click Me!";

    yield return new WorkflowRoutedEvent(() => { }, h => Button.Click += h, h => Button.Click -= h);

    Text.Text = "Now we'll start counting!";
    Button.Content = "Click Me!";

    yield return new WorkflowRoutedEvent(() => { }, h => Button.Click += h, h => Button.Click -= h);

    Button.IsEnabled = false;
    Button.Content = "Counting...";

    yield return new WorkflowBackgroundWorker(_BackgroundWork, _BackgroundProgress);

    Text.Text = "We're Done.";
    Button.Visibility = Visibility.Collapsed;
}

As you can see, several events are happening in a sequential fashion. Some of those events are asynchronous, such as time delays and events. It is all placed in a nice, sequential method without resorting to multiple method calls to chain the events or nested lambda expressions. The biggest benefit here isn't performance, but readability and maintainability of your code.

The whole process is kicked off using the workflow controller, like this:

WorkflowController.Begin(Workflow(),
                            ex => JounceHelper.ExecuteOnUI(() => MessageBox.Show(ex.Message)));

In this case the workflow is begun and any errors are issued using the message box command. You can of course trap errors and handle them as you see fit in your workflow. The quick start includes a text box and some buttons to show that the UI thread is not blocked while the work flow executes. It is a long running task and can even be used to handle navigation or state transitions that occur over multiple screens and input cycles. The state is all maintained by the iterator.

To make workflows easy to build, Jounce provides a few "out of the box" implementations.

Delay Workflow

The delay workflow is perhaps the easiest to use to understand the pattern. The controller will call "invoke" and you are responsible for calling "invoked" when your work is done. Take a look at the delay:

public class WorkflowDelay : IWorkflow 
{
    private readonly DispatcherTimer _timer;

    public WorkflowDelay(TimeSpan interval)
    {
        if (interval <= TimeSpan.Zero)
        {
            throw new ArgumentOutOfRangeException("interval");
        }

        _timer = new DispatcherTimer {Interval = interval};
    }

    void TimerTick(object sender, EventArgs e)
    {
        _timer.Stop();
        _timer.Tick -= TimerTick;
        Invoked();
    }

    public void Invoke()
    {
        _timer.Tick += TimerTick;            
        _timer.Start();
    }

    public Action Invoked { get; set; }        
}

As you can see, in this case the workflow simply starts a timer. When the timer ticks, it is shut down and invoked is called. Yielding one of these in your workflow simply creates a delay. This is useful, for example, when you have "toasts" or notifications that you want to show for a minimum period of time, or during testing when you want to simulate network delays.

Event Workflow

Another workflow encapsulates events. The invoke method registers for the event, and invoked is called once the event is fired. The constructor takes in delegates to hook and unhook the handler so it can write itself out of the equation when the event is done.

public class WorkflowEvent : IWorkflow 
{
    private readonly Action _begin;

    private readonly EventHandler _handler;

    private readonly Action<EventHandler> _unregister;
        
    public WorkflowEvent(Action begin, Action<EventHandler> register, Action<EventHandler> unregister)
    {
        _begin = begin;
        _unregister = unregister;
        _handler = Completed;
        register(_handler);            
    }

    public void Completed(object sender, EventArgs args)
    {
        Result = args;
        _unregister(_handler);
        Invoked();
    }

    public EventArgs Result { get; private set; }

    public void Invoke()
    {
        _begin();
    }

    public Action Invoked { get; set; }
}

public class WorkflowEvent<T> : IWorkflow where T: EventArgs
{
    private readonly Action _begin;

    private readonly EventHandler<T> _handler;

    private readonly Action<EventHandler<T>> _unregister;

    public WorkflowEvent(Action begin, Action<EventHandler<T>> register, Action<EventHandler<T>> unregister)
    {
        _begin = begin;
        _unregister = unregister;
        _handler = Completed;
        register(_handler);
    }

    public void Completed(object sender, T args)
    {
        Result = args;
        _unregister(_handler);
        Invoked();
    }

    public T Result { get; private set; }

    public void Invoke()
    {
        _begin();
    }

    public Action Invoked { get; set; }
}

There is also a routed event version that does that same. If you need to wait for a control to be loaded, for example, you can yield a routed workflow and hook/unhook into the loaded event. This line from the quick start shows registering and waiting for a click before continuing:

yield return new WorkflowRoutedEvent(() => { }, h => Button.Click += h, h => Button.Click -= h);

Background Worker

You can yield a background worker workflow to start a background worker and wait until it is finished before continuing. The class has support for reporting progress. Again, from the quickstart, you can simply pass what method is called to do the work and what method is called to report progress:

yield return new WorkflowBackgroundWorker(_BackgroundWork, _BackgroundProgress);

Action Workflow

By far the most flexible workflow is the action workflow. A great example of how to use this is when dealing with callbacks. I like to simplify asynchronous calls using action when dealing with a service layer. This lets me easily manage what happens when the service call is complete without using any awkward event or APM code. During testing, it is also easy to mock the callback and not have to bring in additional overhead. What does a service call look like using the workflow model?

Consider a service method that has this signature:

void DoSomethingService(string parameter, Action<Exception,string> callback);

The method initiates an action, taking in a parameter. When completed, you are called back with an exception (only if one occurred) and a result as a string. To wire this up in a workflow, you simply need to remember the pattern that "invoke" is called to start the process, and invoked when it is finished. Here is a piece of the workflow that will inject the call and capture the exception and return value:

var doSomethingStep = new WorkflowAction();
            
Exception exception = null;
string result = string.Empty;
            
doSomethingStep.Execute = () => ServiceProvider.DoSomethingService(myParameter, (ex, res) =>
                            {
                                exception = ex;
                                result = res;
                                doSomethingStep.Invoked();
                            });
            
yield return doSomethingStep;

As you can see, the action is wired up to fire the service call. Upon returning, the results are captured and then the "invoked" step is called. In the workflow, once the yield completes, you will have the exception and/or result available to process for your next steps.

Work it Out

In conclusion, the IWorkflow engine in Jounce is designed to be an extremely lightweight and flexible way to handle asynchronous processes in a sequential fashion to make your code easier to read and more maintainable (and also to facilitate long running transactions that don't block). For more complex scenarios of both generating, reading, and reacting, you should certainly consider frameworks like Reactive Extensions (Rx) and the upcoming Async featuers. However, sometimes those libraries can be overkill when you just need a few simple steps to execute in sequence, and that's where the workflows in Jounce step in.

Jeremy Likness

1 comment:

  1. Jeremy,

    Do you use WF4? If so, how often and what type of things do you do with it? We are evaluating it now.


    Thanks,

    Eric

    ReplyDelete