feature #29166 [Messenger] Add handled & sent stamps (ogizanagi)

This PR was merged into the 4.2-dev branch.

Discussion
----------

[Messenger] Add handled & sent stamps

| Q             | A
| ------------- | ---
| Branch?       | 4.2 <!-- see below -->
| Bug fix?      | no
| New feature?  | yes <!-- don't forget to update src/**/CHANGELOG.md files -->
| BC breaks?    | no     <!-- see https://symfony.com/bc -->
| Deprecations? | no <!-- don't forget to update UPGRADE-*.md and src/**/CHANGELOG.md files -->
| Tests pass?   | yes    <!-- please add some, will be required by reviewers -->
| Fixed tickets | N/A   <!-- #-prefixed issue number(s), if any -->
| License       | MIT
| Doc PR        | symfony/symfony-docs/issues/10661

Based on #29159

This new feature marks sent and handled messages, so middleware can act upon these and use the handler(s) result(s).
This is also the base of a next PR (#29167), introducing a query bus built on top of the message bus.

I'm not sure yet about the best way to determine the handlers and senders names/descriptions to store in the stamps:
- Handlers are callable. I've just reused the [console text descriptor](1c1818b876/src/Symfony/Bundle/FrameworkBundle/Console/Descriptor/TextDescriptor.php (L457-L491)) format for now.
- ~~Sender are `SenderInterface` instances. `\get_class` is used for now, but a single message can be sent by multiple senders, including of the same class.~~ => Updated. Yielding the sender name if provided, the FQCN otherwise.

~~Instead, what about allowing to yield names from locators, and fallback on the above strategies otherwise? So we'll use transport names from the config for senders, and pre-computed compile-time handlers descriptions?~~
=> Done. For handlers, computing it at compile time might not be straightforward. Let's compute it lazily from `HandledStamp::fromCallable()`

---

### From previous conversations:

> What about not adding HandledStamp on `null` returned from handler

IMHO, `null` still is a result. The stamps allows to identify a message as being handled regardless of the returned value, so makes sense on its own and keeping would require one less check for those wanting to consume it.

> What about adding SentStamp?

Makes sense to me and I think it was requested by @Nyholm before on Slack.
So, included in this PR.

> Should it target 4.2 or 4.3?

Targeting 4.2, because of the removal of the handler result forwarding by middleware. A userland middleware could have used this result, typically a cache middleware. Which would now require extra boring code in userland. This will simplify it and allow users to create their query bus instance until 4.3.

Commits
-------

2f5acf790a [Messenger] Add handled & sent stamps
This commit is contained in:
Nicolas Grekas 2018-11-15 12:53:01 +01:00
commit 88891d5e55
16 changed files with 325 additions and 16 deletions

View File

@ -1578,9 +1578,10 @@ class FrameworkExtension extends Extension
if ('*' !== $message && !class_exists($message) && !interface_exists($message, false)) {
throw new LogicException(sprintf('Invalid Messenger routing configuration: class or interface "%s" not found.', $message));
}
$senders = array_map(function ($sender) use ($senderAliases) {
return new Reference($senderAliases[$sender] ?? $sender);
}, $messageConfiguration['senders']);
$senders = array();
foreach ($messageConfiguration['senders'] as $sender) {
$senders[$sender] = new Reference($senderAliases[$sender] ?? $sender);
}
$sendersId = 'messenger.senders.'.$message;
$container->register($sendersId, RewindableGenerator::class)

View File

@ -569,7 +569,10 @@ abstract class FrameworkExtensionTest extends TestCase
);
$this->assertSame($messageToSendAndHandleMapping, $senderLocatorDefinition->getArgument(1));
$this->assertEquals(array(new Reference('messenger.transport.amqp'), new Reference('audit')), $container->getDefinition('messenger.senders.'.DummyMessage::class)->getArgument(0)[0]->getValues());
$this->assertEquals(array(
'amqp' => new Reference('messenger.transport.amqp'),
'audit' => new Reference('audit'),
), $container->getDefinition('messenger.senders.'.DummyMessage::class)->getArgument(0)[0]->getValues());
}
/**

View File

@ -4,6 +4,7 @@ CHANGELOG
4.2.0
-----
* Added `HandledStamp` & `SentStamp` stamps
* All the changes below are BC BREAKS
* Senders and handlers subscribing to parent interfaces now receive *all* matching messages, wildcard included
* `MessageBusInterface::dispatch()`, `MiddlewareInterface::handle()` and `SenderInterface::send()` return `Envelope`

View File

@ -40,9 +40,9 @@ class HandlersLocator implements HandlersLocatorInterface
$seen = array();
foreach (self::listTypes($envelope) as $type) {
foreach ($this->handlers[$type] ?? array() as $handler) {
foreach ($this->handlers[$type] ?? array() as $alias => $handler) {
if (!\in_array($handler, $seen, true)) {
yield $seen[] = $handler;
yield $alias => $seen[] = $handler;
}
}
}

View File

@ -25,7 +25,7 @@ interface HandlersLocatorInterface
/**
* Returns the handlers for the given message name.
*
* @return iterable|callable[]
* @return iterable|callable[] Indexed by handler alias if available
*/
public function getHandlers(Envelope $envelope): iterable;
}

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Messenger\Middleware;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Exception\NoHandlerForMessageException;
use Symfony\Component\Messenger\Handler\HandlersLocatorInterface;
use Symfony\Component\Messenger\Stamp\HandledStamp;
/**
* @author Samuel Roze <samuel.roze@gmail.com>
@ -40,8 +41,8 @@ class HandleMessageMiddleware implements MiddlewareInterface
{
$handler = null;
$message = $envelope->getMessage();
foreach ($this->handlersLocator->getHandlers($envelope) as $handler) {
$handler($message);
foreach ($this->handlersLocator->getHandlers($envelope) as $alias => $handler) {
$envelope = $envelope->with(HandledStamp::fromCallable($handler, $handler($message), \is_string($alias) ? $alias : null));
}
if (null === $handler && !$this->allowNoHandlers) {
throw new NoHandlerForMessageException(sprintf('No handler for message "%s".', \get_class($envelope->getMessage())));

View File

@ -13,6 +13,7 @@ namespace Symfony\Component\Messenger\Middleware;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Stamp\ReceivedStamp;
use Symfony\Component\Messenger\Stamp\SentStamp;
use Symfony\Component\Messenger\Transport\Sender\SendersLocatorInterface;
/**
@ -42,8 +43,8 @@ class SendMessageMiddleware implements MiddlewareInterface
$handle = false;
$sender = null;
foreach ($this->sendersLocator->getSenders($envelope, $handle) as $sender) {
$envelope = $sender->send($envelope);
foreach ($this->sendersLocator->getSenders($envelope, $handle) as $alias => $sender) {
$envelope = $sender->send($envelope)->with(new SentStamp(\get_class($sender), \is_string($alias) ? $alias : null));
}
if (null === $sender || $handle) {

View File

@ -0,0 +1,89 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Messenger\Stamp;
/**
* Stamp identifying a message handled by the `HandleMessageMiddleware` middleware
* and storing the handler returned value.
*
* @see \Symfony\Component\Messenger\Middleware\HandleMessageMiddleware
*
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
*
* @experimental in 4.2
*/
final class HandledStamp implements StampInterface
{
private $result;
private $callableName;
private $handlerAlias;
/**
* @param mixed $result The returned value of the message handler
*/
public function __construct($result, string $callableName, string $handlerAlias = null)
{
$this->result = $result;
$this->callableName = $callableName;
$this->handlerAlias = $handlerAlias;
}
/**
* @param mixed $result The returned value of the message handler
*/
public static function fromCallable(callable $handler, $result, string $handlerAlias = null): self
{
if (\is_array($handler)) {
if (\is_object($handler[0])) {
return new self($result, \get_class($handler[0]).'::'.$handler[1], $handlerAlias);
}
return new self($result, $handler[0].'::'.$handler[1], $handlerAlias);
}
if (\is_string($handler)) {
return new self($result, $handler, $handlerAlias);
}
if ($handler instanceof \Closure) {
$r = new \ReflectionFunction($handler);
if (false !== strpos($r->name, '{closure}')) {
return new self($result, 'Closure', $handlerAlias);
}
if ($class = $r->getClosureScopeClass()) {
return new self($result, $class->name.'::'.$r->name, $handlerAlias);
}
return new self($result, $r->name, $handlerAlias);
}
return new self($result, \get_class($handler).'::__invoke', $handlerAlias);
}
/**
* @return mixed
*/
public function getResult()
{
return $this->result;
}
public function getCallableName(): string
{
return $this->callableName;
}
public function getHandlerAlias(): ?string
{
return $this->handlerAlias;
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Messenger\Stamp;
/**
* Marker stamp identifying a message sent by the `SendMessageMiddleware`.
*
* @see \Symfony\Component\Messenger\Middleware\SendMessageMiddleware
*
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
*
* @experimental in 4.2
*/
final class SentStamp implements StampInterface
{
private $senderClass;
private $senderAlias;
public function __construct(string $senderClass, string $senderAlias = null)
{
$this->senderAlias = $senderAlias;
$this->senderClass = $senderClass;
}
public function getSenderClass(): string
{
return $this->senderClass;
}
public function getSenderAlias(): ?string
{
return $this->senderAlias;
}
}

View File

@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Messenger\Tests\Handler;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Handler\HandlersLocator;
use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage;
class HandlersLocatorTest extends TestCase
{
public function testItYieldsProvidedAliasAsKey()
{
$handler = $this->createPartialMock(\stdClass::class, array('__invoke'));
$locator = new HandlersLocator(array(
DummyMessage::class => array('dummy' => $handler),
));
$this->assertSame(array('dummy' => $handler), iterator_to_array($locator->getHandlers(new Envelope(new DummyMessage('a')))));
}
}

View File

@ -15,6 +15,7 @@ use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Handler\HandlersLocator;
use Symfony\Component\Messenger\Middleware\HandleMessageMiddleware;
use Symfony\Component\Messenger\Middleware\StackMiddleware;
use Symfony\Component\Messenger\Stamp\HandledStamp;
use Symfony\Component\Messenger\Test\Middleware\MiddlewareTestCase;
use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage;
@ -36,6 +37,55 @@ class HandleMessageMiddlewareTest extends MiddlewareTestCase
$middleware->handle($envelope, $this->getStackMock());
}
/**
* @dataProvider itAddsHandledStampsProvider
*/
public function testItAddsHandledStamps(array $handlers, array $expectedStamps)
{
$message = new DummyMessage('Hey');
$envelope = new Envelope($message);
$middleware = new HandleMessageMiddleware(new HandlersLocator(array(
DummyMessage::class => $handlers,
)));
$envelope = $middleware->handle($envelope, $this->getStackMock());
$this->assertEquals($expectedStamps, $envelope->all(HandledStamp::class));
}
public function itAddsHandledStampsProvider()
{
$first = $this->createPartialMock(\stdClass::class, array('__invoke'));
$first->method('__invoke')->willReturn('first result');
$firstClass = \get_class($first);
$second = $this->createPartialMock(\stdClass::class, array('__invoke'));
$second->method('__invoke')->willReturn(null);
$secondClass = \get_class($second);
yield 'A stamp is added' => array(
array($first),
array(new HandledStamp('first result', $firstClass.'::__invoke')),
);
yield 'A stamp is added per handler' => array(
array($first, $second),
array(
new HandledStamp('first result', $firstClass.'::__invoke'),
new HandledStamp(null, $secondClass.'::__invoke'),
),
);
yield 'Yielded locator alias is used' => array(
array('first_alias' => $first, $second),
array(
new HandledStamp('first result', $firstClass.'::__invoke', 'first_alias'),
new HandledStamp(null, $secondClass.'::__invoke'),
),
);
}
/**
* @expectedException \Symfony\Component\Messenger\Exception\NoHandlerForMessageException
* @expectedExceptionMessage No handler for message "Symfony\Component\Messenger\Tests\Fixtures\DummyMessage"

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Messenger\Tests\Middleware;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\SendMessageMiddleware;
use Symfony\Component\Messenger\Stamp\ReceivedStamp;
use Symfony\Component\Messenger\Stamp\SentStamp;
use Symfony\Component\Messenger\Test\Middleware\MiddlewareTestCase;
use Symfony\Component\Messenger\Tests\Fixtures\ChildDummyMessage;
use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage;
@ -33,7 +34,12 @@ class SendMessageMiddlewareTest extends MiddlewareTestCase
$sender->expects($this->once())->method('send')->with($envelope)->willReturn($envelope);
$middleware->handle($envelope, $this->getStackMock(false));
$envelope = $middleware->handle($envelope, $this->getStackMock(false));
/* @var SentStamp $stamp */
$this->assertInstanceOf(SentStamp::class, $stamp = $envelope->last(SentStamp::class), 'it adds a sent stamp');
$this->assertNull($stamp->getSenderAlias());
$this->assertStringMatchesFormat('Mock_SenderInterface_%s', $stamp->getSenderClass());
}
public function testItSendsTheMessageToAssignedSenderWithPreWrappedMessage()
@ -128,6 +134,8 @@ class SendMessageMiddlewareTest extends MiddlewareTestCase
$sender->expects($this->never())->method('send');
$middleware->handle($envelope, $this->getStackMock());
$envelope = $middleware->handle($envelope, $this->getStackMock());
$this->assertNull($envelope->last(SentStamp::class), 'it does not add sent stamp for received messages');
}
}

