Click here to Skip to main content
14,934,026 members
Articles / Web Development / HTML
Article
Posted 4 May 2018

Stats

6.1K views
58 downloads
3 bookmarked

An APOD Slideshow Demonstrating Visual Computing Concepts with Data-Centric Agents

Rate me:
Please Sign up or sign in to vote.
5.00/5 (5 votes)
4 May 2018CPOL25 min read
Examples in C#, C# with ClearScript + Javascript, and pure Javascript

Image 1Image 2

Contents

Introduction

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:

Image 3

to, please, NOT this (from MagPi Magazine's review of Scratch):

Image 4

but more like this (screenshot from Node-RED tool)

Image 5

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:

Image 6

is again, too low level and workflow centric rather than data-centric.

The Proposed Solution

My proposed solution to this borrows from functional programming and adds a few things. 

Operators

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.

Data-Aware Agents

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!

A Simple Example - A Counter

Here's a simple "count from 1 to 10" example. 

Image 7

In this example, we have three agents:

  1. An agent that logs the data packet content to the console.
  2. 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.
  3. 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:

Image 8

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!

A Word About...

The API Key from NASA

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 ClearScript DLL's

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.

The Projects

  • 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.

Context and Type

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.

Dynamic and ExpandoObject

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.

Agent Notation

Visually, I'm going to use the following "formal" notation for the various agents.

Map Agent

Image 9

The map agent:

  1. Accepts the specified input type.
  2. Executes the mapping script that transforms the data.
  3. 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.

Match Agent

Image 10

The match agent, in the order in which the match qualifier-maps are specified:

  1. Accepts the specified input type.
  2. Executes the qualifier script.
  3. If the qualifier script returns true:
    1. The associated mapping script is executed.
    2. The resulting mapped data is published with the output type unless overridden by the mapping script.
    3. Processing of matches stops when the first qualifier that returns true is found.
  4. If the qualifier script returns false, the next match is tested.
  5. If no matches are found, no data is published, which terminates that data flow for the particular flow-branch.

Output Agent

Image 11

An output agent:

  1. Accepts the specified input type.
  2. Executes the function specified in the constructor that provides the interface for outputting data to the desired "device."
  3. Publishes the input data with the specified output type.

Custom Agent

Image 12

A custom agent executes what an agent from an existing library or a custom agent that the user created.  It:

  1. Accepts the specified input type.
  2. Performs some operation.
  3. 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

A More Complicated Example - Astronomy Picture of the Day (APOD) Slide Show

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:

  1. Pure C# - no scripting, the application specific "code" is implemented as C# anonymous lambda expressions.
  2. A hybrid of C# for the agents and Javascript, interpreted using ClearScript, for the application specific conditionals and maps.
  3. A pure Javascript implementation that can be run in a browser.

What Does the APOD Response Look Like?

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://apod.nasa.gov/apod/image/1801/PerseusCluster_DSSChandra_3600.jpg", 
  "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.

Agents

The agents and their scripts that we need for an APOD slideshow are defined next.  There are a few things to note:

  1. 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.
  2. 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.
  3. 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.

Image 13

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.

Image 14

 Executes an HTTP GET and publishes the resulting JSON.

 Image 15

 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 }

Image 16

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.

 Image 17

If a high definition version of the image exists, then we use that image, otherwise we use the URL in the JSON "url" field.

Image 18

Retrieves the image from the provided url value.

Image 19

Displays the image in the picture box.

Image 20

Pauses on the image for defined seconds.

Image 21

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.

Image 22

Maps the current date to the next date and publishes as "NextDate" type.

Image 23

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:

Image 24

C# Only Implementation

Image 25

We'll look first at an implementation written just in C#.

Agents

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.

Agent Base Class

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.

Map Agent

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:

  1. Receiving the data.
  2. Using either the mapping function specified when the agent is instantiated or the mapping function specified in the data packet itself.
  3. Publishing the mapped data.

HTTP Get JSON Agent

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.

HTTP Get Image Agent

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.

Output Agent

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.

Sleep Agent

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.

Match Agent

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.

Agent Instantiation

The following shows the code for instantiating each agent.

Mapping the URL, API Key and Date

Image 26

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 }
  })
};

Acquiring the JSON from the APOD Web Service

Image 27

var apodAgent = new HttpGetJsonAgent(Contexts.APOD, "ApodUrl", "ApodResponseData");

Matching the Media Type

Image 28

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:

  1. 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.
  2. We indicate with the false that we are overriding the output data type, which is specified in the map.

Output Date, and Title Explanation

Image 29

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)
  {
    // We want a synchronous call here!!!!
    control.Invoke((Delegate)action);
  }
  else
  {
    action();
  }
}

Get the High Definition Image if Possible

Image 30

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.

Get the Image

Image 31

