(WF4) Workflow 4.0 Hosting Extensions Redux

(Foreword: I tried to write one article about Extensions already, but what I produced could possibly be considered too condensed. For my encore, here is the longer, more explicit version with some actual hosting code to look at, and that also gives you a general idea of how to use bookmarks in custom activities. Feedback in the form of comments is always appreciated [and moderated for spam filtering].)

Much like sliced bread, workflow hosting extensions are a great idea. However, it is not necessarily easy to explain that without some kind of back story, so let us invent one.

Let’s say we’re using WF in the UI domain by developing a user-interactive Kiosk display at the local Zoo. Workflow seems like it could be a nifty tool for building a story flowchart, which controls a sequence of pages displayed in the kiosk. We can model the page flow, by modelling each page as an activity instance in a flowchart, and the workflow designer rehosting feature will make it easy to build a content generation application for enhancing the kiosk page flow. Brilliant!

How do we actually make it work? Each page is definitely an activity instance in the workflow designer. You could have different activity classes for each page, but probably less coding work would be involved in having one Page class where we can just create many instances and configure each instance differently – to look appropriately when displayed.

Great. Now to some harder questions of practical implementation:

How do we control the page rendering?

Depending on how we executed our workflow, we could possibly be running it on the UI thread(*not discussing how to do that right away), and able to safely call our GUI class to ask it to render pages - but what instance of the GUI class will we be making those calls to? It doesn’t seem nice either to make GUI static (we’d rather have it be an interface or abstract class, so that we can replace it with an alternative implementation for testing, or selling into a different market channel), also to require the customer who designs the workflows to configure InArgument<GUI> on every single Page would not be ideal

How does the user control the transitions between pages?

Let’s imagine for sake of simplicity that pages usually end with a question, and that there’s going to be a Yes button, and a No button on our Kiosk. We need to get the information about what button they pressed into a variable in our workflow so that we can use FlowDecisions to control the page flow.

Also, do we use bookmarking?

Yes. We are going to use bookmarking. One reason is that workflows without bookmarks are just not that interesting. The other reason is that workflows with bookmarks have what we might call an admirable unselfish quality: the workflow can and will give up the thread completely for those times when it doesn’t need it. (This is how we want all our WF4 workflows to behave!)

[Interjection: right now, I want you to realize extensions are not the only way to solve this problem. They’re A way. I’m going to show an arbitrary solution which does use Extensions. Then right at the end, we can discuss alternatives and relative merits. OK.]

The extension-centric solution

Let’s start by defining two extension classes to help solve our problem. GUIExtension, and ButtonInputExtension. And we’ll make them abstract because we want to achieve somewhat loose coupling of the Workflow Page activity with its actual runtime environment, whether that eventually be the real Kiosk, our test lab, or something completely different.

 

    public abstract class GUIExtension

    {

        public abstract void RenderPage(string rawPageRenderingData);

    }

 

    public abstract class ButtonInputExtension

    {

        public abstract bool PressedYes { get; set; }

    }

 

Next we’re going to declare our Page activity, and inside our Page activity CacheMetadata() override, we’re going to declare that it requires the extensions. We are also going to NOT provide any default extension implementation.

 

    public class PageActivity : NativeActivity

    {

        protected override void Execute(NativeActivityContext context)

        {

            // TODO, in just a few minutes, I promise

        }

 

        protected override void CacheMetadata(NativeActivityMetadata metadata)

        {

            base.CacheMetadata(metadata);

            metadata.RequireExtension<GUIExtension>();

            metadata.RequireExtension<ButtonInputExtension>();

        }

    }

 

Quick diversion: if you’ve never used RequireExtension<T>() before and you have a healthy inquiring mind, you’re probably wondering what happens when we try to run a workflow which has a PageActivity in it, but forgetting to provide any extensions. Does it crash in the middle of the Page activity?

 

       static void Main(string[] args)

        {

            var activity = new PageActivity();

            WorkflowInvoker app = new WorkflowInvoker(activity);

            app.Invoke();

        }

 

No! It is a little bit more advanced than that, it managed to crash before any actual workflow execution:

System.Activities.ValidationException was unhandled
  Message=An extension of type 'ConsoleApplication1.GUIExtension' must be configured in order to run this workflow.

This is a good property to have for those long running business workflows, that they do not fail due to a missing extension after you executed half the workflow.

