Initial commit

This commit is contained in:
Chris 2024-03-01 14:34:14 +01:00
commit befe5f5d59
15 changed files with 711 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/vendor/
/.phpunit.*
/composer.lock

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# Command Bus for ReactPHP
* Can run monolithic (create a bus and use as is), or distributed (create bus and use clients), or a hybrid.
* All commands called asynchronously using promises and deferreds.
## Installing
```shell
$ composer require noccylabs/react-command-bus:^0.1.0
```
## Usage
```php
// This is enough to setup a local bus.
$bus = new CommandBus();
$bus->register('hello', function (Context $context) {
return new Promise(function (callable $resolve) use ($context) {
return $resolve([ 'message' => "Hello, {$context->name}" ]);
});
});
// You can call it as expected
$bus->execute('hello', [ 'name' => "Bob" ])
->then(function (array $result) {
echo "Result: {$result['message']}\n";
});
// Add a listener, and you can now connect to it!
$bus->addServer($server);
// So using this in another script works as expected, if you consider
// the async flow. See the examples for working examples.
$client = new CommandBusClient();
$client->connect($socket);
$client->execute('hello', [ 'name' => "Bob" ])
->then(function (array $result) {
echo "Result: {$result['message']}\n";
});
// The bus can also notify all clients about important events
$bus->notify('updateCompleted', [ 'info' => [] ]);
// Listening on the bus or client will yield the event
$bus->on('notify', function (string $event, array $data) {});
$client->on('notify', function (string $event, array $data) {});
```

29
composer.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "noccylabs/react-command-bus",
"description": "A command bus for ReactPHP applications",
"type": "library",
"license": "GPL-3.0-or-later",
"autoload": {
"psr-4": {
"NoccyLabs\\React\\CommandBus\\": "src/"
}
},
"authors": [
{
"name": "NoccyLabs",
"email": "labs@noccy.com"
}
],
"require": {
"react/event-loop": "^1.5",
"react/promise": "^3.1",
"react/socket": "^1.15",
"react/stream": "^1.3",
"react/promise-timer": "^1.10",
"symfony/uid": "^6.0|^7.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"phpstan/phpstan": "^1.10"
}
}

33
examples/local.php Normal file
View File

@ -0,0 +1,33 @@
<?php
require_once __DIR__."/../vendor/autoload.php";
use NoccyLabs\React\CommandBus\CommandBus;
use NoccyLabs\React\CommandBus\CommandRegistry;
use NoccyLabs\React\CommandBus\Context;
use React\EventLoop\Loop;
use React\Promise\Promise;
$commands = new CommandRegistry();
$commands->register("hello", function (Context $context) {
return new Promise(function (callable $resolve) use ($context) {
Loop::addTimer(1, function () use ($context, $resolve) {
$resolve("Hello, {$context->name}");
});
});
});
$commands->register("hello2", function (Context $context) {
return "Hello2, {$context->name}";
});
$bus = new CommandBus($commands);
$bus->execute('hello', ['name'=>'Bob'])
->then(function ($result) {
var_dump($result);
});
$bus->execute('hello2', ['name'=>'Bob'])
->then(function ($result) {
var_dump($result);
});

45
examples/server.php Normal file
View File

@ -0,0 +1,45 @@
<?php
require_once __DIR__."/../vendor/autoload.php";
use NoccyLabs\React\CommandBus\CommandBus;
use NoccyLabs\React\CommandBus\CommandBusClient;
use NoccyLabs\React\CommandBus\CommandRegistry;
use NoccyLabs\React\CommandBus\Context;
use React\Promise\Promise;
use React\Socket\SocketServer;
$commands = new CommandRegistry();
// Register some function to call. The name here is "hello", and it will
// receive a Context holding the call context. Any passed data will be
// available as properties on the Context object.
$commands->register("hello", function (Context $context) {
// You don't have to, but you should return a promise from your
// commands.
return new Promise(function (callable $resolve) use ($context) {
$resolve("Hello, {$context->name}");
});
});
// Create the CommandBus and pass the CommandRegistry
$bus = new CommandBus($commands);
$server = new SocketServer("tcp://127.0.0.1:9999");
$bus->addServer($server);
// The server is sorted, now for the client!
$client = new CommandBusClient();
$client->on(CommandBusClient::EVENT_CONNECTED,
function () use ($client, $bus) {
$client->execute('hello', ['name'=>'Bob'])
->then(function ($result) use ($client,$bus) {
// Result from the call
var_dump($result);
// Shut down after receiving the response
$bus->close();
$client->close();
});
}
);
$client->connect("tcp://127.0.0.1:9999");

