[FEATURE] add events and the event broker

This commit is contained in:
Marco Stoll 2019-06-21 10:22:57 +02:00
parent 3b0ad843ff
commit 588a41abfe
7 changed files with 837 additions and 0 deletions

View File

@ -0,0 +1,70 @@
<?php
/**
* Definition of AbstractEvent
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Events;
use FF\Factories\ClassLocators\ClassIdentifierAwareInterface;
/**
* Class AbstractEvent
*
* @package FF\Events
*/
abstract class AbstractEvent implements ClassIdentifierAwareInterface
{
/**
* For use with the BaseNamespaceClassLocator of the EventsFactory
*/
const COMMON_NS_SUFFIX = 'Events';
/**
* @var bool
*/
protected $isCanceled = false;
/**
* @return bool
*/
public function isCanceled(): bool
{
return $this->isCanceled;
}
/**
* @param bool $isCanceled
* @return $this
*/
public function setIsCanceled(bool $isCanceled)
{
$this->isCanceled = $isCanceled;
return $this;
}
/**
* @return $this
*/
public function cancel()
{
return $this->setIsCanceled(true);
}
/**
* {@inheritDoc}
*/
public static function getClassIdentifier(): string
{
$className = get_called_class();
$needle = '\\' . self::COMMON_NS_SUFFIX . '\\';
$pos = strpos($className, $needle);
if ($pos === false) return $className;
return substr($className, $pos + strlen($needle));
}
}

View File

@ -0,0 +1,244 @@
<?php
/**
* Definition of EventBroker
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services;
use FF\DataStructures\IndexedCollection;
use FF\DataStructures\OrderedCollection;
use FF\Events\AbstractEvent;
use FF\Factories\Exceptions\ClassNotFoundException;
use FF\Services\Factories\EventsFactory;
/**
* Class EventBroker
*
* @package FF\Services
*/
class EventBroker extends AbstractService
{
/**
* @var IndexedCollection
*/
protected $subscriptions;
/**
* @var EventsFactory
*/
protected $eventsFactory;
/**
* {@inheritDoc}
*/
protected function initialize(array $options)
{
parent::initialize($options);
$this->subscriptions = new IndexedCollection();
$this->eventsFactory = EventsFactory::getInstance();
}
/**
* @return EventsFactory
*/
public function getEventsFactory(): EventsFactory
{
return $this->eventsFactory;
}
/**
* @return IndexedCollection
*/
public function getSubscriptions(): IndexedCollection
{
return $this->subscriptions;
}
/**
* @param string $classIdentifier
* @return OrderedCollection
*/
public function getSubscribers(string $classIdentifier): OrderedCollection
{
$this->initializeSubscribersCollection($classIdentifier);
return $this->subscriptions->get($classIdentifier);
}
/**
* Appends a listener to the subscribers list of an event
*
* Removes any previous subscriptions of the listener first to the named event.
*
* @param callable $listener
* @param string $classIdentifier
* @return $this
*/
public function subscribe(callable $listener, string $classIdentifier)
{
$this->unsubscribe($listener, $classIdentifier);
$this->initializeSubscribersCollection($classIdentifier);
$this->subscriptions->get($classIdentifier)->push($listener);
return $this;
}
/**
* Prepends a listener to the subscriber list of an event
*
* Removes any previous subscriptions of the listener first to the named event.
*
* @param callable $listener
* @param string $classIdentifier
* @return $this
*/
public function subscribeFirst(callable $listener, string $classIdentifier)
{
$this->unsubscribe($listener, $classIdentifier);
$this->initializeSubscribersCollection($classIdentifier);
$this->subscriptions->get($classIdentifier)->unshift($listener);
return $this;
}
/**
* Unsubscribes a listener
*
* If $name is omitted, the listener will be unsubscribed from each event it was subscribed to.
*
* @param callable $listener
* @param string $classIdentifier
* @return $this
*/
public function unsubscribe(callable $listener, string $classIdentifier = null)
{
/** @var OrderedCollection $listenerCollection */
foreach ($this->subscriptions as $name => $listenerCollection) {
if (!is_null($classIdentifier) && $classIdentifier != $name) continue;
$index = $listenerCollection->search($listener, true);
if (is_null($index)) continue;
// remove listener from event
unset($listenerCollection[$index]);
}
return $this;
}
/**
* Removes all subscriptions for this event
*
* @param string $classIdentifier
* @return $this
*/
public function unsubscribeAll(string $classIdentifier)
{
unset($this->subscriptions[$classIdentifier]);
return $this;
}
/**
* Checks whether any listeners where subscribed to the named event
*
* @param string $classIdentifier
* @return bool
*/
public function hasSubscribers(string $classIdentifier): bool
{
return $this->subscriptions->has($classIdentifier) && !$this->subscriptions->get($classIdentifier)->isEmpty();
}
/**
* Checks of the listener has been subscribed to the given event
*
* @param callable $listener
* @param string $classIdentifier
* @return bool
*/
public function isSubscribed(callable $listener, string $classIdentifier): bool
{
if (!$this->hasSubscribers($classIdentifier)) return false;
return !is_null($this->subscriptions->get($classIdentifier)->search($listener));
}
/**Notifies all listeners of events of the given type
*
* Listeners will be notified in the order of their subscriptions.
* Does nothing if no listeners subscribed to the type of the event.
*
* Creates an event instance and fires it.
* Does nothing if no suitable event model could be created.
*
* Any given $args will be passed to the constructor of the suitable event
* model class in the given order.
*
* @param string $classIdentifier
* @param mixed ...$args
* @return $this
*/
public function fire(string $classIdentifier, ...$args)
{
$event = $this->createEvent($classIdentifier, ...$args);
if (is_null($event)) return $this;
foreach ($this->getSubscribers($classIdentifier) as $listener) {
$this->notify($listener, $event);
if ($event->isCanceled()) {
// stop notifying further listeners if event has been canceled
break;
}
}
return $this;
}
/**
* Initialize listener collection if necessary
*
* @param string $classIdentifier
*/
protected function initializeSubscribersCollection(string $classIdentifier)
{
if ($this->subscriptions->has($classIdentifier)) return;
$this->subscriptions->set($classIdentifier, new OrderedCollection());
}
/**
* Create a fresh event instance
*
* @param string $classIdentifier
* @param mixed ...$args
* @return AbstractEvent|null
*/
protected function createEvent(string $classIdentifier, ...$args): ?AbstractEvent
{
try {
return $this->eventsFactory->create($classIdentifier, ...$args);
} catch (ClassNotFoundException $e) {
return null;
}
}
/**
* Passes the event to the listener
*
* The listener will be invoked with the event as the first and only argument.
* Any return values of the listener will be discarded.
*
* @param callable $listener
* @param AbstractEvent $event
*/
protected function notify(callable $listener, AbstractEvent $event)
{
call_user_func($listener, $event);
}
}

