ΝUnit Inspired Task Runner
Creating NUnit like task sequences for routine application processes
Introduction
A common and downright unglamorous requirement in many business applications is to run sequences of tasks for things like data import or export and other daily processing. These task sequences don't change very often and detailed knowledge of them leaks from the organisation as people move on. This means that it can be difficult to update them without introducing new faults. This is particularly true if task order has to be changed or a new task introduced part way through a sequence.
By providing an easy to use way of creating task sequences that allows us to group related tasks and any state information they might require, set task execution order at design time and add new tasks to, or remove tasks from, a sequence without having to understand or amend any control logic and without having to move large chunks of code around it ought to be possible to reduce the effort required to maintain such sequences and, perhaps, reduce the likelihood of introducing new faults when amending a task sequence.
The starting point for this solution was the realisation that by identifying methods in a sequence with a custom attribute much as NUnit does it would be possible to write a task controller that could locate and run tasks without additional information other than that available at sequence design time.
The solution as it currently stands requires only a modicum of reflection and three classes.
Class | Comment |
Task |
A custom attribute that allows a developer to mark a method as a task |
TaskInvoker |
A custom attribute that allows a developer to mark a method as a custom task caller. |
TaskGroup |
Abstract class. An implementation of which, with task methods, forms an executable sequence of tasks. |
Using the Code
Outline
Having added a reference to the TaskRunner
DLL to his or her project, the developer derives a class from TaskGroup
and adds a method to the class for each task that is to be carried out. Each of these methods is marked with a Task
attribute which allows the developer to assign the method a position in the execution order and an identity that can be queried at run time.
The default signature for a Task
method is:
void <name>(void)
It is possible to create TaskGroups
with methods that have a signature other than the default. To do this, the developer must add a custom call or hook method marked with the TaskInvoker
attribute to the derived class.
To run the TaskGroup
, an instance is created and its Execute
method called.
Class: Task
Property | Type | Comment |
ExecuteOrder |
integer |
Optional. If not specified then tasks will be executed in discovery order. If specified, then must be specified for all tasks in the taskgroup. Cannot have some tasks with and some tasks without. Duplicate values are not allowed, but there is no requirement to use regular intervals. |
Identity |
integer |
Optional. Allows for runtime identification of a task. |
Ignore |
bool |
Optional. Exclude a task from the sequence. |
Comment |
String |
Optional. Brief description of task's purpose. |
Example 1
No task order specified. Tasks executed in discovery (source code) order.
[Task()]
public void ImpTaskFour()
{
Console.WriteLine("Implicitly ordered Task Group (Task Four)");
}
[Task()]
public void ImpTaskTwo()
{
Console.WriteLine("Implicitly ordered Task Group (Task Two)");
}
[Task()]
public void ImpTaskThree()
{
Console.WriteLine("Implicitly ordered Task Group (Task Three)");
}
[Task()]
public void ImpTaskOne()
{
Console.WriteLine("Implicitly ordered Task Group (Task One)");
}
Example 2
Task order specified. Tasks will be executed in the order shown regardless of discovery order. Note that task two will not be run.
[Task(ExecuteOrder=2, Ignore=true)]
public void ExpTaskTwo()
{
Console.WriteLine("Explicitly ordered Task Group (Task Two)");
}
[Task(ExecuteOrder=3)]
public void ExpTaskThree()
{
Console.WriteLine("Explicitly ordered Task Group (Task Three)");
}
[Task(ExecuteOrder=1)]
public void ExpTaskOne()
{
Console.WriteLine("Explicitly ordered Task Group (Task One)");
}
Example 3
Tasks requiring a TaskInvoker
intercept with order and identity specified. Tasks will be executed in the order shown regardless of discovery order, but the developer must provide a TaskInvoker
method.
This example uses an enumeration to make runtime identification less error prone.
[Task(ExecuteOrder=2,
Identity=(int)TaskIdent.SimpleString,
Comment="Return unaltered value as text.")]
public string SimpleString(int value)
{
return value.ToString();
}
[Task(ExecuteOrder=1,
Identity=(int)TaskIdent.SquareString,
Comment="Return square of value as text.")]
public string SquareString(int value)
{
value *= value;
return value.ToString();
}
Class: TaskInvoker
Property | Type | Comment |
Comment |
String |
Optional. Brief description of TaskInvoker 's purpose. |
A TaskInvoker
need not be terribly complex, all it has to do is make sure that the methods in the TaskGroup
are called in the correct way. The example below shows a number of possible techniques that can be used.
Example
[TaskInvoker(Comment="Call methods that accept an int and return a string.")]
public void CallMethod(MethodInfo mi)
{
// Identify the task to be executed.
Task task = TaskAttributes(mi);
// Demonstrate conditional execution based on task execution order.
if (task.ExecuteOrder == 1)
Console.WriteLine("First task to execute is {0}", task.Comment);
// Demonstrate conditional execution based on task identity.
if (task.Identity == (int)TaskIdent.SimpleString)
Console.Write("Simple ");
else
Console.Write("Square ");
// Invoke task according to signature and return type using a standard
// bit of reflection.
string retVal = (string)mi.Invoke(this, new object[]{param});
Console.WriteLine(retVal);
}
You'll note that the TaskInvoker
method has to have the signature...
void <name>(MethodInfo)
...and that the example expects all methods in its TaskGroup
to have the same signature. However as it is possible to read a task's ExecuteOrder
and Identity
attributes and execute branches accordingly, this isn't a strict requirement.
A TaskGroup
may have only one TaskInvoker
. If more than one is written, then the last one found at TaskGroup
instantiation is used.
Class: TaskGroup
This is an abstract
class so the developer has no choice but to create a derived class.
Property | Type | Comment |
CustomCall |
MethodInfo |
Null or a MethodInfo describing a TaskInvoker |
Method | Visibility | Comment |
Execute |
public |
Runs all Tasks in the TaskGroup |
findTaskMethods |
protected |
Locates all Task s and TaskInvoker in the TaskGroup . Run at TaskGroup instantiation. |
TaskAttributes |
protected |
Retrieves the Task attribute, if any, for a method. |
Example 1
It doesn't get much easier than this. The following is a complete runnable TaskGroup
. If we were not concerned with task order, then we could simplify it further by dropping the ExecuteOrder
specifications.
internal class TaskGroupExplicitlyOrdered : TaskGroup
{
[Task(ExecuteOrder=2)]
public void ExpTaskTwo()
{
Console.WriteLine("Explicitly ordered Task Group (Task Two)");
}
[Task(ExecuteOrder=3)]
public void ExpTaskThree()
{
Console.WriteLine("Explicitly ordered Task Group (Task Three)");
}
[Task(ExecuteOrder=1)]
public void ExpTaskOne()
{
Console.WriteLine("Explicitly ordered Task Group (Task One)");
}
[Task(ExecuteOrder=4)]
public void ExpTaskFour()
{
Console.WriteLine("Explicitly ordered Task Group (Task Four)");
}
}
Example 2
The following is also a complete runnable TaskGroup
, but demonstrates retrieval and use of Task
attributes as well as a custom TaskInvoker
.
internal class TaskGroupCustomCall : TaskGroup
{
// To demonstrate task identification.
private enum TaskIdent
{
SimpleString = 0,
SquareString = 1
}
private int param = 0;
// To demonstrate that we can provide and use setup params.
// Note the call to the base constructor.
public TaskGroupCustomCall(int someValue) : base()
{
param = someValue;
}
// We have a case where the default task method signature void name(void)
// doesn't meet our needs.
// So we mark a method as a task invoker and hand it the responsibility of
// calling each method in the task group.
[TaskInvoker(Comment="Call methods that accept an int and return a string.")]
public void CallMethod(MethodInfo mi)
{
// Identify the task to be executed.
Task task = TaskAttributes(mi);
// Demonstrate conditional execution based on task execution order.
if (task.ExecuteOrder == 1)
Console.WriteLine("First task to execute is {0}", task.Comment);
// Demonstrate conditional execution based on task identity.
if (task.Identity == (int)TaskIdent.SimpleString)
Console.Write("Simple ");
else
Console.Write("Square ");
// Invoke task according to signature and return type using a standard
// bit of reflection.
string retVal = (string)mi.Invoke(this, new object[]{param});
Console.WriteLine(retVal);
}
// Now define the tasks for this task group. As methods are identified
// by their attributes it doesn't matter whereabouts in the class the
// methods are placed.
[Task(ExecuteOrder=2,
Identity=(int)TaskIdent.SimpleString,
Comment="Return unaltered value as text.")]
public string SimpleString(int value)
{
return value.ToString();
}
[Task(ExecuteOrder=1,
Identity=(int)TaskIdent.SquareString,
Comment="Return square of value as text.")]
public string SquareString(int value)
{
value *= value;
return value.ToString();
}
}
Running TaskGroups
TaskGroups
are objects with methods and state so you're really only limited by the application's requirements and your own creativity in the ways you can use them.
The example below shows the simplest possible use:
class Program
{
static void Main(string[] args)
{
TaskGroupExplicitlyOrdered groupExplicit = new TaskGroupExplicitlyOrdered();
groupExplicit.Execute();
Console.WriteLine();
TaskGroupImplicitlyOrdered groupImplicit = new TaskGroupImplicitlyOrdered();
groupImplicit.Execute();
Console.WriteLine();
TaskGroupCustomCall groupCustom = new TaskGroupCustomCall(12);
groupCustom.Execute();
Console.ReadLine();
}
}
If you wanted to be too clever by half you could create a TaskGroup
controller, as shown below, which allows you to not only specify the order within sequences easily but also the order of those sequences.
internal class TaskGroupController : TaskGroup
{
[Task(ExecuteOrder=1)]
public void RunExplicit()
{
TaskGroupExplicitlyOrdered groupExplicit = new TaskGroupExplicitlyOrdered();
groupExplicit.Execute();
}
[Task(ExecuteOrder=2)]
public void RunImplicit()
{
TaskGroupImplicitlyOrdered groupImplicit = new TaskGroupImplicitlyOrdered();
groupImplicit.Execute();
}
[Task(ExecuteOrder=3)]
public void RunCustom()
{
TaskGroupCustomCall groupCustom = new TaskGroupCustomCall(12);
groupCustom.Execute();
}
}
...and the code to run umpteen task sequences reduces to...
TaskGroupController controller = new TaskGroupController();
controller.Execute();
The Last Bit
It was pleasant to find how easy, almost trivial, the .NET framework made the implementation of this idea. There is only one method in the three classes with any degree of complexity, findTaskMethods
, and even that boils down to not much more than a loop with two or three calls to the System.Reflection
library.
All very amusing, you may say, but what does this give you that dropping a series of method calls into a general purpose module doesn't?
- Simple task identification at run time
- Strong encapsulation of related processing
- As there is no need to shunt blocks of code around, it is very much easier to ...
- ... insert new tasks at any point in the sequence
- ... change the sequence of tasks
- ... temporarily exclude tasks from the sequence
TaskGroup
s, being objects can ...- ... maintain their own state
- ... be re-run easily with differing starting state
- ... be easily handed off to separate threads
History
- August 2010 - Proof of concept developed
- November 2010 - Tidied up for publication