.NET Usage Tutorial
January 8, 2023 ยท View on GitHub
Table of Contents
In this usage tutorial, we are going to build a small demo app that streams a "Hello World" message. To this end, we are going to create 3 functions: a Launcher that starts a HelloWorldGenerator and passes the resulting stream to a StreamPrinter.

Prerequisites
- A cursory understanding of the Concepts
- A .NET Runtime and SDK (version 6.0)
- Either Docker (/Podman) or Java JDK 11 or higher
Starting Perper Fabric
Before we begin, start an instance of Perper Fabric; this will allow us to run parts of the sample as we proceed.
$ docker run --rm -p 10800:10800 -p 40400:40400 obecto/perper-fabric:0.8.0 config/example.xml
(Alternatively, if using a local Perper clone)
From the root of a clone of the Perper repository, run:
$ cd path/to/perper/fabric
fabric$ ./gradlew run --args="config/example.xml"
Note that in 0.8.0-beta1 you might want to occasionally restart Fabric by interrupting (Ctrl-C) the process and running it again, since starting our Launcher over and over will result in many HelloWorldGenerator / StreamPrinter-s running.
Step by step tutorial
Note that if you feel like following the step-by-step tutorial and want to go directly for some off-roads experimentation, you can jump down to the Exploration ideas at the end.
Creating an agent
To create a new agent, create a new Console .NET project and add a reference to the Perper NuGet package:
$ dotnet new console -o MyFirstAgent
$ cd MyFirstAgent
MyFirstAgent$ dotnet add package Perper
(Alternatively, if using a local Perper clone)
Change the last command to:
MyFirstAgent$ dotnet add reference path/to/perper/agent/dotnet/src/Perper
Then, change the default Program.cs boilerplate to:
using Perper.Application;
using Microsoft.Extensions.Hosting;
Host.CreateDefaultBuilder().ConfigurePerper(perper => perper.AddAssemblyHandlers("MyFirstAgent")).Build().Run();
Congratulations, you now have a Perper agent! An empty one, but an agent nonetheless.
If you were to run the project right now, it would print something like the following before exiting:
info: Perper.Application.PerperBuilder[0]
APACHE_IGNITE_ENDPOINT: 127.0.0.1:10800
info: Perper.Application.PerperBuilder[0]
PERPER_FABRIC_ENDPOINT: http://127.0.0.1:40400
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
(Possibly along with a few other stray logs)
What just happened?
- The agent process's entrypoint,
Program.cs, got called. - In turn, it instanced a
HostBuilder/IHost, a standard C# mechanism for managing application lifecycle. - Then, it used
ConfigurePerper, a utility that lets us turn that host into a Perper agent process by providing anIPerperBuilderto add handlers for the different delegates (methods) that may be called on the instances of that agent. - It then called
AddAssemblyHandlers, which went through the classes in the assembly's root namespace that have aRunorRunAsyncmethod and added them as handlers for"MyFirstAgent"to thePerperStartup.AddAssemblyHandlerscan also use another assembly or look for classes filtered by another namespace. Alternatively, there isAddClassHandlerswhich would add all of a class's methods as handlers. Or, one could also useAddHandler/AddInitHandlerto specify handlers manually. For now, we will stick toAddAssemblyHandlers, as it allows us to split our code more easily.
- Finally,
IHost'sRunmethod got called, and it printed some debug information before connecting to Fabric, and then some more debug information. - At this point, we still haven't defined any handlers, so our agent process ends up doing nothing apart from connecting... but we are going to fix in a moment.
Writing the Launcher
Let's start implementing our agent by adding the launcher function, which would then set up the stream graph printing "Hello World". As we don't have anything for that launcher to call yet, it will just directly print "Hello World" for now.
AddAssemblyHandlers expects our launcher to be a class named Deploy somewhere under the MyFirstAgent namespace, so, add a new file called Deploy.cs with the following code:
using System;
using System.Threading.Tasks;
namespace MyFirstAgent // <- AddAssemblyHandlers is going to be looking in this namespace by default.
{
public static class Deploy // <- The class name is used for the handler's name, "Deploy"
// Note that static is not required; non-static handler classes are automatically instanced
{
public static async Task RunAsync() // <- All classes with a Run or RunAsync method are turned into handlers
{
Console.WriteLine("Hello world from Deploy!");
await Task.Delay(1); // <- A delay to avoid the warning about async method lacking await.
}
}
}
Now, if you run the project... you should see something like this:
...
Hello world from Deploy!
Yay!
What just happened?
AddAssemblyHandlersfound ourDeployclass, and usedAddHandlerto register it toPerperStartup, automatically wrapping it in aMethodPerperHandlerand picking aDeployPerperListenerfor it.MethodPerperHandleris a low-level utility class that wraps a .NET method, handling things likeTask/ValueTask/IAsyncEnumerablereturn values.DeployPerperListeneris a service that invokes the handler as soon as it connects to Perper.
- The
DeployPerperListenercalled ourRunAsyncfunction as part of starting the host. - The process remains there, waiting for us to press Ctrl-C. Let's fix that!
Adding and calling another function
Next, let's add the StreamPrinter function and call it. For the time being, we won't actually pass it a stream, but just showcase calling a function in Perper.
Create a StreamPrinter.cs file (similar to the Deploy.cs from before):
using System;
using System.Threading.Tasks;
namespace MyFirstAgent
{
public static class StreamPrinter // <- Creates a handler for the "StreamPrinter" delegate
{
public static async Task RunAsync()
{
Console.WriteLine("Hello world from StreamPrinter!");
await Task.Delay(1);
}
}
}
Then modify Deploy.cs so it would call the StreamPrinter:
using System;
using System.Threading.Tasks;
using Perper.Extensions;
namespace MyFirstAgent
{
public static class Deploy
{
public static async Task RunAsync()
{
Console.WriteLine("Hello world from Deploy!");
await PerperContext.CallAsync("StreamPrinter"); // <- Call the "StreamPrinter" handler on the same agent
}
}
}
And now, running the project should result in something like this:
...
Hello world from Deploy!
Hello world from StreamPrinter!
What just happened?
AddAssemblyHandlersfound ourStreamPrinterclass as well, usingAddHandlerto register it; picking anExecutionPerperListenerthis time around.- The
DeployPerperListenercalled ourDeployhandler. In parallel, theExecutionPerperListenerstarted listening for executions forStreamPrinter. - Our
Deployhandler usedPerperContext.CallAsyncto create a new execution forStreamPrinter. - The
ExecutionPerperListenerpicked up the execution forStreamPrinterand called our handler. - Our
StreamPrinterhandler printed a hello world message. Except... it's still not coming from a stream.
Adding a stream
To meet our initial objective of receiving the hello world message over a stream, let's add the HelloWorldGenerator stream. This would be a slightly bigger change, as we are also going to modify Deploy and StreamPrinter to use the stream.
Create a HelloWorldGenerator.cs file, with the following contents:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace MyFirstAgent
{
public static class HelloWorldGenerator // <- Creates a handler for the "HelloWorldGenerator" delegate
{
public static async IAsyncEnumerable<char> RunAsync() // The IAsyncEnumerable return value makes this a stream handler
{
Console.WriteLine("Hello world from HelloWorldGenerator!");
foreach (var ch in "Hello world through stream!")
{
yield return ch; // (see also https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/async-return-types#async-streams-with-iasyncenumerablet)
await Task.Delay(100); // <- By delaying the time between characters, we'd be able to actually see them pop on screen one-by-one
}
}
}
}
Then modify Deploy.cs to call the new HelloWorldGenerator and pass the resulting stream to StreamPrinter:
using System;
using System.Threading.Tasks;
using Perper.Model;
using Perper.Extensions;
namespace MyFirstAgent
{
public static class Deploy
{
public static async Task RunAsync()
{
Console.WriteLine("Hello world from Deploy!");
PerperStream stream = await PerperContext.CallAsync<PerperStream>("HelloWorldGenerator"); // <- Get an object representing our stream
await PerperContext.CallAsync("StreamPrinter", stream); // <- Pass the PerperStream to StreamPrinter
}
}
}
And finally, modify StreamPrinter.cs to receive and process the stream:
using System;
using System.Threading.Tasks;
using Perper.Model;
using Perper.Extensions;
namespace MyFirstAgent
{
public static class StreamPrinter
{
public static async Task RunAsync(PerperStream streamToPrint) // <- Receive the passed PerperStream as a parameter
{
Console.WriteLine("Hello world from StreamPrinter!");
await foreach (var ch in streamToPrint.EnumerateAsync<char>())
{
Console.Write(ch);
}
}
}
}
And now, running the project should finally result in something looking like this:
...
Hello world from Deploy
Hello world from StreamPrinter!
Hello world from HelloWorldGenerator!
Hello World through stream!
What just happened?
AddAssemblyHandlersfound ourHelloWorldGeneratorclass, and register it as a picking anStreamPerperListener. TheStreamPerperListenerstarted listening for executions related to theHelloWorldGenerator.- Our
Deployhandler got called. - Our
Deployhandler usedPerperContext.CallAsync<>to call"HelloWorldGenerator"and get aPerperStreamrepresenting the resulting stream.- The
PerperStreamis automatically returned by theStreamPerperListener, as it starts a long-running execution for the stream in the background.
- The
- The
StreamPerperListenercalled ourHelloWorldGeneratorhandler, only forMethodPerperHandlerto notice it returns anIAsyncEnumerable. The latter then started waiting for a listener for that stream.- It is possible to make the stream start without waiting for a listener by using
[Perper.Application.PerperStreamOptions(Action=true)]on the stream class.
- It is possible to make the stream start without waiting for a listener by using
- Our
Deployhandler usedPerperContext.CallAsyncto create a new execution forStreamPrinter, and passed it thePerperStreamobject. - Our
StreamPrinterhandler usedPerperStreamExtensions.EnumerateAsyncto start listening for items added to the stream. MethodPerperHandlernoticed that there is a listener and started ourHelloWorldGeneratorhandler, that wrote the hello world text to the stream one character at a time.- Our
StreamPrinterhandler started receiving and printing the hello world message. Success!
Note: This is the first time you might need to restart Fabric between runs. Since stream completion is not yet implemented, StreamPrinter never completes, and restarting the agent process would slowly result in many leftover StreamPrinter-s accumulating. Also, note that stopping the process might take a while, as IHost gives a generous timeout to the StreamPrinter.
Adding another agent
Our code so far achieves the stated goal of streaming a hello world text through Perper. However, to demonstrate more of Perper's functionality and to, uh, promote code reusability, we can move StreamPrinter to its own separate agent.
To this end, make another agent project called StreamPrinterAgent, adding a reference to Perper like before.
Then, again as before, change its Program.cs to:
using Perper.Application;
using Microsoft.Extensions.Hosting;
Host.CreateDefaultBuilder().ConfigurePerper(perper => perper.AddAssemblyHandlers("StreamPrinterAgent")).Build().Run();
Then, move StreamPrinter.cs to the new project, changing it's namespace:
using System;
using System.Threading.Tasks;
using Perper.Model;
using Perper.Extensions;
namespace StreamPrinterAgent // <- AddAssemblyHandlers wants to have namespaces named just like the assembly/project
{
public static class StreamPrinter
{
public static async Task RunAsync(PerperStream streamToPrint)
{
Console.WriteLine("Hello world from StreamPrinterAgent's StreamPrinter!");
await foreach (var ch in streamToPrint.EnumerateAsync<char>())
{
Console.Write(ch);
}
}
}
}
And finally, modify the Deploy.cs in the original project.
using System;
using System.Threading.Tasks;
using Perper.Model;
using Perper.Extensions;
namespace MyFirstAgent
{
public static class Deploy
{
public static async Task RunAsync()
{
Console.WriteLine("Hello world from Deploy!");
PerperStream stream = await PerperContext.CallAsync<PerperStream>("HelloWorldGenerator");
PerperAgent agent = await PerperContext.StartAgentAsync("StreamPrinterAgent"); // <- Create an object representing our agent
await agent.CallAsync("StreamPrinter", stream); // <- Call the agent's StreamPrinter
}
}
}
Now, if you run both projects, in separate terminals, you should see that Deploy and HelloWorldGenerator run in the first agent process / terminal, while StreamPrinter runs in the other agent process / terminal.
... (first terminal)
Hello world from Deploy
Hello world from HelloWorldGenerator!
... (second terminal)
Hello world from StreamPrinterAgent's StreamPrinter!
Hello World through stream!
What just happened?
- The first process's
Host.Runcalled ourDeployhandler. - As before, our
Deployhandler usedPerperContext.CallAsync<>to get aPerperStreamrepresenting aHelloWorldGeneratorstream. - Our
Deployhandler usedPerperContext.StartAgentto create a new instance of theStreamPrinterAgentagent, and get aPerperAgentrepresenting it.- The stream printer's
Host.Runthen would have called theStarthandler, had we provided one.
- The stream printer's
- Our
Deployhandler then usedPerperContext.CallAsyncto create a new execution forStreamPrinter, and passed it thePerperStreamobject. - The stream printer's
PerperStartuppicked the execution up, and started theStreamPrinterhandler. - As before,
StreamPrintersubscribed toHelloWorldGenerator, and the hello world text started flowing across, one character at a time. Woohooo!
And, that's it! You have successfully created two Perper agents; one of which starts the other and streams it a hello world message. Who said hello world had to be complicated?
Usage sample code
You can find the whole code of the usage sample created as part of this tutorial in samples/dotnet/MyFirstAgent and samples/dotnet/StreamPrinterAgent.
Exploration ideas
If you feel like playing around with the sample further, here is a list of things you could try. Do note that restarting Fabric might be needed to clear out stale executions.
- Try running the agents without Fabric / starting Fabric after the agents. Or try running just one of the agents / running them in different order.
- Try changing the
"StreamPrinterAgent"string in bothStreamPrinterAgent/Program.csandMyFirstAgent/Deploy.cs. What happens on mismatch? - Try changing
HelloWorldGenerator's return type.PerperStreamobjects are untyped, so it is possible to enumerate them as the wrong type -- what happens then? - Try calling
StreamPrintermultiple times in parallel with the same stream. (Hint: you might need to useTask.WhenAll) - Make
HelloWorldGeneratoraccept the string to print as a parameter. Also, give it a default value. - Make a
GetHellofunction which returns astring(orTask<string>) with the hello world text to print, then call that instead of hardcoding the string. - Refactor
HelloWorldGeneratorinto its own agent. - Store the
PerperStreamobject forHelloWorldGeneratorinside ofPerperStateinstead of a variable. - Make one of the
Program.cs-s useAddInitHandler<T>/AddHandler<T>instead ofAddAssemblyHandlers. - Make one of the
Program.cs-s useAddClassHandlers<T>instead ofAddAssemblyHandlers. (Hint: move all of the functions in that project to one class, and rename the methods to{Delegate}Asyncinstead ofRunAsync) - Make a new Perper project which includes the other projects, and use multiple invocations of
AddAssemblyHandlers/AddClassHandlersto host all the agents in that one process.
Closing note
Thank you for following this tutorial; I hope it was useful in figuring out the basics of using Perper through C#! If you want to read more in-depth documentation, feel free to check out the Architecture page. Or, if you just want to dive into using the framework, feel free to check out the Reference (currently nonexistent, but the classes in the Perper.Extensions namespace should be a good place to start)!