View File

@ -0,0 +1,82 @@
<?php
/**
* Definition of EventsFactory
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services\Factories;
use FF\Events\AbstractEvent;
use FF\Factories\AbstractFactory;
use FF\Factories\ClassLocators\BaseNamespaceClassLocator;
use FF\Factories\ClassLocators\ClassLocatorInterface;
/**
* Class EventsFactory
*
* @package FF\Services\Factories
*/
class EventsFactory extends AbstractFactory
{
/**
* @var EventsFactory
*/
protected static $instance;
/**
* Declared protected to prevent external usage.
* Uses a BaseNamespaceClassLocator pre-configured with the 'Events' as common suffix and the FF namespace.
*
* @see \FF\Factories\ClassLocators\BaseNamespaceClassLocator
*/
protected function __construct()
{
parent::__construct(new BaseNamespaceClassLocator(AbstractEvent::COMMON_NS_SUFFIX, 'FF'));
}
/**
* Declared protected to prevent external usage
*/
protected function __clone()
{
}
/**
* {@inheritDoc}
* @return BaseNamespaceClassLocator
*/
public function getClassLocator(): ClassLocatorInterface
{
return parent::getClassLocator();
}
/**
* Retrieves the singleton instance of this class
*
* @return EventsFactory
*/
public static function getInstance(): EventsFactory
{
if (is_null(self::$instance)) {
self::$instance = new EventsFactory();
}
return self::$instance;
}
/**
* {@inheritdoc}
* @return AbstractEvent
*/
public function create(string $classIdentifier, ...$args)
{
/** @var AbstractEvent $event */
$event = parent::create($classIdentifier, ...$args);
return $event;
}
}

View File

@ -0,0 +1,45 @@
<?php
/**
* Definition of EventEmitterTrait
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services\Traits;
use FF\Services\EventBroker;
use FF\Services\Factories\SF;
/**
* Trait EventEmitterTrait
*
* @package FF\Services\Traits
*/
trait EventEmitterTrait
{
/**
* Creates an event instance and fires it
*
* Delegates the execution to the EventBroker provided by the ServiceFactory.
*
* @param string $classIdentifier
* @param mixed ...$args
* @return $this
*/
protected function fire(string $classIdentifier, ...$args)
{
/** @var EventBroker $eventBroker */
static $eventBroker = null;
if (is_null($eventBroker)) {
$eventBroker = SF::i()->get('EventBroker');
}
$eventBroker->fire($classIdentifier, ...$args);
return $this;
}
}

