Contents
Over various articles and a couple years now I've been writing and mulling over various ideas -- visual programming, data-centric computing, semantic computing, and so forth. There's been this nagging thought over the years that imperative (and even functional) programming is backwards -- data should drive the workflow rather than the workflow pushing data around, and if we worked from the data driven approach it would be possible to actually write very small re-usable components and wire them together visually.
While we all start with this, and still have to deal with it at some level:
to, please, NOT this (from MagPi Magazine's review of Scratch):
but more like this (screenshot from Node-RED tool)
I actually want something that is a bit more in-between Scratch and Node-Red. Scratch is a horrible visual representation of programming primitives like assignment, if-then-else, and loops. Node-RED is great but there's two problems for what I'm trying to do. First, it's too high level--the high level components that are demonstrated in Node-RED should, in my world, be composed from lower level constructs created in the design tool. Second, Node-RED's implementation appears to be workflow-centric rather than data centric -- the difference here is that the lines connect outputs to inputs whereas in the work that I've done with data-centric programming, the lines are an artifact of agents understanding the data types placed into a data pool.
My work with the DRAKON programming language (image clipped from example found on wikipedia:
is again, too low level and workflow centric rather than data-centric.
My proposed solution to this borrows from functional programming and adds a few things.
First off, most code is performing a map, reduce, or filter operation. Data is either:
- being mapped: transformed from one representation to another.
- being reduced: aggregated, accumulated, or somehow processed into single value.
- being filtered: the data we don't want is being removed.
This however is not sufficient -- we need at least the ability to "match and map" and to loop:
- match and map: when the input data matches a particular condition, it is mapped to another representation / type
- loop: any set of things is automatically iterated over. In addition, a basic loop can be created with map and "match and map" primitives.
The other important piece of the puzzle borrows from a my previous work -- agents that are triggered by the "publishing" of data. The key realization is that the operators described above (map, reduce, filter, match) are actually agents!
Here's a simple "count from 1 to 10" example.
In this example, we have three agents:
- An agent that logs the data packet content to the console.
- A general purpose match agent that tests the condition that the number is less than 10, and if it is, remaps that data to a new type.
- A general purpose map agent that "converts" the data from one form to another, in this case simply incrementing the number.
Visually, that's how the program would be built (even if you hate the icons.) Because the program is data-centric, the data type, indicated in green, must change to trigger the agents that work on that type. That seems a bit absurd in this simple example however it makes a lot more sense in real life examples, as we'll see later.
Because I don't have the implementation for the designer completed (that's the next article) using either FlowSharp or FlowSharpWeb, we'll have to look for the moment at actual C# code to instantiate the agents and seed data.
private static void SimpleCounterExample()
{
var seed = CreateExpando(new Dictionary<string, dynamic>()
{
{ "Context", "Counter" },
{ "Type", "Number" },
{ "val", 1 }
});
var logAgent = new ConsoleLogAgent("Counter", "Number", "NextNumber");
var matchAgent = new MatchAgent("Counter", "NextNumber", "IncrementNumber").
Add((context, data) => data.val < 10, (context, data) => data);
var incrementAgent = new MapAgent("Counter", "IncrementNumber", "Number")
{
Map = (context, data) => CreateExpando(new Dictionary<string, dynamic>() { { "val", data.val + 1 } })
};
agentPool.Add(logAgent);
agentPool.Add(matchAgent);
agentPool.Add(incrementAgent);
dataPool.Enqueue(seed);
}
That's a lot of lines of code to get this:
and hidden behind the scenes are what the agents are doing. At this point I assume you are thinking I'm crazy! Bare with me though, this will get more interesting. The point of the above counter example is that you'd never actually write that C# code -- you'd draw the counter as I did above. Furthermore, all this ExpandoObject
business is an annoyance of C#, being a strongly typed language, when dealing with compile-time indeterminate types -- remember that the "program" is written at runtime. It's actually a lot easier to write this code in Python or Javascript!
It's also worth noting that the reason this article discusses ClearSharp and Javascript is that the mapping function is scripted, and for a match agent, the qualifiers are scripted as well. This let's us create general purpose mid-level computing agents. With minimal programming skills (everyone uses Excel, right?), the user can create simple conditionals and mappings. The real hard work is knowing the data with which you're working!
Please replace "[your API key]" in the C# and Javascript with the API key obtained from https://api.nasa.gov/index.html#apply-for-an-api-key
There are three places in the source code this needs to happen:
In the apodPureCSharp project, Program.cs, line 174:
ApiKey = "[your API key]",
In the apod project, Prgoram.cs, line 188:
ApiKey = "[your API key]",
In apodSlideShow.html, line 192:
ApiKey: "[your API key]"
The download already contains the compiled ClearScript DLL's in the bin\Debug folder, which is why the download is 9MB! This saves you the step of compiling ClearScript yourself. As a side note, don't run this in a VM - when I tried the ClearScript version of the project (called just "apod" in the solution) it didn't work in an Oracle VirtualBox VM.
- apod - This is the C# + ClearScript with Javascript scripts.
- apodPureCSharp - This is the pure C# version, using lambda expressions.
- apodSlideShow.html - this is the pure Javascript version and is found in the apod project. Don't open the file in your browser -- you need a server because of the cross-domain request.
I previously published an article on a Contextual Data Explorer, as the point is that data without context is fairly meaningless. Here in this article, while there's the concept of context, it's really just a placeholder for working with more complicated applications. In the "context" of this demo, where we're working just with the data types of the APOD "context", there's no need to explore agents in different contexts. Having different types for the data is sufficient.
I'm breaking a lot of rules here by using dynamic
and ExpandoObject
-- at least in C#, one would normally create strongly typed classes and serialize the data in and out of these classes. And that is totally doable but completely defeats the purpose of having a low-level agent that knows nothing about the data on which it operates. The rules -- the qualifiers and mapping functions -- are instead coded as scripts, and I don't want to be compiling C# code at runtime. The ExpandoObject
and the dynamic
type are an excellent way of managing un-typed data. The result is that the code behaves more like the typeless key-value dictionaries in Javascript and JSON, which as it turns out is exactly what we want.
Visually, I'm going to use the following "formal" notation for the various agents.
The map agent:
- Accepts the specified input type.
- Executes the mapping script that transforms the data.
- Publishes the mapped data with the specified output type.
Note that the mapping function itself can specify the context and output type, allowing the transform to override the output type specified when the agent was constructed.
The match agent, in the order in which the match qualifier-maps are specified:
- Accepts the specified input type.
- Executes the qualifier script.
- If the qualifier script returns true:
- The associated mapping script is executed.
- The resulting mapped data is published with the output type unless overridden by the mapping script.
- Processing of matches stops when the first qualifier that returns true is found.
- If the qualifier script returns false, the next match is tested.
- If no matches are found, no data is published, which terminates that data flow for the particular flow-branch.
An output agent:
- Accepts the specified input type.
- Executes the function specified in the constructor that provides the interface for outputting data to the desired "device."
- Publishes the input data with the specified output type.
A custom agent executes what an agent from an existing library or a custom agent that the user created. It:
- Accepts the specified input type.
- Performs some operation.
- Emits the result of the operation in the specified output type.
Examples of custom agents that are used in this demo are:
- HttpGetJsonAgent
- HttpGetImageAgent
- SleepAgent
It's time to do a deep dive into how an agent data-centric system can be put together. I'll be demonstrating the concept in several different ways:
- Pure C# - no scripting, the application specific "code" is implemented as C# anonymous lambda expressions.
- A hybrid of C# for the agents and Javascript, interpreted using ClearScript, for the application specific conditionals and maps.
- A pure Javascript implementation that can be run in a browser.
Here's what we're working with, as an example:
{
"date": "2018-01-02",
"explanation": "Why does the Perseus galaxy cluster shine so strangely in one specific color of X-rays? [etc...]
"hdurl": "https:
"media_type": "image",
"service_version": "v1",
"title": "Unexpected X-Rays from Perseus Galaxy Cluster",
"url": "https://apod.nasa.gov/apod/image/1801/PerseusCluster_DSSChandra_960.jpg"
}
Of particular note is the media type and in many images, both a high definition and a "low definition" image.
The agents and their scripts that we need for an APOD slideshow are defined next. There are a few things to note:
- In this example, I'm using C# lambda expressions rather than Javascript for the qualifier scripts. There is little difference between Javascript and C# in these examples.
- The mapping script, while implemented as a C# anonymous function in the "C# only" example, is described actually as a JSON object, since this will be common for the hybrid C#-ClearScript and pure Javascript examples.
- Because the algorithm is data-centric, there is no workflow and therefore no arrows gluing the agents together. Can you figure out the workflow based on the input/output data types? I've provided a second diagram which illustrates the agents glued together by input/output type which gives you a sense of the workflow that is merely the result of the specified input/output types.
Transforms the input data fields and context fields to a single url suitable to request the APOD information, which is published in the data pool.
Executes an HTTP GET and publishes the resulting JSON.
Matches the "media_type" JSON field with "image". If matches, publishes the data as "ApodNotVideo" otherwise publishes the data as "DateCheck", which starts the process for acquiring the next date.
A custom type overrides the default output type to trigger the date check handler. This map is: { Type : "DateCheck", date : data.date }
Outputs the date, title, and explanation. Note that this agent doesn't publish any output type, thus the data-flow execution stops on this data-flow branch.
If a high definition version of the image exists, then we use that image, otherwise we use the URL in the JSON "url" field.
Retrieves the image from the provided url value.
Displays the image in the picture box.
Pauses on the image for defined seconds.
Checks if the current date has been reached. If not, publishes the current data packet but as a "Date" type. If the match statement fails, the data flow process terminates as nothing is published.
Maps the current date to the next date and publishes as "NextDate" type.
Maps the current date into the data packet needed to request the next APOD image and publishes the data packet to the data pool.
Here's the same thing, but I've connected the data-flow based on input/output types:
We'll look first at an implementation written just in C#.
The agents are very simple. That's the whole point of this -- to write small code blocks that are wired together to do bigger things.
In the pure C# implementation, there's really not much going on here.
public abstract class Agent
{
public string Context { get; protected set; }
public string DataType { get; protected set; }
public string ResponseContext { get; set; }
public string ResponseDataType { get; set; }
public dynamic ContextData { get; set; }
public Agent(string context, string dataType, string responseDataType)
{
Context = context;
DataType = dataType;
ResponseDataType = responseDataType;
}
public abstract void Call(dynamic data);
public void SetContextAndType(dynamic data, dynamic resp, bool useAgentContextAndType)
{
if (useAgentContextAndType || !((IDictionary<string, object>)resp).ContainsKey("Context"))
{
resp.Context = ResponseContext ?? Context;
}
if (useAgentContextAndType || !((IDictionary<string, object>)resp).ContainsKey("Type"))
{
resp.Type = ResponseDataType;
}
}
}
The important thing going on here is handling the override for when the mapped data specifies the context and/or type. Since these two elements are optional even if the useAgentContextAndType
is false, we still check that they exist and use the agent's context and output type if the elements aren't defined in the map function.
public class MapAgent : Agent
{
public Func<dynamic, dynamic, dynamic> Map { get; set; }
protected bool useAgentContextAndType;
public MapAgent(string context, string dataType, string responseDataType, bool useAgentContextAndType = true) : base(context, dataType, responseDataType)
{
this.useAgentContextAndType = useAgentContextAndType;
}
public override void Call(dynamic data)
{
var resp = Map == null ? ((Func<dynamic, dynamic, bool>)data.Map)(ContextData, data) : Map(ContextData, data);
SetContextAndType(data, resp, useAgentContextAndType);
Program.QueueData(resp);
}
}
Three lines of code handle:
- Receiving the data.
- Using either the mapping function specified when the agent is instantiated or the mapping function specified in the data packet itself.
- Publishing the mapped data.
public class HttpGetJsonAgent : Agent
{
public HttpGetJsonAgent(string context, string dataType, string responseDataType) : base(context, dataType, responseDataType)
{
}
public async override void Call(dynamic data)
{
HttpClient client = new HttpClient();
using (var response = await client.GetAsync(data.url, HttpCompletionOption.ResponseHeadersRead))
{
if (response.IsSuccessStatusCode)
{
using (var stream = await response.Content.ReadAsStreamAsync())
{
using (var streamReader = new StreamReader(stream))
{
var str = await streamReader.ReadToEndAsync();
dynamic resp = JsonConvert.DeserializeObject<ExpandoObject>(str);
resp.Context = ResponseContext ?? data.Context;
resp.Type = ResponseDataType;
Program.QueueData(resp);
}
}
}
}
}
}
Most of the work here is in the setup to get the response. Once the JSON response is received, it is deserialized into an ExpandoObject
and published onto the data pool.
public class HttpGetImageAgent : Agent
{
public HttpGetImageAgent(string context, string dataType, string responseDataType) : base(context, dataType, responseDataType)
{
}
public async override void Call(dynamic data)
{
HttpClient client = new HttpClient();
using (var response = await client.GetAsync(data.url, HttpCompletionOption.ResponseHeadersRead))
{
if (response.IsSuccessStatusCode)
{
using (var stream = await response.Content.ReadAsStreamAsync())
{
var image = Image.FromStream(stream);
data.Image = image;
data.Context = ResponseContext ?? data.Context;
data.Type = ResponseDataType;
Program.QueueData(data);
}
}
}
}
}
Similarly, receiving an image leverages the Image
class to process the input stream.
public class OutputAgent : Agent
{
protected Action<dynamic> action;
public OutputAgent(string context, string dataType, string responseDataType, Action<dynamic> action) : base(context, dataType, responseDataType)
{
this.action = action;
}
public override void Call(dynamic data)
{
action(data);
data.Context = ResponseContext ?? data.Context;
data.Type = ResponseDataType;
Program.QueueData(data);
}
}
The salient point here is that this agent executes an action, passing in the data, and then publishes the agent back to the data pool. Obviously (or at least it should be obvious) the published type should be null or a different type than the input type, otherwise an infinite loop will recur.
public class SleepAgent : Agent
{
protected int msSleep;
public SleepAgent(string context, string dataType, string responseDataType, int msSleep) : base(context, dataType, responseDataType)
{
this.msSleep = msSleep;
}
public override void Call(dynamic data)
{
Thread.Sleep(msSleep);
data.Context = ResponseContext ?? data.Context;
data.Type = ResponseDataType;
Program.QueueData(data);
}
}
Here this agent simply suspends processing of the data flow for the specified number of milliseconds then publishes the data to the data pool with the specified "output" type.
public class MatchAgent : Agent
{
protected List<(Func<dynamic, dynamic, bool> condition, Func<dynamic, dynamic, dynamic> map, bool useAgentContextAndType)> matches =
new List<(Func<dynamic, dynamic, bool>, Func<dynamic, dynamic, dynamic>, bool)>();
public MatchAgent(string context, string dataType, string responseDataType) : base(context, dataType, responseDataType)
{
}
public MatchAgent Add(Func<dynamic, dynamic, bool> condition, Func<dynamic, dynamic, dynamic> map, bool useAgentContextAndType = true)
{
matches.Add((condition, map, useAgentContextAndType));
return this;
}
public override void Call(dynamic data)
{
var match = matches.FirstOrDefault(t => t.condition(ContextData, data));
if (!match.Equals((null, null, false)))
{
dynamic resp = match.map(ContextData, data);
SetContextAndType(data, resp, match.useAgentContextAndType);
Program.QueueData(resp);
}
}
}
This class implements the general purpose matching agent. If the condition returns true, the associated mapping function is executed and the data is published based on either the output type specified when the agent is instantiated or by a context and type specified in the resulting map data.
The following shows the code for instantiating each agent.
var apodUrlMappingAgent = new MapAgent(Contexts.APOD, "ApodRequestData", "ApodUrl")
{
ContextData = contextData,
Map = (context, data) => CreateExpando(new Dictionary<string, dynamic>()
{
{ "url", context.Url + "?api_key=" + context.ApiKey + "&date=" + data.date }
})
};
var apodAgent = new HttpGetJsonAgent(Contexts.APOD, "ApodUrl", "ApodResponseData");
var apodMediaTypeFilter = new MatchAgent(Contexts.APOD, "ApodResponseData", "ApodNotVideo").
Add((context, data) => data.media_type == "image", (context, data) => data).
Add((_, __) => true, (context, data) =>
CreateExpando(new Dictionary<string, dynamic>()
{
{ "Type", "DateCheck" }, { "date", data.date }
}), false);
Note here two things:
- First, remember that the matches are tested in the order in which they are added. If the media type is not "image", the second match is processed because it always returns
true
. - We indicate with the
false
that we are overriding the output data type, which is specified in the map.
var apodTextToControlAgent = new OutputAgent(Contexts.APOD, "ApodNotVideo", null, data =>
{
form.SetDate(data.date);
form.SetTitle(data.title);
form.SetExplanation(data.explanation);
});
In this agent, we pass in the action to update the date, title, and explanation text boxes. Because this can occur asynchronously, we wrap the actual implementation in an Invoke, for example:
this.Invoke(() => pbApod.Image = image);
Which, because this is something I do a lot in various WinForm implementations, is implemented as an extension method (we could use BeginInvoke
if we want to queue the action onto the window message loop processing):
public static void Invoke(this Control control, Action action)
{
if (control.InvokeRequired)
{
control.Invoke((Delegate)action);
}
else
{
action();
}
}
var apodBestImageAgent = new MatchAgent(Contexts.APOD, "ApodNotVideo", "ImageUrl").
Add((context, data) =>
!String.IsNullOrEmpty(data.hdurl),
(context, data) =>
CreateExpando(new Dictionary<string, dynamic>()
{
{ "url", data.hdurl }, { "date", data.date }
}
)).
Add((_, __) => true,
(context, data) =>
CreateExpando(new Dictionary<string, dynamic>()
{
{ "url", data.url }, { "date", data.date }
}
)
);
Again note that the match is tested in the order in which they are added, so that the "default" is to use the URL if the high definition URL does not exist.
var apodImageAgent = new HttpGetImageAgent(Contexts.APOD, "ImageUrl", "ApodImage");
var imageToPictureBoxAgent = new OutputAgent(Contexts.APOD, "ApodImage", "ApodDelay",
data => form.SetImage(data.Image));
var sleepAgent = new SleepAgent(Contexts.APOD, "ApodDelay", "DateCheck", 10000);
var dateCheckAgent = new MatchAgent(Contexts.APOD, "DateCheck", "Date").
Add((context, data) =>
DateTime.Parse(data.date + " 23:59:59") < DateTime.Now, (context, data) => data);
Note that this match has no "default" case -- if there are no further dates to process, the data-flow execution terminates.
var apodNextDateAgent = new MapAgent(Contexts.APOD, "Date", "NextDate")
{
Map = (context, data) =>
CreateExpando(new Dictionary<string, dynamic>()
{
{ "date", DateTime.Parse(data.date).AddDays(1).ToString("yyyy-MM-dd") }
}
)
};
var apodNextImageAgent = new MapAgent(Contexts.APOD, "NextDate", "ApodRequestData", false)
{
ContextData = contextData,
Map = (context, data) =>
CreateExpando(new Dictionary<string, dynamic>()
{
{ "Url", context.Url }, { "ApiKey", context.ApiKey }, { "date", data.date }
}
)
};
At this point, processing continues from the point where we began.
After the agents are created, they are registered in the agent pool:
agentPool.Add(apodUrlMappingAgent);
agentPool.Add(apodAgent);
agentPool.Add(apodMediaTypeFilter);
agentPool.Add(apodBestImageAgent);
agentPool.Add(apodImageAgent);
agentPool.Add(apodTextToControlAgent);
agentPool.Add(imageToPictureBoxAgent);
agentPool.Add(apodNextDateAgent);
agentPool.Add(apodNextImageAgent);
agentPool.Add(sleepAgent);
agentPool.Add(dateCheckAgent);
The application is seeded with the date we want to start the slide show, which I arbitrarily set to the first of year (2018):
private static void RegisterInitialDataLoad()
{
object data = new
{
Context = Contexts.APOD,
Type = "ApodRequestData",
date = "2018-01-01",
};
QueueData(data);
}
The context data contains the "invariant" data, namely the base URL and your API key:
private static object CreateContextData()
{
object data = new
{
Url = "<a href="https:
ApiKey = "[your API key]",
};
return data;
}
I decided to write a helper function for this, which help the mapping operation look more like JSON even though it's a dictionary key-value pair:
private static dynamic CreateExpando(Dictionary<string, dynamic> collection)
{
var obj = (IDictionary<string, object>)new ExpandoObject();
collection.ForEach(kvp => obj[kvp.Key] = kvp.Value);
return obj;
}
The loop for processing data in the data pool, either synchronously or asynchronously:
private static void StartProcessing(Action processor)
{
Task.Run(() =>
{
while (true)
{
dataSemaphore.WaitOne();
processor();
}
});
}
Both synchronous and asynchronous processors are implemented in the methods ProcessSynchronously
and ProcessAsynchronously
respectively.
Data is placed into the data pool with:
public static void QueueData(dynamic data)
{
dataPool.Enqueue(data);
dataSemaphore.Release();
}
Depending on whether you want to perform synchronous or asynchronous processing:
private static void ProcessSynchronously()
{
dataPool.TryDequeue(out dynamic data);
var agents = agentPool.Where(a => a.Context == data.Context && a.DataType == data.Type).ToList();
Log(agents, data);
agents.ForEach(agent => agent.Call(data));
}
private static void ProcessAsynchronously()
{
dataPool.TryDequeue(out dynamic data);
var agents = agentPool.Where(a => a.Context == data.Context && a.DataType == data.Type).ToList();
Log(agents, data);
agents.ForEach(agent => { Task.Run(() => agent.Call((object)data)); });
}
Synchronous processing is easier to work with when debugging.
The logger outputs the agent name, its context and the data type it is processing, which we can view in the console window which is created when we set the output type for the application to Console Window even though it's a WinForm application:
The log looks like this:
In order to avoid runtime compilation of application specific portions of the code, which are the conditions and mapping functions, I investigated using ClearScript so that I could write the conditions and mapping functions in Javascript. This takes us closer to the goal of being able to use pre-canned agents, implemented in the native language, to visually build the application. Only the script code needs to be written, otherwise known as "low-code." From wikipedia: "Low-code development platforms (LCDPs) allow the creation of application software through graphical user interfaces and configuration instead of traditional procedural computer programming."
The code base for ClearScript built without issues -- follow the instructions exactly as described in the ClearScript documentation. Once ClearScript is built, you need to do two things:
1. Add a reference to the ClearScript.dll
2. Copy the other DLL's to the bin\Debug or bin\Release folder:
One of the things you can do with ClearScript is host your C# object which can then be referenced directly in Javascript. In the C#-only example above, there was one "context" object for the invariant data and one "data" object for the variant data. With ClearScript, we can host multiple context objects along with the data object, which is useful if we want to provide multiple invariant contexts from different "applets". This is easily implemented with a string-object dictionary in which the contexts are registered, then hosted:
public void InitializeContextData()
{
ContextData?.ForEach(kvp => engine.AddHostObject(kvp.Key, kvp.Value));
}
This isn't the end of the story though. When you evaluate a Javascript expression, the return type is of type V8ScriptItem
. Combined with the Dynamic Language Runtime (DLR) you can easily access the members of the returned expression with [object].[field]
notation, however, you can't just pass a V8ScriptItem
back to another Javascript function -- nasty things happen, including stack overflow exceptions. We have to go through this hoop instead:
public void HostData(dynamic data)
{
if (data.GetType().Name == "V8ScriptItem" && Keys == null)
{
throw new Exception("Keys must be defined for the host data in this context.datatype: " + Context+"."+DataType);
}
if (data.GetType().Name != "V8ScriptItem")
{
engine.AddHostObject("data", data);
}
else
{
var data2 = new ExpandoObject() as IDictionary<string, object>;
Keys.ForEach(key => data2[key] = data[key]);
engine.AddHostObject("data", data2);
}
}
The above code will host a non-V8ScriptItem
easily enough, calling the host object "data". For a V8ScriptItem
, we have to convert it to an ExpandoObject
. Because we don't know the Javascript members in the V8ScriptItem
object, we need to provide a schema (called "Keys" here) that lets us move the data from the V8ScriptItem
to the ExpandoObject
.
The map agent that works with ClearScript looks like this:
public override void Call(dynamic data)
{
InitializeContextData();
HostData(data);
var resp = Map == null ? engine.Evaluate(data.Map) : engine.Evaluate(Map);
SetContextAndType(data, resp, useAgentContextAndType);
Program.QueueData(resp);
}
Contrast it to the C#-only example earlier:
public override void Call(dynamic data)
{
var resp = Map == null ? ((Func<dynamic, dynamic, bool>)data.Map)(ContextData, data) : Map(ContextData, data);
SetContextAndType(data, resp, useAgentContextAndType);
Program.QueueData(resp);
}
Basically the same thing except for the addition of the context and data hosting.
The match agent is similar, but note the code comment regarding the use of var
vs. dynamic
:
public override void Call(dynamic data)
{
InitializeContextData();
HostData(data);
var match = matches.FirstOrDefault(t => (bool)engine.Evaluate(t.condition) == true);
if (!match.Equals((null, null, false)))
{
dynamic resp = engine.Evaluate(match.map);
SetContextAndType(data, resp, match.useAgentContextAndType);
Program.QueueData(resp);
}
}
Such were the subtleties I discovered working with ClearScript!
All the other agents follow the same pattern as above and are very similar to the C#-only implementation with one nuance -- even though the incoming type for the data is defined as dynamic, we actually have to cast the type to dynamic to access the members. For example, in the HttpGetImageAgent
:
Earlier I said that you could access the member fields with [object].[field]
notation -- this is true within the code of that calls the execute or evaluate method. Once the object is passed around with lambda expressions or inside Task.Run
blocks, the cast to dynamic
suddenly becomes a requirement. I suspect this has to do with the DLR but I have no idea really what is going on.
So, let's look at how the agents are instantiated, this time using Javascript to specify the conditions and mapping. Contrast this with the C#-only code presented earlier. The most significant thing you'll notice is that CreateExpando
from a string-object dictionary is no longer needed, but we've added specifying the return object schema as necessary. Also note that I only show instantiation of agents that use Javascript -- the other agent instantiations are identical as presented in the C#-only code above.
var apodUrlMappingAgent = new MapAgent(Contexts.APOD, "ApodRequestData", "ApodUrl")
{
Keys = new List<string> { "Type", "Url", "ApiKey", "date" },
ContextData = new Dictionary<string, object>() { { "context", contextData } },
Map = "({url: context.Url + '?api_key=' + context.ApiKey + '&date=' + data.date})",
};
var apodMediaTypeFilter = new MatchAgent(Contexts.APOD, "ApodResponseData", "ApodNotVideo").
Add("data.media_type == 'image'", "data").
Add("true", @"({Type: 'Date', date: data.date})", false);
Note here two things:
- First, remember that the matches are tested in the order in which they are added. If the media type is not "image", the second match is processed because it always returns
true
. - We indicate with the
false
that we are overriding the output data type, which is specified in the map.
var apodBestImageAgent = new MatchAgent(Contexts.APOD, "ApodNotVideo", "ImageUrl") { Keys = new List<string> { "url", "hdurl" } }.
Add("data.hdurl !== undefined && data.hdurl != ''", "({url: data.hdurl, date: data.date})").
Add("true", "({url: data.url, date: data.date})");
var dateCheckAgent = new MatchAgent(Contexts.APOD, "DateCheck", "Date") { Keys = new List<string> { "date" } }.
Add("new Date(data.date + ' 23:59') < Date.now()", "data");
Date processing and formatting in Javascript is horrid, as we have to prepend "0" for digits < 10 and deal with UTC time vs. the date in your local time.
var apodNextDateAgent = new MapAgent(Contexts.APOD, "Date", "NextDate")
{
Keys = new List<string> { "date" },
Map = @"
var nextDate = new Date(data.date + ' 0:01');
nextDate.setDate(nextDate.getDate() + 1);
({ date: nextDate.getFullYear()+'-'+('0' + (nextDate.getMonth()+1)).slice(-2) + '-' + ('0' + nextDate.getDate()).slice(-2)})"
};
var apodNextImageAgent = new MapAgent(Contexts.APOD, "NextDate", "ApodRequestData", false)
{
Keys = new List<string> { "date" },
ContextData = new Dictionary<string, object>() { { "context", contextData } },
Map = "({date: data.date,})"
};
While this was fun to implement a hybrid approach, debugging the Javascript was painful and often required using the browser debugger or the ClearScript console window. There is apparently a way that the Javascript can be debugged with a browser but I didn't explore this. I also have no idea what the performance of all this is, so let the buyer beware when using ClearScript to add scripting to a C# application! It also took hours to figure out the nuances of working with ClearScript objects in the particular way that I am using them and ultimately required the "Keys" kludge. So, cool technology, it works great actually, but I don't think this is something you want to take to your team leader and with "I found a great way to add scripting to C#!" I would recommend that you look at CPian JohnLeitch's article on Aphid instead.
Now that you've seen a pure C# and a hybrid C# - Javascript implementation, let's wrap this up with a purely Javascript implementation. The neat thing is, the Javascript scripts we created earlier for the mapping and condition-map matches are 100% usable in the pure Javascript implementation.
There's a few things to note about the Javascript implementation:
- Registering the agents no longer requires specifying the return object schema in the
Keys
property because, well, it's Javascript! - Javascript is single-threaded but some operations, like performing an HTTP GET or sleeping, are asynchronous. This doesn't mean that they run on a separate thread (at least in the script processing) but rather that when they complete, processing resumes. This means that we have to kick-start the data-flow processor if it has exited with nothing to do. I didn't want to deal with rolling my own semaphore mechanism, so it's a bit of hack.
- I am using the evil
eval
statement. This works for now. Remember, eventually we want the user to be able to script the mapping and conditions for already coded agents, so we're going to have to process strings rather than writing functions. This seems a bit obtuse since we're coding everything in Javascript to begin with, but remember, the next step (in the next article) is to visually plop agents onto a data-flow diagram and simply script the salient map, reduce, filter, match, and output functions.
The beauty of the Javascript implementation is that everything is a JSON object and all the weird hoops one has to go through with ExpandoObjects
in the C# implementation goes away. I would have to say that Javascript really excels at working seemlessly with JSON and dictionary key-value data, which makes it something of a natural language for implementing this concept.
Because we're making HTTP GET requests with XMLHttpRequest
, if we simply load the HTML file into the Chrome browser window, we get:
'Access-Control-Allow-Origin' header has a value 'null' that is not equal to the supplied origin.
Origin 'null' is therefore not allowed access.
Yuck. This means that we have to run our HTML page and its Javascript hosted by a server. A one-liner way to do this is to fire up the built in Python server. If you have Python installed, open a console window in the directory that contains the "apodSlideShow.html" file -- one way to do this is to navigate to the folder in Windows, then on the address bar, type "cmd" and press ENTER. Then, depending on the version of Python you have:
Python 2.x:
python -m SimpleHTTPServer
Python 3.x
python -m http.server
You can then navigate to localhost:8000/apodSlideShow.html
to start the slide show.
For some reason, Edge brings up this dialog box:
I've tested the app in JsFiddle, Chrome, and Firefox and it works fine as served by the Python server. But of course, not in Edge. Supposedly this is because the "scheme" is missing, so if you try http://localhost:8000/apodSlideShow.html it never completes the HTTP request. Here's the console log:
Notice that the status is 0 (the readyState is 4):
Logging the callback, we see:
From this document https://developer.mozilla.org/en-US/docs/Web/Guide/AJAX/Getting_Started it appears that this may be the issue:
The second parameter is the URL you're sending the request to. As a security feature, you cannot call URLs on 3rd-party domains by default. Be sure to use the exact domain name on all of your pages or you will get a "permission denied" error when you call open().
Then again, it might also have to do with the local intranet / Local Intranet zone settings. I have no idea, and I don't want to waste my time figuring out Edge issues when it's the only browser that doesn't work "correctly." If anyone has a fix for this, please comment in the comments section of this article.
function processQueue() {
while (app.queue.length > 0) {
let data = app.queue.shift();
agents.filter(agent => agent.context == data.context && agent.dataType == data.type).
map(agent => {
console.log("Invoking " + agent.constructor.name + " : " + data.context + "." + data.type);
agent.call(app.context, data);
});
}
}
let app = initializeApp();
let agents = instantiateAgents();
processQueue();
Notice that the logging looks pretty much identical to the C# output:
Here's the implementation of the different agents. These should look very familiar to the C# counterparts. You'll note however that there is no HttpGetImage
agent as this is relegated to a simple "output" agent that maps the image source to the URL in the HTML.
class MapAgent extends Agent {
constructor(context, dataType, responseDataType, responseContext) {
super(context, dataType, responseDataType, responseContext);
this.useAgentContextAndType = true;
}
call(context, data) {
let fncMap = ((data.map === undefined) ? this.map : data.map);
let resp = eval(fncMap);
this.setContextAndType(data, resp, this.useAgentContextAndType);
publish(resp);
}
}
class MatchAgent extends Agent {
constructor(context, dataType, responseDataType, responseContext) {
super(context, dataType, responseDataType, responseContext);
this.matches = [];
return this;
}
add(condition, map, useAgentContextAndType = true) {
this.matches.push({ condition: condition, map: map, useAgentContextAndType: useAgentContextAndType });
return this;
}
call(context, data) {
for (let i = 0; i < this.matches.length; i++) {
if (eval(this.matches[i].condition)) {
let resp = eval(this.matches[i].map);
this.setContextAndType(data, resp, this.matches[i].useAgentContextAndType);
publish(resp);
break;
}
}
}
}
class OutputAgent extends Agent {
constructor(context, dataType, responseDataType, responseContext, action) {
super(context, dataType, responseDataType, responseContext);
this.action = action;
}
call(context, data) {
this.action(context, data);
this.setContextAndType(data, data);
publish(data);
}
}
class SleepAgent extends Agent {
constructor(context, dataType, responseDataType, responseContext, ms) {
super(context, dataType, responseDataType, responseContext);
this.ms = ms;
}
sleep(time) {
return new Promise((resolve) => setTimeout(resolve, time));
}
call(context, data) {
this.sleep(this.ms).then(() => {
this.setContextAndType(data, data);
publish(data, true);
});
}
}
class HttpGetJsonAgent extends Agent {
constructor(context, dataType, responseDataType, responseContext) {
super(context, dataType, responseDataType, responseContext);
}
call(context, data) {
let req = new XMLHttpRequest();
req.onreadystatechange = publishResponse(this, req, data);
req.open("GET", data.url);
req.send();
}
}
function publishResponse(agent, req, data) {
let myagent = agent;
let myreq = req;
let mydata = data;
return function() {
if (this.readyState == 4 && this.status == 200) {
let resp = JSON.parse(myreq.responseText);
myagent.setContextAndType(mydata, resp);
publish(resp, true);
}
}
}
Here's the entire function -- If you've read the C# implementation above, I don't think I need to break this apart into separate code snippets. Output agents are currently implemented as agents that require a function to update the HTML. I'll have to deal with this when I implement this concept in the visual designer.
function instantiateAgents() {
let agents = [];
let apodUrlMappingAgent = new MapAgent("APOD", "ApodRequestData", "ApodUrl");
apodUrlMappingAgent.map = "({ url: context.Url + '?api_key=' + context.ApiKey + '&date=' + data.date })";
let httpGetJsonAgent = new HttpGetJsonAgent("APOD", "ApodUrl", "ApodResponseData");
let apodMediaTypeFilter = new MatchAgent("APOD", "ApodResponseData", "ApodNotVideo").
add("data.media_type == 'image'", "data").
add("true", "({type: 'Date', date: data.date})", false);
let apodTextToControlAgent = new OutputAgent("APOD", "ApodNotVideo", undefined, undefined, (context, data) => {
document.getElementById("date").innerHTML = data.date;
document.getElementById("title").innerHTML = data.title;
document.getElementById("explanation").innerHTML = data.explanation;
});
let apodBestImageAgent = new MatchAgent("APOD", "ApodNotVideo", "ImageUrl").
add("data.hdurl !== undefined && data.hdurl != ''", "({url: data.hdurl, date: data.date})").
add("true", "({url: data.url, date: data.date})");
let apodImageAgent = new OutputAgent("APOD", "ImageUrl", "ApodDelay", undefined, (context, data) => {
document.getElementById("image").innerHTML = "<image src='" + data.url + "'>";
});
let sleepAgent = new SleepAgent("APOD", "ApodDelay", "DateCheck", undefined, 10000);
let dateCheckAgent = new MatchAgent("APOD", "DateCheck", "Date").
add("new Date(data.date + ' 23:59') < Date.now()", "data");
let apodNextDateAgent = new MapAgent("APOD", "Date", "NextDate");
apodNextDateAgent.map = "var nextDate = new Date(data.date + ' 0:01');
nextDate.setDate(nextDate.getDate() + 1);
({ date: nextDate.getFullYear() + '-' +
('0' + (nextDate.getMonth() + 1)).slice(-2) + '-' +
('0' + nextDate.getDate()).slice(-2) })";
let apodNextImageAgent = new MapAgent("APOD", "NextDate", "ApodRequestData");
apodNextImageAgent.map = "({date: data.date})";
agents.push(apodUrlMappingAgent);
agents.push(httpGetJsonAgent);
agents.push(apodMediaTypeFilter);
agents.push(apodNextDateAgent);
agents.push(apodNextImageAgent);
agents.push(apodTextToControlAgent);
agents.push(apodBestImageAgent);
agents.push(apodImageAgent);
agents.push(sleepAgent);
agents.push(dateCheckAgent);
return agents;
}
The kludgy part here is that the data-flow processor has to be kick-started when an asynchronous operation completes. At this point, there are two agents that are asynchronous and trigger a callback: the HTTP GET agent, and the sleep agent. So, when we publish the response date, we have to do this:
function publish(data, fromAsyncCall = false) {
app.queue.push(data);
if (fromAsyncCall && app.queue.length == 1) {
processQueue();
}
}
Oh well. It works.
The next step is to fold this into the FlowSharpWeb designer. This will really bring home the concept of visual programming with data-centric agents. Instead of coding if-then-else, while, etc., statements, and instead of "visually" programming them with blocks that are nothing more than language flow control objects, leveraging the concepts of map, reduce, and filter, and functional programming concepts such as match, results in a higher level visual programming tool. The ultimate idea is that you can build modular components using the map/filter/reduce/match + "custom" agents and then construct more complex applications from those modules, building out the application as a three dimensional application where the 2D surface is the interaction between modules and the third dimension, the depth, is the detailed implementation of each module, sub-module, etc.