View File

@ -0,0 +1,72 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Messenger\Tests\Stamp;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Stamp\HandledStamp;
use Symfony\Component\Messenger\Tests\Fixtures\DummyCommandHandler;
class HandledStampTest extends TestCase
{
public function testConstruct()
{
$stamp = new HandledStamp('some result', 'FooHandler::__invoke()', 'foo');
$this->assertSame('some result', $stamp->getResult());
$this->assertSame('FooHandler::__invoke()', $stamp->getCallableName());
$this->assertSame('foo', $stamp->getHandlerAlias());
$stamp = new HandledStamp('some result', 'FooHandler::__invoke()');
$this->assertSame('some result', $stamp->getResult());
$this->assertSame('FooHandler::__invoke()', $stamp->getCallableName());
$this->assertNull($stamp->getHandlerAlias());
}
/**
* @dataProvider provideCallables
*/
public function testFromCallable(callable $handler, ?string $expectedHandlerString)
{
/** @var HandledStamp $stamp */
$stamp = HandledStamp::fromCallable($handler, 'some_result', 'alias');
$this->assertStringMatchesFormat($expectedHandlerString, $stamp->getCallableName());
$this->assertSame('alias', $stamp->getHandlerAlias(), 'alias is forwarded to construct');
$this->assertSame('some_result', $stamp->getResult(), 'result is forwarded to construct');
}
public function provideCallables()
{
yield array(function () {}, 'Closure');
yield array('var_dump', 'var_dump');
yield array(new DummyCommandHandler(), DummyCommandHandler::class.'::__invoke');
yield array(
array(new DummyCommandHandlerWithSpecificMethod(), 'handle'),
DummyCommandHandlerWithSpecificMethod::class.'::handle',
);
yield array(\Closure::fromCallable(function () {}), 'Closure');
yield array(\Closure::fromCallable(new DummyCommandHandler()), DummyCommandHandler::class.'::__invoke');
yield array(\Closure::bind(\Closure::fromCallable(function () {}), new \stdClass()), 'Closure');
yield array(new class() {
public function __invoke()
{
}
}, 'class@anonymous%sHandledStampTest.php%s::__invoke');
}
}
class DummyCommandHandlerWithSpecificMethod
{
public function handle(): void
{
}
}