12
phpstan.neon Normal file
View File

@ -0,0 +1,12 @@
parameters:
level: 5
excludePaths:
- doc
- vendor
- tests
# Paths to include in the analysis
paths:
- src

40
src/Command.php Normal file
View File

@ -0,0 +1,40 @@
<?php
namespace NoccyLabs\React\CommandBus;
use React\Promise\Deferred;
use React\Promise\Promise;
use React\Promise\PromiseInterface;
/**
*
*/
class Command
{
/** @var string $name The command name */
private string $name;
/** @var callable $handler The handler */
private $handler;
public function __construct(string $name, callable $handler)
{
$this->name = $name;
$this->handler = $handler;
}
public function getName(): string
{
return $this->name;
}
public function call(Context $context): PromiseInterface
{
return new Promise(function (callable $resolve) use ($context) {
$resolve(call_user_func($this->handler, $context));
return;
});
}
}

115
src/CommandBus.php Normal file
View File

@ -0,0 +1,115 @@
<?php
namespace NoccyLabs\React\CommandBus;
use Evenement\EventEmitterTrait;
use React\Promise\PromiseInterface;
use React\Promise\Promise;
use React\Promise\Deferred;
use React\Socket\ServerInterface;
use React\Stream\DuplexStreamInterface;
use SplObjectStorage;
class CommandBus implements CommandBusInterface
{
use EventEmitterTrait;
private CommandRegistry $commandRegistry;
private SplObjectStorage $connections;
private SplObjectStorage $servers;
public function __construct(CommandRegistry $commandRegistry)
{
$this->commandRegistry = $commandRegistry;
$this->connections = new SplObjectStorage();
$this->servers = new SplObjectStorage();
}
public function addServer(ServerInterface $server): void
{
$this->servers->attach($server);
$server->on('connection', $this->onServerConnection(...));
}
public function removeServer(ServerInterface $server): void
{
$server->close();
$server->removeListener('connection', $this->onServerConnection(...));
$this->servers->detach($server);
}
public function close(): void
{
foreach ($this->servers as $server) {
$this->removeServer($server);
}
}
private function onServerConnection(DuplexStreamInterface $client): void
{
$this->connections->attach($client);
$client->on('data', function ($data) use ($client) {
try {
$message = Message::fromString($data);
$this->onClientMessage($client, $message);
} catch (MessageException $e) {
$client->end('{"msg":"error","data":{"error":"Bad message format"}}');
}
});
$client->on('close', function () use ($client) {
$this->connections->detach($client);
});
}
private function onClientMessage(DuplexStreamInterface $client, Message $message): void
{
switch ($message->getType()) {
case Message::MSGTYPE_EXECUTE: // Client call to execute command
$data = $message->getData();
$context = new Context($data['command'],$data['context']);
$this->executeContext($context)->then(
function ($result) use ($message, $client) {
$client->write($message->asResult($result)->toJson()."\n");
}
);
break;
case Message::MSGTYPE_RESULT: // Result from execution on client
// TODO implement me
break;
case Message::MSGTYPE_REGISTRY: // command registry actions
// TODO implement me
break;
default:
$client->end('{"msg":"error","data":{"error":"Unexpected message type"}}');
}
}
/**
* {@inheritDoc}
*/
public function execute(string $command, array $context): PromiseInterface
{
$context = new Context($command, $context);
return $this->executeContext($context);
}
private function executeContext(Context $context): PromiseInterface
{
$command = $this->commandRegistry->find($context->getCommandName());
if (!$command) return new Promise(function (callable $resolve) use ($context) {
throw new \RuntimeException("Unable to resolve command: ".$context->getCommandName());
});
return $command->call($context);
}
/**
* {@inheritDoc}
*/
public function notify(string $event, array $data): void
{
}
}

150
src/CommandBusClient.php Normal file
View File

