Great job kicking things off with a UML diagram!
In an ideal scenario using the command pattern, you should be able to execute any command without prior knowledge of the specific commands or their availability. This is because each command acts as a self-contained object. However, if you are creating a command instance based on stringified data, there comes a point where you need to map a string name to its corresponding class implementation.
Your UML includes a reference to a CommandListener
. Although I'm not entirely sure about the server-side execution details, it might be beneficial for each ConcreteCommand
to attach its own listener. This listener could then listen to all commands and handle those that match its specified name.
Another approach could involve having a centralized TaskParser
, responsible for converting all JSON actions into Command
objects and assigning them to the invoker (referred to as
CommandRunner</code in this case). Each <code>ConcreteCommand
would need to register itself with the
TaskParser
.
When considering what a Command
requires to be created from a JSON string, a generic StringableCommand
dependent on parameters Args
was initially conceptualized. It features a name
attribute for identification purposes, can be constructed based on Args
, and includes a method called validateArgs
, which acts as a type guard. If validateArgs
returns true
, the args
array is confirmed to belong to type
Args</code, thereby making it safe to invoke <code>new
.
interface StringableCommand<Args extends any[]> extends Command {
new(...args: Args): Command;
name: string;
validateArgs( args: any[] ): args is Args;
}
It's important to note that this interface applies to the class prototype rather than the class instance, which can lead to some confusion. Therefore, we refrain from specifying that our class extends StringableCommand
. To make validateArgs
accessible via the prototype, it must be implemented as a static method. As per JavaScript principles, we do not need to explicitly implement name
since it already exists.
However, employing generics within StringableCommand
may complicate matters for our TaskParser
, which deals with multiple command types. Thus, defining a constructible command as one featuring a fromArgs
method that accepts an array of unknown arguments and either returns a Command
or raises an Error
could potentially simplify the process. The class could handle validation internally, or rely on the constructor to throw errors (which TypeScript would need to ignore).
interface StringableCommand {
name: string;
fromArgs( ...args: any[] ): Command; // or throw an error
}
The JSON string being parsed exhibits the following structure:
interface ActionPayload {
action: string;
tasks: TaskPayload[];
}
interface TaskPayload {
command: string;
parameters: any[];
timestamp: number;
id: string;
}
For ease of passing parameters to the command constructor, I've altered your parameters
structure to be an array instead of a keyed object.
A simplified version of our TaskParser
intended to resolve these commands resembles the snippet provided below:
class TaskParser {
private readonly runner: CommandRunner;
private commandConstructors: Record<string, StringableCommand> = {}
private errors: Error[] = [];
constructor(runner: CommandRunner) {
this.runner = runner;
}
public registerCommand( command: StringableCommand ): void {
this.commandConstructors[command.name] = command;
}
public parseJSON( json: string ): void {
const data = JSON.parse(json) as ActionPayload;
data.tasks.forEach(({command, parameters, id, timestamp}) => {
const commandObj = this.findCommand(command);
if ( ! commandObj ) {
this.storeError( new Error(`invalid command name ${command}`) );
return;
}
try {
const c = commandObj.fromArgs(parameters);
this.runner.addCommand(c, timestamp, id);
} catch (e) { // catch errors thrown by `fromArgs`
this.storeError(e);
}
});
}
private findCommand( name: string ): StringableCommand | undefined {
return this.commandConstructors[name];
}
private storeError( error: Error ): void {
this.errors.push(error);
}
}
This class functions alongside a placeholder CommandRunner
which currently serves as a stub due to insufficient insight into the expected system behavior. In practice, this would be the component responsible for invoking/executing the commands.
class CommandRunner {
addCommand( command: Command, timestamp: number, id: string ): void { // whatever args you need
// perform necessary operations
}
}
For further exploration and experimentation, feel free to check out the Typescript Playground Link.