View File

@ -30,4 +30,14 @@ class SendersLocatorTest extends TestCase
$this->assertSame(array($sender), iterator_to_array($locator->getSenders(new Envelope(new DummyMessage('a')))));
$this->assertSame(array(), iterator_to_array($locator->getSenders(new Envelope(new SecondMessage()))));
}
public function testItYieldsProvidedSenderAliasAsKey()
{
$sender = $this->getMockBuilder(SenderInterface::class)->getMock();
$locator = new SendersLocator(array(
DummyMessage::class => array('dummy' => $sender),
));
$this->assertSame(array('dummy' => $sender), iterator_to_array($locator->getSenders(new Envelope(new DummyMessage('a')))));
}
}

View File

@ -46,9 +46,9 @@ class SendersLocator implements SendersLocatorInterface
$seen = array();
foreach (HandlersLocator::listTypes($envelope) as $type) {
foreach ($this->senders[$type] ?? array() as $sender) {
foreach ($this->senders[$type] ?? array() as $alias => $sender) {
if (!\in_array($sender, $seen, true)) {
yield $seen[] = $sender;
yield $alias => $seen[] = $sender;
}
}
$handle = $handle ?: $this->sendAndHandle[$type] ?? false;

View File

@ -29,7 +29,7 @@ interface SendersLocatorInterface
* @param bool|null &$handle True after calling the method when the next middleware
* should also get the message; false otherwise
*
* @return iterable|SenderInterface[]
* @return iterable|SenderInterface[] Indexed by sender alias if available
*/
public function getSenders(Envelope $envelope, ?bool &$handle = false): iterable;
}