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
.
- A cursory understanding of the Concepts
- A .NET Runtime and SDK (version 6.0)
- Either Docker (/Podman) or Java JDK 11 or higher
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.
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.
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)
- 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 anIPerperBuilder
to 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 aRun
orRunAsync
method and added them as handlers for"MyFirstAgent"
to thePerperStartup
.AddAssemblyHandlers
can also use another assembly or look for classes filtered by another namespace. Alternatively, there isAddClassHandlers
which would add all of a class's methods as handlers. Or, one could also useAddHandler
/AddInitHandler
to specify handlers manually. For now, we will stick toAddAssemblyHandlers
, as it allows us to split our code more easily.
- Finally,
IHost
'sRun
method 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.
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!
AddAssemblyHandlers
found ourDeploy
class, and usedAddHandler
to register it toPerperStartup
, automatically wrapping it in aMethodPerperHandler
and picking aDeployPerperListener
for it.MethodPerperHandler
is a low-level utility class that wraps a .NET method, handling things likeTask
/ValueTask
/IAsyncEnumerable
return values.DeployPerperListener
is a service that invokes the handler as soon as it connects to Perper.
- The
DeployPerperListener
called ourRunAsync
function as part of starting the host. - The process remains there, waiting for us to press Ctrl-C. Let's fix that!
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!
AddAssemblyHandlers
found ourStreamPrinter
class as well, usingAddHandler
to register it; picking anExecutionPerperListener
this time around.- The
DeployPerperListener
called ourDeploy
handler. In parallel, theExecutionPerperListener
started listening for executions forStreamPrinter
. - Our
Deploy
handler usedPerperContext.CallAsync
to create a new execution forStreamPrinter
. - The
ExecutionPerperListener
picked up the execution forStreamPrinter
and called our handler. - Our
StreamPrinter
handler printed a hello world message. Except... it's still not coming from 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!
AddAssemblyHandlers
found ourHelloWorldGenerator
class, and register it as a picking anStreamPerperListener
. TheStreamPerperListener
started listening for executions related to theHelloWorldGenerator
.- Our
Deploy
handler got called. - Our
Deploy
handler usedPerperContext.CallAsync<>
to call"HelloWorldGenerator"
and get aPerperStream
representing the resulting stream.- The
PerperStream
is automatically returned by theStreamPerperListener
, as it starts a long-running execution for the stream in the background.
- The
- The
StreamPerperListener
called ourHelloWorldGenerator
handler, only forMethodPerperHandler
to 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
Deploy
handler usedPerperContext.CallAsync
to create a new execution forStreamPrinter
, and passed it thePerperStream
object. - Our
StreamPrinter
handler usedPerperStreamExtensions.EnumerateAsync
to start listening for items added to the stream. MethodPerperHandler
noticed that there is a listener and started ourHelloWorldGenerator
handler, that wrote the hello world text to the stream one character at a time.- Our
StreamPrinter
handler 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
.
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!
- The first process's
Host.Run
called ourDeploy
handler. - As before, our
Deploy
handler usedPerperContext.CallAsync<>
to get aPerperStream
representing aHelloWorldGenerator
stream. - Our
Deploy
handler usedPerperContext.StartAgent
to create a new instance of theStreamPrinterAgent
agent, and get aPerperAgent
representing it.- The stream printer's
Host.Run
then would have called theStart
handler, had we provided one.
- The stream printer's
- Our
Deploy
handler then usedPerperContext.CallAsync
to create a new execution forStreamPrinter
, and passed it thePerperStream
object. - The stream printer's
PerperStartup
picked the execution up, and started theStreamPrinter
handler. - As before,
StreamPrinter
subscribed 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?
You can find the whole code of the usage sample created as part of this tutorial in samples/dotnet/MyFirstAgent
and samples/dotnet/StreamPrinterAgent
.
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.cs
andMyFirstAgent/Deploy.cs
. What happens on mismatch? - Try changing
HelloWorldGenerator
's return type.PerperStream
objects are untyped, so it is possible to enumerate them as the wrong type -- what happens then? - Try calling
StreamPrinter
multiple times in parallel with the same stream. (Hint: you might need to useTask.WhenAll
) - Make
HelloWorldGenerator
accept the string to print as a parameter. Also, give it a default value. - Make a
GetHello
function which returns astring
(orTask<string>
) with the hello world text to print, then call that instead of hardcoding the string. - Refactor
HelloWorldGenerator
into its own agent. - Store the
PerperStream
object forHelloWorldGenerator
inside ofPerperState
instead 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}Async
instead ofRunAsync
) - Make a new Perper project which includes the other projects, and use multiple invocations of
AddAssemblyHandlers
/AddClassHandlers
to host all the agents in that one process.
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)!