Creating Custom Commands
The most valuable feature of Petabridge.Cmd is its ability to be extended to support any arbitrary number of user-defined commands, and in this section of the documentation we will explain how.
First, you should read up on how Petabridge.Cmd works.
Important
In Petabridge.Cmd 1.0 we introduced some important structural changes to how CommandPalette
s are handled on the server-side that might break compatibility with palettes developed against Petabridge.Cmd v0.8 and earlier.
Command Syntax and Structure
Petabridge.Cmd is highly extensible and any user can define their own custom commands they wish to expose to the client. This section explains how commands are defined on the server and how those are represented in pbm
for the end-user.
Defining a Command
All commands in Petabridge.Cmd are defined using the following parts:
- Name - The name of the command as it's actually used on the CLI.
- Description - A human-friendly description of what the command does and what type of output it produces.
- Arguments - A set of 0-N arguments that provide the command with data and instructions needed for execution. Some arguments might be marked as required.
Most of the work that goes into creating a command consists of defining a command's arguments and subsequently defining the actor responsible for executing the command itself.
The easiest way to build a command is to use the CommandDefinitionBuilder
, a fluent domain specific language (DSL) that makes it easy to iteratively create a CommandDefinition
, the object that Petabridge.Cmd uses to define a command on both the host and on the pbm
client.
For example, here's an example of how you might define a command inside the Petabridge.Cmd quick start:
public static readonly CommandDefinition CheckMessages =
new CommandDefinitionBuilder().WithName("view")
.WithDescription("Views the set of saved messages on the server")
.WithArgument(
builder =>
builder.WithName("since")
.WithDescription(
"A timerange for messages we wish to view. Values should be in the format of 5s, 5m, 10m, 1h.")
.IsMandatory(false)
.WithDefaultValues("1m", "5m", "30m", "1h", "4hr", "1d")
.WithSwitch("-s").WithSwitch("-S"))
.Build();
Command Arguments
Each argument in a CommandDefinition
can be defined with the following options:
- Name - A human-friendly name for the commandline argument.
- Description - A human-friendly description of what the argument does.
- Switches - the argument as it's represented on the CLI, i.e.
-S
or-s
. Must supply at least 1 switch with every argument for each command, and by convention most developers define their switches with a preceding-
. - IsMandatory - defaults to
false
, but when set totrue
the command to which this argument belongs will fail validation if this argument is not supplied. - AllowMultiple - defaults to
false
, but when set totrue
allows multiple instances of the same argument to by specified for a single instance of the command. - IsFlag - defaults to
false
, but when set totrue
it means that this argument doesn't accept a value. Meaning, just the presence of the argument itself is all that is needed. - DefaultValues - optional. Provides tab-autocomplete hints for pieces of static data back to the end-user.
By encapsulating all of this information into a standardized CommandDefinition
object, this makes it possible for the pbm
client to be able to expose the command's definition to the end-user. It also gives the pbm
client the ability to apply intelligent tab-autocomplete and automatic help documentation for all user-defined commands as an added bonus.
Using Commands
All CommandDefinition
s are used as a type of schema to validate the lexical structure of a Command
. Think of it this was: a CommandDefinition
is a class and Command
is an object, an instance of that class.
Validation
When a Command
is sent to the Petabridge.Cmd.Host
for execution, that command is validated against the rules you defined when specifying the command's arguments and structure. If there's anything that violates the structure of a given command, a validation error will be produced and sent back to the client.
If you wish to test the validity of your commands directly you can create a new instance of the CommandValidator
. Here's an example:
public static readonly CommandValidator Validator =
new CommandValidator(new CommandPalette("cluster", new[] {JoinCmd, LeaveCmd}));
[Fact]
public void ShouldAllowMultipleOfSameArgumentWhenValid()
{
var command = new Command("cluster", "join", new List<Tuple<string, string>>
{
Tuple.Create("-n", "akka.tcp://MyActorSystem@localhost:8080"),
Tuple.Create("-n", "akka.tcp://MyActorSystem@localhost:8081")
});
var validation = Validator.Validate(command);
validation.Should().BeEmpty();
}
Command Palettes
All CommandDefinition
s are grouped together into a unit known as a CommandPalette
in Petabridge.Cmd. A CommandPalette
is unit of work used by other Petabridge.Cmd.Common
tools such as the CommandValidator
and the CommandParser
. After you've finished defining a set of commands, you group those commands together into a CommandPalette
such as this:
// -----------------------------------------------------------------------
// <copyright file="MsgCommands.cs" company="Petabridge, LLC">
// Copyright (C) 2017 - 2017 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
namespace Petabridge.Cmd.QuickStart
{
/// <summary>
/// Contains all of the custom commands used in the Petabridge.Cmd QuickStart
/// </summary>
public static class MsgCommands
{
public static readonly CommandDefinition CheckMessages =
new CommandDefinitionBuilder().WithName("view")
.WithDescription("Views the set of saved messages on the server")
.WithArgument(
builder =>
builder.WithName("since")
.WithDescription(
"A timerange for messages we wish to view. Values should be in the format of 5s, 5m, 10m, 1h.")
.IsMandatory(false)
.WithDefaultValues("1m", "5m", "30m", "1h", "4hr", "1d")
.WithSwitch("-s").WithSwitch("-S"))
.Build();
public static readonly CommandDefinition Echo = new CommandDefinitionBuilder().WithName("echo")
.WithDescription("Echoes a message from the server back to the client")
.WithArgument(
b =>
b.WithName("message")
.WithDescription("The message that will be echoed back from the server to the client.")
.IsMandatory(true)
.WithSwitch("-m")
.WithSwitch("-M"))
.Build();
public static readonly CommandDefinition Write = new CommandDefinitionBuilder().WithName("write")
.WithDescription("Writes a message to the server that can be accessed by other clients.")
.WithArgument(
b =>
b.WithName("message")
.WithDescription("The message that will be written to the server.")
.IsMandatory(true)
.WithSwitch("-m")
.WithSwitch("-M"))
.Build();
public static readonly CommandDefinition Purge = new CommandDefinitionBuilder().WithName("purge")
.WithDescription("Purges all saved messages on the server.").Build();
public static readonly CommandPalette Palette = new CommandPalette("msg",
new[] { Write, Echo, CheckMessages, Purge });
}
}
The example above demonstrates the convention that the Petabridge team uses when developing command palettes for the official Petabridge.Cmd modules, and it's a good template to work from.
Handling Commands
Once you've defined a set of commands, the next thing you need to do in order to make your commands actionable is to define some actors who can handle and process the commands sent to the server.
Note
This information is updated as of Petabridge.Cmd 1.0 - the server-side actor hierarchy for Petabridge.Cmd has since changed and has added support for "Sessions," a feature which allows a single IPbmClient
to execute multiple concurrent commands on the server.
Prior to Petabridge.Cmd 1.0, a single client could only issue one command at a time to a Petabridge.Cmd host so server-side actor hierarchies didn't need to support processing multiple instances of the same request concurrently. That's the new change that developers who created CommandPalette
s prior to v1.0 need to support in order to upgrade.
Every single pbm
or IPbmClient
client connection to the server, including in-process clients, creates a brand new "client handler," and this is the actor responsible for managing the connection to the client. But for every single CommandPalette
registered with the PetabridgeCmd
extension on the host we create an actor responsible for handling that palette's commands.
Ultimately, for every custom CommandPalette
you're going to implement you will need to implement a CommandPaletteHandler
// -----------------------------------------------------------------------
// <copyright file="CommandPaletteHandler.cs" company="Petabridge, LLC">
// Copyright (C) 2017 - 2018 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
using Akka.Actor;
namespace Petabridge.Cmd.Host
{
/// <summary>
/// Used to register a <see cref="CommandPalette" /> with a given <see cref="IActorRef" />.
/// Any <see cref="Command" /> objects that are received by this host which are defined inside
/// <see cref="Palette" /> will be routed to actor created via <see cref="HandlerProps" />.
/// Any commands defined inside a different <see cref="CommandPalette" /> will be routed to the appropriate
/// <see cref="CommandPaletteHandler" /> by the petabridge.cmd.Host.
/// </summary>
public abstract class CommandPaletteHandler
{
protected CommandPaletteHandler(CommandPalette palette)
{
Palette = palette;
}
/// <summary>
/// The set of defined commands handled by <see cref="HandlerProps" />
/// </summary>
public CommandPalette Palette { get; }
/// <summary>
/// The <see cref="Props" /> of the actor who will handle the messages.
/// </summary>
/// <remarks>
/// One instance of the <see cref="HandlerProps" /> actor will be created per-connection.
/// The actor will be terminated when that connection is closed.
/// </remarks>
public abstract Props HandlerProps { get; }
/// <summary>
/// Called when registered by the <see cref="PetabridgeCmd" /> plugin.
/// </summary>
public virtual void OnRegister(PetabridgeCmd plugin)
{
}
}
}
This class provides the PetabridgeCmd
extension with two key pieces of information:
- The
Props
needed to start theCommandPalette
-specific actor and - The
CommandPalette
itself, which is necessary in order to let the server know that it supports these commands in the first place.
Lastly, there's also the OnRegister
method... This can be used to start other auxiliary actors you might need to support the processing of commands.
For instance, in the Petabridge.Cmd quick start we have a shared MessageMemorizerActor
that each of the individual MsgCommandHandlerActor
instances depend on in order to retrieve messages that have been written by other pbm
instances:
public class MsgCommandPaletteHandler : CommandPaletteHandler
{
private Props _underlyingProps;
public MsgCommandPaletteHandler()
: base(MsgCommands.Palette) // registers the command palette with this handler.
{
}
public override Props HandlerProps => _underlyingProps;
/*
* Overriding this method gives us the ability to do things like create the MessageMemorizerActor before HandlerProps gets used
*/
public override void OnRegister(PetabridgeCmd plugin)
{
var memorizer = plugin.Sys.ActorOf(Props.Create(() => new MessageMemorizerActor()), "pbm-msg-memorizier");
// will be used to create a new MsgCommandHandlerActor instance per connection
_underlyingProps = Props.Create(() => new MsgCommandHandlerActor(memorizer));
base.OnRegister(plugin);
}
}
Defining CommandHandlerActor
Instances
The majority of the work that goes into creating custom Petabridge.Cmd Command
s is turning the raw Command
object into an actionable, usable C# message type with all of the correctly parsed parameters. All of the arguments and the values supplied with those arguments will all be pre-parsed and organized into the Command
object itself, but it's still up to you as the end-user to ensure that those values are rendered into useful information.
To help make things a bit easier, we've defined a base class called a CommandHandlerActor
that you can use to automatically validate and subsequently handle any Command
instances that you've defined in your CommandPalette
.
CommandHandlerActor
is a base class that inherits from the Akka.NET ReceiveActor
type, and uses a similar structure:
Process(MsgCommands.Write.Name, HandleWrite);
Process(MsgCommands.Echo.Name, HandleEcho);
Process(MsgCommands.Purge.Name, HandlePurge);
}
public void HandlePurge(Command purge)
{
_messageMemorizer.Tell(MessageMemorizerActor.PurgeMessages.Instance, Sender);
Process(string commandName, Action<Command> handler)
is used much in the same way that Receive<T>
is in a traditional Akka.NET actor, but in this instance it also performs automatic command validation against the CommandDefinition
supplied in your CommandPalette
for this actor and additional bits of error-checking.
The rest of the work you have to carry out in building a custom command consists of scanning through the Command.Arguments
list and converting each argument's values into usable bits of data. Here's another example of this from the quick start:
x => MsgCommands.Echo.ArgumentsByName["message"].Switch.Contains(x.Item1))?
.Item2;
Sender.Tell(new CommandResponse(new MessageMemorizerActor.Message(msg, DateTime.UtcNow, Sender.Path.Name).ToString())); // will echo what was written on commandline
}
public void HandleCheckMessages(Command fetch)
{
// check if we have a timeframe for the message specified
All of the arguments inside a Command
object are organized as a list of CommandArgument
structures, and as you may recall they're entered into the pbm
command line using switches such as -s
, -a
, and so forth. We're scanning through the list of CommandArgument
s and trying to match an argument we're looking up by name against one or more of those switches.
When we find the value we're looking for, in a typical CommandHandlerActor
you'll translate that into a POCO message that you'll want to forward onto another actor who will do the actual processing of the command. If the command is trivial, however, you can simply send a CommandResponse
back to the Sender
immediately.
And this is a key point: all Command
messages MUST be responded to with a CommandResponse
of some sort. Otherwise the client will indefinitely wait for a response to come back. The Sender
of the Command
is always the party you need to send a CommandResponse
reply to.
Response Streams
In some instances, it's helpful to be able to stream back multiple responses in a sequence as part of fulfilling a Command
. This is very easy to do with the CommandResponse
class, and here's an example:
Receive<FetchMessages>(f => f.Since == null, f => // all messages
{
foreach (var msg in _messages)
Sender.Tell(new CommandResponse(msg.ToString(), false));
// by setting final:false we signal to client that more responses are coming
Sender.Tell(CommandResponse.Empty); // tells the client not to expect any more responses (final == true)
});
If you send a reply back to the Sender
with CommandResponse.Final == false
, the pbm
or IPbmClient
will expect additional CommandResponse
messages before this command is finished being handled. At the very end of processing the Command
you can send back a CommandResponse.Empty
to signal that the stream has terminated.
For more information on implementing custom commands, please clone and check out the Petabridge.Cmd Quick Start source code from Github.
Handling Commands with Session Support (1.0 and Above)
Sessions are a new feature introduced to Petabridge.Cmd in 1.0+ - each unique command that is created by a IPbmClient
will create a unique SessionSequencer
actor on the server - responsible for coding incoming Command
s and all outgoing CommandResponse
messages with a long SessionId
that correlates the IPbmClient
's specific session with the response stream on the server.
The key to handling all of this well inside a custom CommandPaletteHandler
- assume that the Sender
of each Command
represents a unique session instance. So for instance, if you're building a streaming command (i.e. log tail
) that can transmit a stream of responses back to a client, make sure you build your log tail
actor to build an internal set of IActorRef
subscribers - because there may be multiple instances of the command running at once for a single client.
Each CommandHandlerActor
is unique to a single IPbmClient
instance - we don't share actors across multiple clients. But a CommandHandlerActor
can be shared across multiple sessions belonging to the same IPbmClient
.
Therefore, you need to make sure that each of your CommandHandlerActor
s can process multiple concurrent requests from the same client. You don't need to explicitly worry about tracking their long SessionId
s - this will be handled automatically by Petabridge.Cmd.Host.