OK, now also our Page activity make use of those extensions somehow, and go Idle (create a bookmark) when it has nothing to actually do. Here is a (demo-suitable) PageActivity

    public class PageActivity : NativeActivity

    {

        // PageData: This will be configured by the customer in the workflow designer

        public string PageData { get; set; }

 

        // PressedYes: This will be bound to a variable by the customer in the workflow designer

        public OutArgument<bool> PressedYes { get; set; }

 

        protected override bool CanInduceIdle

        {

            get { return true; }

        }

 

        protected override void Execute(NativeActivityContext context)

        {

            // Get the GUI extension, and use it

            var gui = context.GetExtension<GUIExtension>();

            gui.RenderPage(this.PageData);

 

            // Go idle, with a bookmark, which will be resumed with a button press

            context.CreateBookmark("ContinuePage", OnContinuePage);

        }

 

        private void OnContinuePage(NativeActivityContext context, Bookmark bookmark, object value)

        {

            // Get the button press data and return it to the workflow via the OutArgument

            var buttonExtension = context.GetExtension<ButtonInputExtension>();

            if (this.PressedYes != null)

            {

                context.SetValue(this.PressedYes, buttonExtension.PressedYes);

            }

        }

 

       protected override void CacheMetadata(NativeActivityMetadata metadata){…}

    }

 

Now we have a PageActivity, we need to write a slightly better hosting code because we can’t actually use it, unless we have a host which provides the missing extensions. So let’s do that. We aren’t going to use WorkflowInvoker any more, we’re going to use WorkflowApplication.

TIP: If you’re working on a WPF application and want workflow activities to execute on the same dispatcher thread as the UI, then instead of having to marshal cross thread with AsyncInvoke etc, you can have a host created from the UI thread like this:

     WorkflowApplication app = new WorkflowApplication(activity);

     Dispatcher d = Dispatcher.CurrentDispatcher;

     app.SynchronizationContext = new DispatcherSynchronizationContext(d);

     app.Run();

But we won’t actually do that in this article.

The sample host, all in one shot:

    class Program

    {

        static AutoResetEvent pageMarkEvent = new AutoResetEvent(false);

        static AutoResetEvent completedEvent = new AutoResetEvent(false);

 

        static void Main(string[] args)

        {

            var sequence = new Sequence

            {

                Variables =

                {

                    new Variable<bool>("ContinueVar"),

                },

           Activities =

                {

 

                    new PageActivity{ PageData = "Page1", PressedYes = new VisualBasicReference<bool>("ContinueVar") },

                    new If

                    {

                        Condition = new VisualBasicValue<bool>("ContinueVar"),

                        Then = new PageActivity { PageData = "Page2" },

                        Else = new PageActivity { PageData = "Page3" }

                    }

                }

            };

 

            WorkflowApplication app = new WorkflowApplication(sequence);

           

            var guiExtension = new ConsoleGUI();

            var buttonExtension = new ConsoleYesNo();

            app.Extensions.Add(guiExtension);

            app.Extensions.Add(buttonExtension);

 

            app.Idle += delegate(WorkflowApplicationIdleEventArgs e)

            {

                Console.WriteLine("\nHOST: WF Idle");

                pageMarkEvent.Set();

            };

            app.Completed += delegate(WorkflowApplicationCompletedEventArgs e)

            {

                Console.WriteLine("\nHOST: WF Completed");

                completedEvent.Set();

            };

 

            app.Run();

            while (true)

            {

                AutoResetEvent[] handles = new AutoResetEvent[2] { pageMarkEvent, completedEvent };

                int index = AutoResetEvent.WaitAny(handles, Timeout.Infinite);

                if (handles[index] == pageMarkEvent)

                {

                    buttonExtension.WaitForInput();

                    app.ResumeBookmark("PageBookmark", null);

                    app.Run();

                }

                else

                {

                    Console.WriteLine("HOST: WF Completed (Part 2)");

                    break;

                }

            }

 

            Console.WriteLine("HOST: Exiting");

        }

 

        private class ConsoleGUI : GUIExtension

        {

            public override void RenderPage(string rawPageRenderingData)

            {

                Console.WriteLine("\nGUIEXTENSION: rendering> {0}", rawPageRenderingData);

            }

        }

 

        private class ConsoleYesNo : ButtonInputExtension

        {

            public override bool PressedYes { get; set; }

 

            internal void WaitForInput()

            {

                while (true)

                {

                    Console.WriteLine("\nBUTTONEXTENSION: Waiting for you to enter (Y/N) to simulate button press");

                    string line = Console.ReadLine();

                    if (line.ToUpper().Equals("Y"))

                    {

                        this.PressedYes = true;

                        return;

                    }

                    else if (line.ToUpper().Equals("N"))

                    {

                        this.PressedYes = false;

                        return;

                    }

                }

            }

        }

    }

 

 

Now that was kind of long, so let’s break it down. The first thing we do is create a test workflow. A sequence, with 3 pages. (Sequence instead of flowchart purely for my convenience.)