var apodImageAgent = new HttpGetImageAgent(Contexts.APOD, "ImageUrl", "ApodImage");

Display the Image

Image 32

var imageToPictureBoxAgent = new OutputAgent(Contexts.APOD, "ApodImage", "ApodDelay", 
  data => form.SetImage(data.Image));

Pause for Specified Time

Image 33

var sleepAgent = new SleepAgent(Contexts.APOD, "ApodDelay", "DateCheck", 10000);

More Dates to Process?

Image 34

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.

Get the Next Date

Image 35

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") } 
    }
  )
};

Form the Request for the next APOD

Image 36

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.

Registering the Agents

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);

Seeding the Application

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

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://api.nasa.gov/planetary/apod">https://api.nasa.gov/planetary/apod</a>",
    ApiKey = "[your API key]",
  };

  return data;
}

Creating the ExpandoObject

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 Process Loop

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:

Image 37

The log looks like this:

Image 38

C# and ClearScript Implementation

Image 39

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."

Working with ClearScript

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

Image 40

2. Copy the other DLL's to the bin\Debug or bin\Release folder:

Image 41

Hosting C# Objects

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));
}

The Nuances of Hosting C# Objects

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 it's a V8ScriptItem, then the Keys must be defined!
  if (data.GetType().Name == "V8ScriptItem" && Keys == null)
  {
    throw new Exception("Keys must be defined for the host data in this context.datatype: " + Context+"."+DataType);
  }

  // Kludge because the data returned from a map of a match is a V8ScriptItem which cannot be added as a host object!
  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.

Invoking the Script

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)))
  {
    // Can't use var here! Why not, I used var in MapAgent!
    dynamic resp = engine.Evaluate(match.map);

    // and if the Context and Type has been deleted from a map operation, nothing can coerce the Context back in to the ExpandoObject.
    // For example, this doesn't work - an exception is thrown that the ExpandoObject doesn't have Context.
    // ((dynamic)resp).Context = ResponseContext ?? data.Context;
    // Casting to an IDictionary doesn't work either!

    SetContextAndType(data, resp, match.useAgentContextAndType);
    Program.QueueData(resp);
  }
}

Such were the subtleties I discovered working with ClearScript!

Instantiating Agents with Javascript Scripts

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:

Image 42

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.

Mapping the URL, API Key and Date

Image 43

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})",
};

Matching the Media Type

Image 44

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:

  1. 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.
  2. We indicate with the false that we are overriding the output data type, which is specified in the map.

Get the High Definition Image if Possible

Image 45

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})");

More Dates to Process?

Image 46

var dateCheckAgent = new MatchAgent(Contexts.APOD, "DateCheck", "Date") { Keys = new List<string> { "date" } }.
  Add("new Date(data.date + ' 23:59') < Date.now()", "data");

Get the Next Date

Image 47

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)})"
};

Form the Request for the next APOD

Image 48

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,})"
};

Final Word on the C# - ClearScript Implementation

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.

Pure Javascript Implementation

Image 49

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:

  1. Registering the agents no longer requires specifying the return object schema in the Keys property because, well, it's Javascript!
  2. 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.
  3. 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.

Why Do I Need a Server to Run the Demo?

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.

Don't Use Edge

For some reason, Edge brings up this dialog box:

Image 50

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:

Image 51

Notice that the status is 0 (the readyState is 4):

Image 52

Logging the callback, we see:

Image 53

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.

Initialization and Processing the Data-Flow Queue

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:

Image 54

The Agents

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.

The Map Agent

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);
  }
}

The Match Agent

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;
      }
    }
  }
}

The Output Agent

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);
  }
}

The Sleep Agent

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);
      // Sleep is an async function, so we need to kick the data pool processor.
      publish(data, true);
    });
  }
}

The HttpGetJsonAgent

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) {
  // Closure:
  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);
    }
  }
}

Instantiating the Agents

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;
}

Asynchronous Nuances

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 the queue is empty, the loop has exited, so restart the queue processing.
  if (fromAsyncCall && app.queue.length == 1) {
    processQueue();
  }
}

Oh well.  It works.

Conclusion

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.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Marc Clifton
Architect Interacx
United States United States
Blog: https://marcclifton.wordpress.com/
Home Page: http://www.marcclifton.com
Research: http://www.higherorderprogramming.com/
GitHub: https://github.com/cliftonm

All my life I have been passionate about architecture / software design, as this is the cornerstone to a maintainable and extensible application. As such, I have enjoyed exploring some crazy ideas and discovering that they are not so crazy after all. I also love writing about my ideas and seeing the community response. As a consultant, I've enjoyed working in a wide range of industries such as aerospace, boatyard management, remote sensing, emergency services / data management, and casino operations. I've done a variety of pro-bono work non-profit organizations related to nature conservancy, drug recovery and women's health.

Comments and Discussions

 
-- There are no messages in this forum --