@ -0,0 +1,150 @@
<?php
namespace NoccyLabs\React\CommandBus;
use Evenement\EventEmitterTrait;
use React\EventLoop\Loop;
use React\Promise\PromiseInterface;
use React\Promise\Promise;
use React\Promise\Deferred;
use React\Socket\ConnectorInterface;
use React\Socket\TcpConnector;
use React\Stream\DuplexStreamInterface;
use Throwable;
class CommandBusClient implements CommandBusInterface
{
use EventEmitterTrait;
const EVENT_ERROR = 'error';
const EVENT_CONNECTED = 'connected';
const EVENT_DISCONNECTED = 'disconnected';
private ?CommandRegistry $commandRegistry = null;
private ConnectorInterface $connector;
private ?DuplexStreamInterface $connection = null;
/** @var array<string,Deferred> */
private array $pending = [];
private string $address;
public function __construct(?CommandRegistry $commandRegistry = null, ?ConnectorInterface $connector = null)
{
$this->commandRegistry = $commandRegistry;
if ($commandRegistry) {
$commandRegistry->on(CommandRegistry::EVENT_REGISTERED, $this->onCommandRegistered(...));
$commandRegistry->on(CommandRegistry::EVENT_UNREGISTERED, $this->onCommandUnregistered(...));
}
$this->connector = $connector??new TcpConnector();
}
public function connect(string $address): void
{
$this->address = $address;
$this->reconnect();
}
public function close(): void
{
$this->connection->close();
$this->connection->removeAllListeners();
$this->connection = null;
}
private function reconnect(): void
{
if ($this->connection) {
$this->close();
}
$this->connector->connect($this->address)->then(
function (DuplexStreamInterface $connection) {
$this->connection = $connection;
$this->emit(self::EVENT_CONNECTED);
$connection->on('close', function () {
$this->emit(self::EVENT_DISCONNECTED);
});
$connection->on('data', function ($data) use ($connection) {
try {
$message = Message::fromString($data);
$this->onServerMessage($message);
} catch (MessageException $e) {
$connection->end('{"msg":"error","data":{"error":"Bad message format"}}');
}
});
},
function (Throwable $e) {
$this->emit(self::EVENT_ERROR, [ $e->getMessage(), $e ]);
Loop::addTimer(5, $this->reconnect(...));
}
);
}
private function onServerMessage(Message $message): void
{
// fprintf(STDERR, "onServerMessage: %s\n", $message->toJson());
switch ($message->getType()) {
case Message::MSGTYPE_EXECUTE: // server call to execute command
// TODO implement me
break;
case Message::MSGTYPE_RESULT: // result from server
$uuid = $message->getUuid();
$result = $message->getData()['result'];
if (array_key_exists($uuid, $this->pending)) {
$this->pending[$uuid]->resolve($result);
unset($this->pending[$uuid]);
}
break;
case Message::MSGTYPE_REGISTRY: // command registry actions
// TODO implement me
break;
default:
$this->connection->end('{"msg":"error","data":{"error":"Unexpected message type"}}');
}
}
private function onCommandRegistered(string $command): void
{
if ($this->connection && $this->connection->isWritable()) {
$msg = new Message(Message::MSGTYPE_REGISTRY, [ 'register' => [ $command ]]);
$this->connection->write($msg->toJson());
}
}
private function onCommandUnregistered(string $command): void
{
if ($this->connection && $this->connection->isWritable()) {
$msg = new Message(Message::MSGTYPE_REGISTRY, [ 'unregister' => [ $command ]]);
$this->connection->write($msg->toJson());
}
}
/**
* {@inheritDoc}
*/
public function execute(string $command, array $context): PromiseInterface
{
$deferred = new Deferred();
$message = new Message(Message::MSGTYPE_EXECUTE, [
'command' => $command,
'context' => $context
]);
$this->pending[$message->getUuid()] = $deferred;
// fprintf(STDERR, "write: %s\n", $message->toJson());
$this->connection->write($message->toJson()."\n");
return $deferred->promise();
}
/**
* {@inheritDoc}
*/
public function notify(string $event, array $data): void
{
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace NoccyLabs\React\CommandBus;
interface CommandBusException
{
}

View File

@ -0,0 +1,33 @@
<?php
namespace NoccyLabs\React\CommandBus;
use Evenement\EventEmitterInterface;
use React\Promise\PromiseInterface;
interface CommandBusInterface extends EventEmitterInterface
{
/** @var string Emitted to distribrute notifications */
const EVENT_NOTIFY = "notify";
/**
* Execute a command on the command bus, and return a promise that will be
* resolved once the command has been handled.
*
* @param string $command The name of the command to execute
* @param array $context Data to pass in the call context
* @®eturn PromiseInterface A promise that will be resolved with the result
*/
public function execute(string $command, array $context): PromiseInterface;
/**
* Notify all connected clients to emit the notify event, and also be
* emitted locally. These messages are unsolicited, and can not be
* responded to. They are fire-and-forget.
*
* @param string $event The application-specific event name
* @param array $data Event-specific data
* @return void
*/
public function notify(string $event, array $data): void;
}

53
src/CommandRegistry.php Normal file
View File

@ -0,0 +1,53 @@
<?php
namespace NoccyLabs\React\CommandBus;
use Evenement\EventEmitterInterface;
use Evenement\EventEmitterTrait;
/**
* A collection of commands that can be executed via CommandBusInterface
*
*/
class CommandRegistry implements EventEmitterInterface
{
use EventEmitterTrait;
const EVENT_REGISTERED = 'registered';
const EVENT_UNREGISTERED = 'unregistered';
/** @var array<string,Command> */
private array $commands = [];
public function register(string $command, callable $handler): void
{
$isNew = !array_key_exists($command, $this->commands);
$this->commands[$command] = new Command($command, $handler);
if ($isNew) {
$this->emit(self::EVENT_REGISTERED, [ $command ]);
}
}
public function unregister(string $command): void
{
if (!array_key_exists($command, $this->commands)) {
return;
}
unset($this->commands[$command]);
$this->emit(self::EVENT_UNREGISTERED, [ $command ]);
}
public function find(string $command): ?Command
{
return $this->commands[$command] ?? null;
}
public function getNames(): array
{
return array_keys($this->commands);
}
}

41
src/Context.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace NoccyLabs\React\CommandBus;
/**
* The context contains all the data relating to calling a command. It has an
* unique identifier, the command to call, and the payload data.
*
*/
class Context
{
/** @var string $commandName The command name to call */
private string $commandName;
/** @var array<string,mixed> The payload data */
private array $payload = [];
public function __construct(string $commandName, array $payload)
{
$this->commandName = $commandName;
$this->payload = $payload;
}
public function getCommandName(): string
{
return $this->commandName;
}
public function getPayload(): array
{
return $this->payload;
}
public function __get($name)
{
return $this->payload[$name] ?? null;
}
}

88
src/Message.php Normal file
View File

@ -0,0 +1,88 @@
<?php
namespace NoccyLabs\React\CommandBus;
use JsonSerializable;
use Symfony\Component\Uid\Uuid;
/**
* A serializable message frame
*
*/
class Message implements JsonSerializable
{
/** @var string Execute request */
const MSGTYPE_EXECUTE = 'execute';
/** @var string Execute result */
const MSGTYPE_RESULT = 'result';
/** @var string Notify event */
const MSGTYPE_NOTIFY = 'notify';
/** @var string Registry update (command list set and update) */
const MSGTYPE_REGISTRY = 'registry';
/** @var string $uuid The message identifier */
private string $uuid;
private string $messageType;
private array $messageData;
public function __construct(string $messageType, array $messageData = [], ?string $uuid = null)
{
$this->uuid = ($uuid && Uuid::isValid($uuid)) ? $uuid : (string)Uuid::v7();
$this->messageType = $messageType;
$this->messageData = $messageData;
}
public function getUuid(): string
{
return $this->uuid;
}
public function getType(): string
{
return $this->messageType;
}
public function getData(): array
{
return $this->messageData;
}
/**
*
* @param string $data The JSON-encoded message data
* @return Message
* @throws MessageException if the message can not be parsed
*/
public static function fromString(string $data): Message
{
$json = @json_decode($data, true);
if (!$json || empty($json['msg'])) {
throw new MessageException("Invalid data");
}
return new Message($json['msg'], $json['data'], $json['uuid']);
}
public function asResult($result): Message
{
return new Message(self::MSGTYPE_RESULT, [
'result' => $result
], $this->uuid);
}
public function toJson(): string
{
return json_encode($this, JSON_UNESCAPED_SLASHES);
}
public function jsonSerialize(): array
{
return [
'uuid' => $this->uuid,
'msg' => $this->messageType,
'data' => $this->messageData
];
}
}

9
src/MessageException.php Normal file
View File

@ -0,0 +1,9 @@
<?php
namespace NoccyLabs\React\CommandBus;
use RuntimeException;
class MessageException extends RuntimeException implements CommandBusException
{
}