Which page we see depends on whether we press the ‘Yes’ button or the ‘No’ button. In order for that to work, we had to provide a Variable ‘ContinueVar’, and also point the OutArgument ‘PressedYes’ at that variable.

            var sequence = new Sequence

            {

                Variables =

                {

                    new Variable<bool>("ContinueVar"),

                },

           Activities =

                {

 

                    new PageActivity{ PageData = "Page1", PressedYes = new VisualBasicReference<bool>("ContinueVar") },

                    new If

                    {

                        Condition = new VisualBasicValue<bool>("ContinueVar"),

                        Then = new PageActivity { PageData = "Page2" },

                        Else = new PageActivity { PageData = "Page3" }

                    }

                }

            };

 

The next and most important bit is where we add extensions to the host, which satisfies the requirements of PageActivity.CacheMetadata().

Note - there are two overloads of WorkflowInstanceExtensionManager.Add() – the plain one used here, or the fancy one that takes a factory Func<>. Right now I don’t remember if they behave the same. The one we are calling just takes (object), and later, a runtime-type test will work out that guiExtension implements GUIExtension.

            WorkflowApplication app = new WorkflowApplication(sequence);

           

            var guiExtension = new ConsoleGUI();

            var buttonExtension = new ConsoleYesNo();

            app.Extensions.Add(guiExtension);

            app.Extensions.Add(buttonExtension);

 

            app.Idle += delegate(WorkflowApplicationIdleEventArgs e)

            {

                Console.WriteLine("\nHOST: WF Idle");

                pageMarkEvent.Set();

            };

            app.Completed += delegate(WorkflowApplicationCompletedEventArgs e)

            {

                Console.WriteLine("\nHOST: WF Completed");

                completedEvent.Set();

            };

 

The rest is fairly straightforward (as straightforward as event waiting code can be).
Here’s a sample console trace, so no need to fire up VS:

GUIEXTENSION: rendering> Page1

 

HOST: WF Idle

 

BUTTONEXTENSION: Waiting for you to enter (Y/N) to simulate button press

n

 

GUIEXTENSION: rendering> Page3

 

HOST: WF Idle

 

BUTTONEXTENSION: Waiting for you to enter (Y/N) to simulate button press

y

 

HOST: WF Completed

HOST: WF Completed (Part 2)

HOST: Exiting

Which concludes our explanatory code sample section.

Weighing the Pros and Cons of the Extension approach

In order to talk about pros and cons, of course we are comparing the extension approach to some alternative. So here are some alternatives!

1. A probably bad alternative to the whole PageActivity: a blocking code activity, which calls some static GUI.DisplayPage() function???

Well, blocking activities which sit in Execute forever are generally a pretty bad idea, because they are going to tie up a workflow execution thread. In the case of a Kiosk application we could probably get away with this kind of design, since we aren’t actually taking advantage of the performance/scalability features of workflow which are impacted by blocking a workflow execution thread.

Exercise in contrast: what if instead of driving a single kiosk, our workflow host was hosting 1000 workflows all at once, which are driving the page logic of 1000 kiosks distributed across the world. Would tying up a thread be a bad thing then? (Yes.) Conclusion: avoid writing blocking activities when possible. You don’t need to hold the thread forever if rendering the page only takes 100ms CPU time.

2. Possible alternative to ButtonInputExtension: using a Bookmark parameter

Let’s think about the communication pattern of ButtonInputExtension.

Host passes workflow the Extension. Workflow at some point decides to use the extension to pull in information. SOMETHING (host? extension? Mr. X?) calls ResumeBookmark(). The bookmark resumption is independent of the communication.

Now let’s consider using a Bookmark parameter.

Host doesn’t pass workflow the Extension. Workflow at some point sets a bookmark. Again, SOMETHING at some point calls ResumeBookmark, and must also simultaneously push in information in the format desired by the workflow.

We can certainly draw a few contrasts, whether they are pros or cons might depend.

-With the extension, the workflow can pull in information at any time, it doesn’t have to create a bookmark.
-
With the extension, we created a requirement upon the host to have specific behavior, i.e. behave by providing an extension
-With the extension, we get automatic validation that the host understands how to support the custom activity, since CacheMetadata() throws if there is no extension available

- With the bookmark, host, or something, whatever that is, can choose exactly what data to send, but the timing of that information has to correspond with the Workflow going idle and setting the bookmark.
-With the bookmark, we created a requirement upon the host to have specific behavior, which is basically ‘resuming the bookmark in some correct way’, which might imply that your host has a very close understanding of exactly the meaning of the bookmark.
-With the bookmark approach, we have no way for the custom activity to validate prior to execution that the host supports its desired execution semantics, we will just have to hope that we are running in a ‘good’ host.

3. Possible alternative to GUIExtension: using WF Tracking feature to ‘read’ or ‘spy on’ data from the workflow, then display it

This is another case where for our GUI application it could probably work, but as a generally applicable technique it may not be universally good. From what I remember hearing, please send corrections if I am wrong, etc, but tracking happens asynchronously to workflow execution. We might need the timing of our workflow to be very predictable in order to use tracking as the ‘output’ part of a Workflow I/O pattern, when I/O is critical to the workflow execution.