View File

@ -0,0 +1,68 @@
<?php
/**
* Definition of AbstractEventTest
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Tests\Events;
use FF\Events\AbstractEvent;
use PHPUnit\Framework\TestCase;
/**
* Test AbstractEventTest
*
* @package FF\Tests
*/
class AbstractEventTest extends TestCase
{
/**
* @var MyEvent
*/
protected $uut;
/**
* {@inheritdoc}
*/
protected function setUp(): void
{
$this->uut = new MyEvent();
}
/**
* Tests the namesake method/feature
*/
public function testSetGetIsCanceled()
{
$same = $this->uut->setIsCanceled(true);
$this->assertSame($this->uut, $same);
$this->assertTrue($this->uut->isCanceled());
}
/**
* Tests the namesake method/feature
*/
public function testCancel()
{
$same = $this->uut->cancel();
$this->assertSame($this->uut, $same);
$this->assertTrue($this->uut->isCanceled());
}
/**
* Tests the namesake method/feature
*/
public function testGetClassIdentifier()
{
$this->assertEquals('MyEvent', MyEvent::getClassIdentifier());
}
}
class MyEvent extends AbstractEvent
{
}

View File

@ -0,0 +1,287 @@
<?php
/**
* Definition of EventBrokerTest
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Tests\Services {
use FF\DataStructures\IndexedCollection;
use FF\DataStructures\OrderedCollection;
use FF\Services\EventBroker;
use FF\Services\Factories\EventsFactory;
use FF\Tests\Services\Events\EventA;
use FF\Tests\Services\Events\EventB;
use PHPUnit\Framework\TestCase;
/**
* Test EventBrokerTest
*
* @package FF\Tests
*/
class EventBrokerTest extends TestCase
{
/**
* @var EventBroker
*/
protected $uut;
/**
* {@inheritdoc}
*/
protected function setUp(): void
{
$this->uut = new EventBroker();
$this->uut->getEventsFactory()
->getClassLocator()
->prependNamespaces(__NAMESPACE__);
}
/**
* {@inheritdoc}
*/
protected function tearDown(): void
{
$this->uut->unsubscribeAll('EventA');
}
/**
* Tests the namesake method/feature
*/
public function testGetEventsFactory()
{
$this->assertInstanceOf(EventsFactory::class, $this->uut->getEventsFactory());
}
/**
* Tests the namesake method/feature
*/
public function testGetSubscriptions()
{
$this->assertInstanceOf(IndexedCollection::class, $this->uut->getSubscriptions());
}
/**
* Tests the namesake method/feature
*/
public function testGetSubscribers()
{
$this->assertInstanceOf(OrderedCollection::class, $this->uut->getSubscribers('EventA'));
}
/**
* Tests the namesake method/feature
*/
public function testSubscribe()
{
$callback = [new ListenerA(), 'shoutA'];
$same = $this->uut->subscribe($callback, 'EventA');
$this->assertSame($this->uut, $same);
$this->assertEquals(1, count($this->uut->getSubscribers('EventA')));
$this->assertSame($callback, $this->uut->getSubscribers('EventA')->getFirst());
}
/**
* Tests the namesake method/feature
*/
public function testSubscribeRepeated()
{
$callback = [new ListenerA(), 'shoutA'];
$this->uut->subscribe($callback, 'EventA')->subscribe($callback, 'EventA');
$this->assertEquals(1, count($this->uut->getSubscribers('EventA')));
}
/**
* Tests the namesake method/feature
*/
public function testSubscribeAppend()
{
$callback1 = [new ListenerA(), 'shoutA'];
$callback2 = [new ListenerA(), 'shoutA'];
$this->uut->subscribe($callback1, 'EventA')
->subscribe($callback2, 'EventA');
$this->assertSame($callback1, $this->uut->getSubscribers('EventA')->get(0));
$this->assertSame($callback2, $this->uut->getSubscribers('EventA')->get(1));
}
/**
* Tests the namesake method/feature
*/
public function testSubscribeFirst()
{
$callback1 = [new ListenerA(), 'shoutA'];
$callback2 = [new ListenerA(), 'shoutA'];
$same = $this->uut->subscribe($callback1, 'EventA')
->subscribeFirst($callback2, 'EventA');
$this->assertSame($this->uut, $same);
$this->assertSame($callback1, $this->uut->getSubscribers('EventA')->get(1));
$this->assertSame($callback2, $this->uut->getSubscribers('EventA')->get(0));
}
/**
* Tests the namesake method/feature
*/
public function testUnsubscribe()
{
$callback1 = [new ListenerA(), 'shoutA'];
$this->uut->subscribe($callback1, 'EventA')
->subscribe($callback1, 'EventB')
->unsubscribe($callback1);
$this->assertTrue($this->uut->getSubscribers('EventA')->isEmpty());
$this->assertTrue($this->uut->getSubscribers('EventB')->isEmpty());
}
/**
* Tests the namesake method/feature
*/
public function testUnsubscribeNamed()
{
$callback1 = [new ListenerA(), 'shoutA'];
$callback2 = [new ListenerA(), 'shoutA'];
$same = $this->uut->subscribe($callback1, 'EventA')
->subscribe($callback2, 'EventA')
->unsubscribe($callback1, 'EventA');
$this->assertSame($this->uut, $same);
$this->assertEquals(1, count($this->uut->getSubscribers('EventA')));
$this->assertSame($callback2, $this->uut->getSubscribers('EventA')->get(0));
}
/**
* Tests the namesake method/feature
*/
public function testUnsubscribeAll()
{
$callback1 = [new ListenerA(), 'shoutA'];
$callback2 = [new ListenerA(), 'shoutA'];
$same = $this->uut->subscribe($callback1, 'EventA')
->subscribe($callback2, 'EventA')
->unsubscribeAll('EventA');
$this->assertSame($this->uut, $same);
$this->assertTrue($this->uut->getSubscribers('EventA')->isEmpty());
}
/**
* Tests the namesake method/feature
*/
public function testHasSubscribers()
{
$callback1 = [new ListenerA(), 'shoutA'];
$this->uut->subscribe($callback1, 'EventA');
$this->assertTrue($this->uut->hasSubscribers('EventA'));
$this->assertFalse($this->uut->hasSubscribers('EventB'));
}
/**
* Tests the namesake method/feature
*/
public function testIsSubscribed()
{
$callback1 = [new ListenerA(), 'shoutA'];
$callback2 = [new ListenerA(), 'shoutB'];
$this->uut->subscribe($callback1, 'EventA');
$this->assertTrue($this->uut->isSubscribed($callback1, 'EventA'));
$this->assertFalse($this->uut->isSubscribed($callback1, 'EventB'));
$this->assertFalse($this->uut->isSubscribed($callback2, 'EventA'));
$this->assertFalse($this->uut->isSubscribed($callback2, 'EventB'));
}
/**
* Tests the namesake method/feature
*/
public function testFire()
{
$same = $this->uut->fire('EventA', 'foo');
$this->assertSame($this->uut, $same);
}
/**
* Tests the namesake method/feature
*/
public function testFireWithListener()
{
$this->expectOutputString('foo');
$this->uut->subscribe([new ListenerA(), 'shoutA'], 'EventA')
->fire('EventA', 'foo');
}
/**
* Tests the namesake method/feature
*/
public function testFireCancel()
{
$this->expectOutputString('foo'); // output would be 'foofoo' if event canceling does not work
$this->uut->subscribe([new ListenerA(), 'cancelA'], 'EventA')
->subscribe([new ListenerB(), 'neverToReach'], 'EventA')
->fire('EventA', 'foo');
}
}
class ListenerA
{
public function shoutA(EventA $event)
{
print $event->content;
}
public function shoutB(EventB $event)
{
print $event->content;
}
public function cancelA(EventA $event)
{
print $event->content;
$event->cancel();
}
}
class ListenerB extends ListenerA
{
public function shoutB(EventB $event)
{
print $event->content;
}
public function neverToReach(EventA $event)
{
print $event->content;
}
}
}
namespace FF\Tests\Services\Events {
use FF\Events\AbstractEvent;
class EventA extends AbstractEvent
{
public $content;
public function __construct(string $content)
{
$this->content = $content;
}
}
class EventB extends EventA
{
}
}

View File

@ -0,0 +1,41 @@
<?php
/**
* Definition of EventsFactoryTest
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Tests\Services\Factories;
use FF\Factories\ClassLocators\BaseNamespaceClassLocator;
use FF\Services\Factories\EventsFactory;
use PHPUnit\Framework\TestCase;
/**
* Test EventsFactoryTest
*
* @package FF\Tests
*/
class EventsFactoryTest extends TestCase
{
/**
* Tests the namesake method/feature
*/
public function testGetInstance()
{
$instance = EventsFactory::getInstance();
$this->assertInstanceOf(EventsFactory::class, $instance);
$this->assertSame($instance, EventsFactory::getInstance());
}
/**
* Tests the namesake method/feature
*/
public function testGetClassLocator()
{
$this->assertInstanceOf(BaseNamespaceClassLocator::class, EventsFactory::getInstance()->getClassLocator());
}
}