Commit 0b0f5f1e authored by Benni Mack's avatar Benni Mack Committed by Benjamin Franzke
Browse files

[FEATURE] Introduce PSR-14-based EventDispatcher as alternative for hooks



The new PSR-14 standard for dispatching Events (that is: to extend
a Framework without having to modify a frameworks' code) adds
a EventDispatcher object that can dispatch Event objects to
EventListeners.

In PSR-14 every dispatched event is an object. It uses PHP class names as
identifiers for events. Class hierarchies may be used to group events.

A ListenerProvider object collects available listeners from an extension
and allows to listen and/or modify data provided by the Event object.

The current implementation relies on a custom TYPO3-specific
ListenerProvider that is configured using Symfony's Dependency Injection
tags.

As an example the Mailer-postProcInitialization signal/slot is
replaced by an Event.

This first patch introduces the feature, and does not deprecate
anything yet. The most important part is that new Events
can use this API instead of Hooks in TYPO3 v10.

Short-Term goal is to deprecate SignalSlot dispatcher in TYPO3 v10,
and migrate all signals to the EventDispatcher.

Resolves: #88770
Releases: master
Change-Id: I3649ddb9b9340640199279e6af3c040bffc397fe
Signed-off-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Signed-off-by: Benjamin Franzke's avatarBenjamin Franzke <bfr@qbus.de>
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/61303


Reviewed-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
Reviewed-by: Andreas Fernandez's avatarAndreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Georg Ringer's avatarGeorg Ringer <georg.ringer@gmail.com>
parent 57b051d0
......@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "112098a7382ac5f79a2b4dfe5fa616a9",
"content-hash": "d1380344a1f0aac44298b30b03cf311e",
"packages": [
{
"name": "cogpowered/finediff",
......@@ -1002,6 +1002,52 @@
],
"time": "2017-02-14T16:28:37+00:00"
},
{
"name": "psr/event-dispatcher",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/event-dispatcher.git",
"reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
"reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
"shasum": ""
},
"require": {
"php": ">=7.2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\EventDispatcher\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Standard interfaces for event handling.",
"keywords": [
"events",
"psr",
"psr-14"
],
"time": "2019-01-08T18:20:26+00:00"
},
{
"name": "psr/http-message",
"version": "1.0.1",
......
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Compatibility\Slot;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent;
use TYPO3\CMS\Core\Mail\Mailer;
use TYPO3\CMS\Extbase\SignalSlot\Dispatcher;
/**
* Bridge to provide backwards-compatibility for executing the SignalSlot dispatcher event.
*/
class PostInitializeMailer
{
/**
* @var Dispatcher
*/
protected $dispatcher;
public function __construct(Dispatcher $dispatcher)
{
$this->dispatcher = $dispatcher;
}
public function __invoke(AfterMailerInitializationEvent $event): void
{
$this->dispatcher->dispatch(Mailer::class, 'postInitializeMailer', [$event->getMailer()]);
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\DependencyInjection;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @internal
*/
final class ListenerProviderPass implements CompilerPassInterface
{
/**
* @var string
*/
private $tagName;
/**
* @param string $tagName
*/
public function __construct(string $tagName)
{
$this->tagName = $tagName;
}
/**
* @param ContainerBuilder $container
*/
public function process(ContainerBuilder $container)
{
$listenerProviderDefinition = $container->findDefinition(ListenerProvider::class);
if (!$listenerProviderDefinition) {
return;
}
$unorderedEventListeners = [];
foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) {
$container->findDefinition($serviceName)->setPublic(true);
foreach ($tags as $attributes) {
if (!isset($attributes['event'])) {
throw new \InvalidArgumentException(
'Service tag "event.listener" requires an event attribute to be defined, missing in: ' . $serviceName,
1563217364
);
}
$eventIdentifier = $attributes['event'];
$listenerIdentifier = $attributes['identifier'] ?? $serviceName;
$unorderedEventListeners[$eventIdentifier][$listenerIdentifier] = [
'service' => $serviceName,
'method' => $attributes['method'] ?? null,
'before' => GeneralUtility::trimExplode(',', $attributes['before'] ?? '', true),
'after' => GeneralUtility::trimExplode(',', $attributes['after'] ?? '', true),
];
}
}
$dependencyOrderingService = new DependencyOrderingService();
foreach ($unorderedEventListeners as $eventName => $listeners) {
// Sort them
$listeners = $dependencyOrderingService->orderByDependencies($listeners);
// Configure ListenerProvider factory to include these listeners
foreach ($listeners as $listener) {
$listenerProviderDefinition->addMethodCall('addListener', [
$eventName,
$listener['service'],
$listener['method'],
]);
}
}
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\EventDispatcher;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\EventDispatcher\StoppableEventInterface;
use TYPO3\CMS\Core\SingletonInterface;
/**
* Base PSR-14 event dispatcher which has only one listener provider, given at runtime
* Is a singleton instance in order to be published once.
*/
class EventDispatcher implements EventDispatcherInterface, SingletonInterface
{
/**
* @var ListenerProviderInterface
*/
protected $listenerProvider;
public function __construct(ListenerProviderInterface $listenerProvider)
{
$this->listenerProvider = $listenerProvider;
}
/**
* @inheritdoc
*/
public function dispatch(object $event)
{
// If the event is already stopped, nothing to do here.
if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
return $event;
}
foreach ($this->listenerProvider->getListenersForEvent($event) as $listener) {
$listener($event);
if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
break;
}
}
return $event;
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\EventDispatcher;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use Psr\Container\ContainerInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
/**
* Provides Listeners configured with the symfony service
* tag 'event.listener'.
*
* @internal
*/
class ListenerProvider implements ListenerProviderInterface
{
/**
* @var ContainerInterface
*/
protected $container;
/**
* @var array
*/
protected $listeners = [];
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
/**
* Not part of the public API, used in the generated service factor for this class,
*
* @param string $event
* @param string $service
* @param string|null $method
* @internal
*/
public function addListener(string $event, string $service, string $method = null): void
{
$this->listeners[$event][] = [
'service' => $service,
'method' => $method
];
}
/**
* Not part of the public API, only used for debugging purposes
*
* @internal
*/
public function getAllListenerDefinitions(): array
{
return $this->listeners;
}
/**
* @inheritdoc
*/
public function getListenersForEvent(object $event): iterable
{
$eventClasses = [get_class($event)];
$classParents = class_parents($event);
$classInterfaces = class_implements($event);
if (is_array($classParents) && !empty($classParents)) {
array_push($eventClasses, ...array_values($classParents));
}
if (is_array($classInterfaces) && !empty($classInterfaces)) {
array_push($eventClasses, ...array_values($classInterfaces));
}
foreach ($eventClasses as $className) {
if (isset($this->listeners[$className])) {
foreach ($this->listeners[$className] as $listener) {
yield $this->getCallable($listener['service'], $listener['method']);
}
}
}
}
/**
* @param string $service
* @param string|null $method
* @return callable
* @throws \InvalidArgumentException
*/
protected function getCallable(string $service, string $method = null): callable
{
$target = $this->container->get($service);
if ($method !== null) {
// Dispatch to configured method name instead of __invoke()
$target = [ $target, $method ];
}
if (!is_callable($target)) {
throw new \InvalidArgumentException(
sprintf('Event listener "%s%s%s" is not callable"', $service, ($method !== null ? '::' : ''), $method),
1549988537
);
}
return $target;
}
}
<?php
declare(strict_types = 1);
namespace TYPO3\CMS\Core\Mail\Event;
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
use TYPO3\CMS\Core\Mail\Mailer;
/**
* This event is fired once a new Mailer is instantiated with specific transport settings.
* So it is possible to add custom mailing settings.
*/
final class AfterMailerInitializationEvent
{
/**
* @var Mailer
*/
private $mailer;
public function __construct(Mailer $mailer)
{
$this->mailer = $mailer;
}
public function getMailer(): Mailer
{
return $this->mailer;
}
}
......@@ -14,6 +14,7 @@ namespace TYPO3\CMS\Core\Mail;
* The TYPO3 project - inspiring people to share!
*/
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\SmtpEnvelope;
......@@ -22,11 +23,11 @@ use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\NamedAddress;
use Symfony\Component\Mime\RawMessage;
use TYPO3\CMS\Core\EventDispatcher\EventDispatcher;
use TYPO3\CMS\Core\Exception as CoreException;
use TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MailUtility;
use TYPO3\CMS\Extbase\Object\ObjectManager;
use TYPO3\CMS\Extbase\SignalSlot\Dispatcher;
/**
* Adapter for Symfony/Mailer to be used by TYPO3 extensions.
......@@ -76,7 +77,7 @@ class Mailer implements MailerInterface
throw new CoreException($e->getMessage(), 1291068569);
}
}
$this->emitPostInitializeMailerSignal();
$this->getEventDispatcher()->dispatch(new AfterMailerInitializationEvent($this));
}
/**
......@@ -183,31 +184,11 @@ class Mailer implements MailerInterface
return GeneralUtility::makeInstance(TransportFactory::class);
}
/**
* Get the object manager
*
* @return ObjectManager
*/
protected function getObjectManager()
{
return GeneralUtility::makeInstance(ObjectManager::class);
}
/**
* Get the SignalSlot dispatcher
*
* @return Dispatcher
*/
protected function getSignalSlotDispatcher()
{
return $this->getObjectManager()->get(Dispatcher::class);
}
/**
* Emits a signal after mailer initialization
*/
protected function emitPostInitializeMailerSignal()
protected function getEventDispatcher(): EventDispatcherInterface
{
$this->getSignalSlotDispatcher()->dispatch(self::class, 'postInitializeMailer', [$this]);
return GeneralUtility::makeInstance(EventDispatcher::class);
}
}
......@@ -18,6 +18,7 @@ return function (ContainerConfigurator $container, ContainerBuilder $containerBu
$containerBuilder->addCompilerPass(new DependencyInjection\SingletonPass('typo3.singleton'));
$containerBuilder->addCompilerPass(new DependencyInjection\LoggerAwarePass('psr.logger_aware'));
$containerBuilder->addCompilerPass(new DependencyInjection\ListenerProviderPass('event.listener'));
$containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.middleware'));
$containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.request_handler'));
$containerBuilder->addCompilerPass(new DependencyInjection\AutowireInjectMethodsPass());
......
......@@ -14,6 +14,13 @@ services:
arguments:
$configPath: "%env(TYPO3:configPath)%/sites"
TYPO3\CMS\Core\EventDispatcher\EventDispatcher:
arguments:
$listenerProvider: '@TYPO3\CMS\Core\EventDispatcher\ListenerProvider'
TYPO3\CMS\Core\EventDispatcher\ListenerProvider:
public: true
TYPO3\CMS\Core\Package\PackageManager:
autoconfigure: false
......@@ -28,3 +35,10 @@ services:
TYPO3\CMS\Core\Database\Schema\SqlReader:
public: true
# EventListeners
TYPO3\CMS\Core\Compatibility\Slot\PostInitializeMailer:
tags:
- { name: event.listener,
identifier: 'legacy-slot',
event: TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent }
.. include:: ../../Includes.txt
==============================================
Feature: #88770 - PSR-14 based EventDispatcher
==============================================
See :issue:`88770`
Description
===========
A new EventDispatcher system is added to extend TYPO3's Core behaviour via PHP code. In the past,
this was done via Extbase's SignalSlot and TYPO3's custom hook system. The new EventDispatcher
system is a fully capable replacement for new code in TYPO3, as well as a possibility to
migrate away from previous TYPO3 solutions.
PSR-14 [https://www.php-fig.org/psr/psr-14/] is a lean solution that builds upon wide-spread
solutions for hooking into existing PHP code (Frameworks, CMS and the like).
PSR-14 consists of four components:
1. An `EventDispatcher` object that is used to trigger an Event. TYPO3 has a custom EventDispatcher
implementation for now, however all EventDispatchers of all frameworks are implementing
:php:`Psr\EventDispatcher\EventDispatcherInterface` thus it is possible to replace the event
dispatcher with another. The EventDispatcher's main method :php:`dispatch()` is called in TYPO3 Core
or extensions, that receives a PHP object and is then handed to all available listeners.
2. A `ListenerProvider` object that contains all listeners which have been registered for all events.
TYPO3 has a custom ListenerProvider that collects all listeners during compile time. This component
is not exposed outside of TYPO3's Core Framework.
3. Various `Event` objects. An event object can be any PHP object and is called from TYPO3 Core or
an extension ("Emitter") containing all information to be transported to the listeners. By default,
all registered listeners get triggered by an Event, however, if an Event has the interface
:php:`Psr\EventDispatcher\StoppableEventInterface` implemented, a listener can stop further execution
of other event listeners. This is especially useful if the listeners are candidates to provide information
to the emitter. This allows to finish event dispatching, once this information has been acquired.
If an event can be modified, appropriate methods should be available, although due to PHP's
nature of handling objects and the PSR-14 Listener signature, it cannot be guaranteed to be immutable.
4. Listeners: Extensions and PHP packages can add listeners that are registered. They are usually
associated to Event objects by the name of the event (FQCN) to be listened on. It is the task of
the `ListenerProvider` to provide configuration mechanisms to represent this relationship.
The main benefits of the EventDispatcher approach over Hooks and Extbase's SignalSlot Dispatcher
is an implementation which helps extension authors to better understand the possibilities
by having a strongly typed system based on PHP. In addition, it serves as a bridge to also
incorporate other Events provided by frameworks that support PSR-14.
Impact
======
TYPO3's EventDispatcher serves as the basis to replace all Signal/Slots and hooks in the future,
however for the time being, hooks and registered Slots work the same way as before, unless migrated
to an EventDispatcher-like code, whereas a deprecation message can be triggered.
Some hooks / signal/slots might not be replaced 1:1 to EventDispatcher, but rather superseded with
a more robust or future-proof API.
Registration:
If an extension author wants to provide a custom Event Listener, an according entry with the tag
`event.listener` can be added to the `Configuration/Services.yaml` file of that extension.
Example:
.. code-block:: yaml
services:
MyCompany\MyPackage\EventListener\NullMailer:
tags:
- { name: event.listener,
identifier: 'myListener',
event: TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent,
before: 'redirects, anotherIdentifier' }
The tag name `event.listener` identifies that a listener should be registered.