Milestone 4: Discovery & Registration (Attribute scanning & PSR-16 cache)
This commit is contained in:
parent
9f92227547
commit
26db488032
29
src/AsEventListener.php
Normal file
29
src/AsEventListener.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Beacon;
|
||||
|
||||
use Attribute;
|
||||
|
||||
/**
|
||||
* AsEventListener Attribute
|
||||
*
|
||||
* Marks a class or method as an event listener for a specific event.
|
||||
* Used by the ListenerDiscovery service to automatically register listeners.
|
||||
*/
|
||||
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
|
||||
class AsEventListener
|
||||
{
|
||||
/**
|
||||
* @param string $event The fully qualified class name of the event to listen for.
|
||||
* @param int $priority The priority (higher runs first).
|
||||
* @param string|null $method The method name to call (if applied to a class). Defaults to __invoke.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $event,
|
||||
public int $priority = 0,
|
||||
public ?string $method = null
|
||||
) {
|
||||
}
|
||||
}
|
||||
26
src/AsSubscriber.php
Normal file
26
src/AsSubscriber.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Beacon;
|
||||
|
||||
use Attribute;
|
||||
|
||||
/**
|
||||
* AsSubscriber Attribute
|
||||
*
|
||||
* Marks a class as an event subscriber.
|
||||
* The class must implement SubscriberInterface to define its listeners.
|
||||
* Used by the ListenerDiscovery service to automatically register subscribers.
|
||||
*/
|
||||
#[Attribute(Attribute::TARGET_CLASS)]
|
||||
class AsSubscriber
|
||||
{
|
||||
/**
|
||||
* @param int $priority Global priority for all listeners in this subscriber (if not overridden).
|
||||
*/
|
||||
public function __construct(
|
||||
public int $priority = 0
|
||||
) {
|
||||
}
|
||||
}
|
||||
179
src/ListenerDiscovery.php
Normal file
179
src/ListenerDiscovery.php
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Beacon;
|
||||
|
||||
use ReflectionClass;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
|
||||
/**
|
||||
* ListenerDiscovery
|
||||
*
|
||||
* Scans directories for classes using Beacon attributes (#[AsEventListener], #[AsSubscriber]).
|
||||
* Supports PSR-16 caching to prevent redundant filesystem scans.
|
||||
*/
|
||||
class ListenerDiscovery
|
||||
{
|
||||
/**
|
||||
* @var string The cache key used for discovery results.
|
||||
*/
|
||||
private const CACHE_KEY = 'phred.beacon.discovery';
|
||||
|
||||
/**
|
||||
* @param array<string> $directories List of absolute paths to scan.
|
||||
* @param CacheInterface|null $cache Optional PSR-16 cache.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $directories,
|
||||
private readonly ?CacheInterface $cache = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers listeners and registers them to the provider.
|
||||
*
|
||||
* @param ListenerProvider $provider
|
||||
* @return void
|
||||
*/
|
||||
public function discover(ListenerProvider $provider): void
|
||||
{
|
||||
$map = $this->getDiscoveryMap();
|
||||
|
||||
foreach ($map['listeners'] as $item) {
|
||||
$provider->addListener($item['event'], $item['listener'], $item['priority']);
|
||||
}
|
||||
|
||||
foreach ($map['subscribers'] as $subscriber) {
|
||||
$provider->addSubscriber($subscriber);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the discovery map from cache or by scanning.
|
||||
*
|
||||
* @return array{listeners: array<int, array{event: string, listener: string|array{0: string, 1: string}, priority: int}>, subscribers: array<int, string>}
|
||||
*/
|
||||
private function getDiscoveryMap(): array
|
||||
{
|
||||
if ($this->cache !== null && $this->cache->has(self::CACHE_KEY)) {
|
||||
return $this->cache->get(self::CACHE_KEY);
|
||||
}
|
||||
|
||||
$map = $this->scan();
|
||||
|
||||
if ($this->cache !== null) {
|
||||
$this->cache->set(self::CACHE_KEY, $map);
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the configured directories for attributes.
|
||||
*
|
||||
* @return array{listeners: array<int, array{event: string, listener: string|array{0: string, 1: string}, priority: int}>, subscribers: array<int, string>}
|
||||
*/
|
||||
private function scan(): array
|
||||
{
|
||||
$map = ['listeners' => [], 'subscribers' => []];
|
||||
|
||||
foreach ($this->directories as $directory) {
|
||||
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory));
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (!$file->isFile() || $file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$className = $this->extractClassName($file->getPathname());
|
||||
if ($className === null || !class_exists($className)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($className);
|
||||
|
||||
// Check for #[AsSubscriber]
|
||||
$subAttributes = $reflection->getAttributes(AsSubscriber::class);
|
||||
if (!empty($subAttributes)) {
|
||||
$map['subscribers'][] = $className;
|
||||
}
|
||||
|
||||
// Check for #[AsEventListener] on class
|
||||
$classAttributes = $reflection->getAttributes(AsEventListener::class);
|
||||
foreach ($classAttributes as $attr) {
|
||||
/** @var AsEventListener $instance */
|
||||
$instance = $attr->newInstance();
|
||||
$map['listeners'][] = [
|
||||
'event' => $instance->event,
|
||||
'listener' => $instance->method ? [$className, $instance->method] : $className,
|
||||
'priority' => $instance->priority,
|
||||
];
|
||||
}
|
||||
|
||||
// Check for #[AsEventListener] on methods
|
||||
foreach ($reflection->getMethods() as $method) {
|
||||
$methodAttributes = $method->getAttributes(AsEventListener::class);
|
||||
foreach ($methodAttributes as $attr) {
|
||||
/** @var AsEventListener $instance */
|
||||
$instance = $attr->newInstance();
|
||||
$map['listeners'][] = [
|
||||
'event' => $instance->event,
|
||||
'listener' => [$className, $method->getName()],
|
||||
'priority' => $instance->priority,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the fully qualified class name from a file.
|
||||
* Basic implementation using token_get_all.
|
||||
*
|
||||
* @param string $file
|
||||
* @return string|null
|
||||
*/
|
||||
private function extractClassName(string $file): ?string
|
||||
{
|
||||
$content = file_get_contents($file);
|
||||
$tokens = token_get_all($content);
|
||||
$namespace = '';
|
||||
$class = '';
|
||||
$gettingNamespace = false;
|
||||
$gettingClass = false;
|
||||
|
||||
foreach ($tokens as $index => $token) {
|
||||
if (is_array($token)) {
|
||||
if ($token[0] === T_NAMESPACE) {
|
||||
$gettingNamespace = true;
|
||||
} elseif ($token[0] === T_CLASS) {
|
||||
$gettingClass = true;
|
||||
}
|
||||
|
||||
if ($gettingNamespace) {
|
||||
if ($token[0] === T_NAME_QUALIFIED || $token[0] === T_STRING) {
|
||||
$namespace .= $token[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ($gettingClass) {
|
||||
if ($token[0] === T_STRING) {
|
||||
$class = $token[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($token === ';' && $gettingNamespace) {
|
||||
$gettingNamespace = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$classFQCN = $namespace ? $namespace . '\\' . $class : $class;
|
||||
return $classFQCN ?: null;
|
||||
}
|
||||
}
|
||||
133
tests/Integration/DiscoveryIntegrationTest.php
Normal file
133
tests/Integration/DiscoveryIntegrationTest.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Beacon\Tests\Integration;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Phred\Beacon\AsEventListener;
|
||||
use Phred\Beacon\AsSubscriber;
|
||||
use Phred\Beacon\EventDispatcher;
|
||||
use Phred\Beacon\ListenerDiscovery;
|
||||
use Phred\Beacon\ListenerProvider;
|
||||
use Phred\BeaconContracts\SubscriberInterface;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
class DiscoveryIntegrationTest extends TestCase
|
||||
{
|
||||
private string $tempDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tempDir = sys_get_temp_dir() . '/beacon_discovery_test_' . uniqid();
|
||||
mkdir($this->tempDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->removeDir($this->tempDir);
|
||||
}
|
||||
|
||||
public function test_it_discovers_attributed_listeners(): void
|
||||
{
|
||||
$this->createTestClass('TestListener', <<<'PHP'
|
||||
namespace Phred\Discovery;
|
||||
use Phred\Beacon\AsEventListener;
|
||||
class TestListener {
|
||||
#[AsEventListener(event: 'stdClass', priority: 10)]
|
||||
public function onEvent($event): void { $event->called = true; }
|
||||
}
|
||||
PHP
|
||||
);
|
||||
|
||||
$provider = new ListenerProvider();
|
||||
$discovery = new ListenerDiscovery([$this->tempDir]);
|
||||
$discovery->discover($provider);
|
||||
|
||||
$dispatcher = new EventDispatcher($provider);
|
||||
$event = new \stdClass();
|
||||
$event->called = false;
|
||||
|
||||
$dispatcher->dispatch($event);
|
||||
|
||||
$this->assertTrue($event->called);
|
||||
}
|
||||
|
||||
public function test_it_discovers_subscribers(): void
|
||||
{
|
||||
$this->createTestClass('TestSubscriber', <<<'PHP'
|
||||
namespace Phred\Discovery;
|
||||
use Phred\Beacon\AsSubscriber;
|
||||
use Phred\BeaconContracts\SubscriberInterface;
|
||||
#[AsSubscriber]
|
||||
class TestSubscriber implements SubscriberInterface {
|
||||
public static function getSubscribedEvents(): array {
|
||||
return ['stdClass' => 'onEvent'];
|
||||
}
|
||||
public function onEvent($event): void { $event->sub_called = true; }
|
||||
}
|
||||
PHP
|
||||
);
|
||||
|
||||
$provider = new ListenerProvider();
|
||||
$discovery = new ListenerDiscovery([$this->tempDir]);
|
||||
$discovery->discover($provider);
|
||||
|
||||
$dispatcher = new EventDispatcher($provider);
|
||||
$event = new \stdClass();
|
||||
$event->sub_called = false;
|
||||
|
||||
$dispatcher->dispatch($event);
|
||||
|
||||
$this->assertTrue($event->sub_called);
|
||||
}
|
||||
|
||||
public function test_lazy_loading_via_container(): void
|
||||
{
|
||||
$this->createTestClass('LazyListener', <<<'PHP'
|
||||
namespace Phred\Discovery;
|
||||
use Phred\Beacon\AsEventListener;
|
||||
class LazyListener {
|
||||
#[AsEventListener(event: 'stdClass')]
|
||||
public function __invoke($event): void { $event->lazy_called = true; }
|
||||
}
|
||||
PHP
|
||||
);
|
||||
|
||||
$container = $this->createMock(ContainerInterface::class);
|
||||
$container->expects($this->once())
|
||||
->method('get')
|
||||
->with('Phred\Discovery\LazyListener')
|
||||
->willReturn(new class {
|
||||
public function __invoke($event) { $event->lazy_called = true; }
|
||||
});
|
||||
|
||||
$provider = new ListenerProvider($container);
|
||||
$discovery = new ListenerDiscovery([$this->tempDir]);
|
||||
$discovery->discover($provider);
|
||||
|
||||
$dispatcher = new EventDispatcher($provider);
|
||||
$event = new \stdClass();
|
||||
$event->lazy_called = false;
|
||||
|
||||
$dispatcher->dispatch($event);
|
||||
|
||||
$this->assertTrue($event->lazy_called);
|
||||
}
|
||||
|
||||
private function createTestClass(string $name, string $content): void
|
||||
{
|
||||
file_put_contents($this->tempDir . '/' . $name . '.php', "<?php\n" . $content);
|
||||
require_once $this->tempDir . '/' . $name . '.php';
|
||||
}
|
||||
|
||||
private function removeDir(string $dir): void
|
||||
{
|
||||
$files = array_diff(scandir($dir), ['.', '..']);
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . '/' . $file;
|
||||
is_dir($path) ? $this->removeDir($path) : unlink($path);
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue