Skip to content

[Messenger] "Self-handling" messages (message AND handler in the same class) #58102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Wirone opened this issue Aug 27, 2024 · 14 comments
Open

Comments

@Wirone
Copy link
Contributor

Wirone commented Aug 27, 2024

Description

We had an interesting discussion about buses and my colleague shared how he implemented mechanism that mixes command and handler in the same class (which looks like an advantage for several people that attended into discussion). Imagine something like this:

final class DoSomething implements Command
{
    public function __construct(public readonly Foo $foo) {}

    public function execute(SomeDependency $dependency)
    {
        // ... do the stuff
    }
}

Then with $bus->execute(new DoSomething(new Foo())); the bus calls execute() on a DoSomething instance, additionally resolving all the dependencies and injecting them as a method's arguments.

This approach is not applicable 1:1 to Messenger since currently the handlers receive the message instance as an argument rather than using object's context, but I was wondering if it's possible to achieve self-executing commands in Messenger 🤔? There is HandlerArgumentsStamp that could be helpful here probably (but rather cumbersome), but my actual thought is: would it be possible to support #[Autowire] in a method marked with #[AsCommandHandler], so the dependencies are resolved dynamically, when the message is handled?

FYI: I don't have a problem with current approach, where command and handler are in separate classes. I put them into separate directories, so I can import whole handlers' dir into DI, but the naming convention is somehow bothering me: why do I actually need DoSomething and DoSomethingHandler? I like naming that represents the intent, not technical side of it, so for me more natural would be to use DoSomething for handler too. But then, with message and command in separate files it's required to keep them in separate namespace and alias the command in the handler anyway.

Please correct me if I'm wrong and it's already possible to achieve this 🙂. I tried to find it in the official docs, but it seems like it's not possible currently. As far as I know in Laravel it's possible and I personally think it's totally OK (side note: I dislike messages to be self-dispatchable though).

It does not make any difference between injecting dependencies through constructor and using them in __handle(), and injecting them through a method marked with #[AsMessageHandler] - in both cases dependencies come from the outside, so the handler remains flexible and testable.

Example

Using message instance as an explicit argument in handler (just like it's done now):

final readonly class DoSomething
{
    public function __construct(public readonly Foo $foo) {}

    #[AsMessageHandler]
    #[Autowire] /* Not sure if required here, maybe it could be implicit? */
    public static function execute(
        DoSomething $command,
        SomeDependency $dependency, // Autowired automatically when possible
        #[Autowire(service: 'decorated_other_dependency')] OtherDependency $otherDependency,
    ) {
        // ... do the stuff
    }
}

Using message instance's context (handler method receives only required dependencies):

final readonly class DoSomething
{
    public function __construct(public readonly Foo $foo) {}

    #[AsMessageHandler]
    #[Autowire] /* Not sure if required here, maybe it could be implicit? */
    public function execute(
        SomeDependency $dependency, // Autowired automatically when possible
        #[Autowire(service: 'decorated_other_dependency')] OtherDependency $otherDependency,
    ) {
        // ... do the stuff
    }
}
@alexander-schranz
Copy link
Contributor

Just add my thoughts here. The handler can in your case not longer be a service which make things a lot harder. The reason is we can not construct a handler service in the dependency injection container as the __construct is for the message not for the handler. So instead of that the HandleMiddleware search for Handlers it would require to check if the current message is lets call it "self handling" and call the __invoke in your example execute method.

A message in current case can have multiple handlers. Is that then still supported, self handled + additinal handlers? The self handled part is not a service which so would not allow service decoration via dependency injection component.

So I think the first example where the command is also given to execute / __invoke does not make sense as that would say that it are 2 different instances $this and $command but they are not. So in my case only in that case the second example with only the dependencies would make sense.

PS: you may can achieve a simple prototype by creating an own HandlerLocator: https://github.com/symfony/messenger/blob/e1dc743492ff9f1cfb23e6eea3592bf2ec9bd079/Middleware/HandleMessageMiddleware.php#L36 or/and an own middleware setting the HandlerArgumentsStamp for additional dependencies: https://github.com/symfony/messenger/blob/e1dc743492ff9f1cfb23e6eea3592bf2ec9bd079/Middleware/HandleMessageMiddleware.php#L77

@Wirone
Copy link
Contributor Author

Wirone commented Aug 27, 2024

So I think the first example where the command is also given to execute / __invoke does not make sense as that would say that it are 2 different instances $this and $command but they are not.

That's why the first example's execute() method is static, to distinguish the contexts (message comes from the outside as an argument).

The handler can in your case not longer be a service which make things a lot harder

That's a great point, though. Maybe with the static method approach it could be registered in the DI somehow for handling (so the bus knows it has to call the static method), but it really messes up with decorating stuff or injecting handler into other services (actually we do it now, because we work on a migration to Messenger but in the first phase we just use old handlers that instantiate new command and call the new handler with it - the actual business logic is moved from old handler to the new one)... Did not think about this before.


Thanks for pointing me to HandlerLocator. I knew the HandlerArgumentsStamp path before, but I believe it would be cumbersome to achieve this in userland, as you would need a middleware that would use reflection on a handler, determine dependencies and add HandlerArgumentsStamp so it can be handled properly, but then most probably you would need to make all of these dependencies public. With a built-in approach it probably would be possible to collect dependencies on a compile time and ensure they'll be passed to the method, without making them public.

@Wirone
Copy link
Contributor Author

Wirone commented Aug 27, 2024

Wouldnt declaring message+handler classes in the same file, achieve somewhat the same result? IIUC the goal is to have relevant code living closer together.

Yes and no 😅. The idea of the code being closer is one of the drivers here, the second one is naming convention. Having message and handler in the same file, but using separate classes does not solve the latter, as the handler still need to be named differently (mixing intent with technical jargon). This is not a big deal, as I said initially, I was just wondering if it's possible to achieve both under the same name (intent). @alexander-schranz's argument related to registering handler in the DI is crucial though, so unless someone comes up with some brilliant idea, I believe it's not doable 🤷.

@alexander-schranz
Copy link
Contributor

alexander-schranz commented Aug 27, 2024

@Wirone

as you would need a middleware that would use reflection on a handler, determine dependencies and add HandlerArgumentsStamp

What might help you achieve it is the:

But also think @kbond solved a similar issue with the dependencies in https://github.com/zenstruck/console-extra.


But maybe we could rethink how we could move the Argument Resolver part for services from HttpKernel package to the Dependency Injection package, so we may not need duplicate things for controllers, commands and maybe message handlers in future. But think @kbond already have some thoughts on this part.

@Wirone
Copy link
Contributor Author

Wirone commented Aug 27, 2024

the handler is a technical concern, the message a domain language one. In that sense class DoSomething + DoSomethingHandler makes perfectly fine naming.

For me both message and handler are application layer concepts (even though handler more or less execute domain logic) and from the intent point of view they both could be named the same. For example, when you want to add a product you have AddProduct intent (command), but also the actual logic that do this could be under the same name, because it fits, right? *Handler suffix for me seems more like a workaround, rather than name I would personally use if I wasn't blocked by other circumstances. Yes, separate namespaces somehow fix that, but then command need to be aliased when importing in a handler class. Again, not a big deal, just brainstorming here 😅.

PS. This mostly applies to command and query buses, where you rather want to have single handler for a message, and then its name correlates with an intent. For event buses there's not a problem since the message is clear (something happened) and there may be multiple handlers not even related (name-wise) to event's name. Maybe naming convention could be flipped? DoSomethingIntent (command) and DoSomething (handler)? I don't know, but I agree it may not be a Messenger problem per se, which of course does not invalidate the whole idea of a message+handler in one class (other arguments could do it, though).

@kbond
Copy link
Member

kbond commented Aug 28, 2024

But also think @kbond solved a similar issue with the dependencies in https://github.com/zenstruck/console-extra.

Yes, for this package I use the ServiceSubscriberTrait + reflection to build getSubscribedServices(). This is essentially a hack and I don't believe something similar could be done here. The nature of commands allowed me to do this in my package.

I have often thought about extracting the logic that allows controllers to be autowired so that any callable can be used this way. I believe this to be the proper solution.

@chalasr
Copy link
Member

chalasr commented Sep 4, 2024

I wouldn't encourage merging messages with handlers. The message captures and represents the intent, mixing it with the handling logic looks too much responsibilities and is less future-proof to me.

@alexander-schranz
Copy link
Contributor

ave often thought about extracting the logic that allows controllers to be autowired so that any callable can be used this way. I believe this to be the proper solution.

@kbond Think it would be great to extract that part to the dependency-injection component. Maybe there something already exists which can be autopted as autowire over __construct is the same logic for atleast this the services cases. So that logic have just additional parameter given for which method services should be defined.

@stof
Copy link
Member

stof commented Sep 4, 2024

The dependency injection cannot perform runtime autowiring of a signature. The DI component is performing all autowiring by altering the service definitions during compilation of the template.

the resolution of controller parameters is not handled by the DI component but by the ArgumentResolver of HttpKernel (there is indeed a ServiceValueResolver hooked into it that relies on some resolution done at compile time, but this is also implemented by HttpKernel, and its uses an extension point of the ArgumentResolver).

@Wirone
Copy link
Contributor Author

Wirone commented Sep 4, 2024

@stof you're talking about how it's done now, but whether it's correct or not is IMHO a totally separate story. "Dependency Injection" as a concept is not strictly bound to container and cache, it's about... injecting dependencies 🤷. Injecting them at runtime is in my opinion a responsibility of dependency injection component more than HttpKernel component, as your runtime can be CLI which does not have HTTP context, but still may require injecting params/dependencies. Personally I believe this is one of Symfony's design flaws, and I hope it can be fixed somehow (just like profiling CLI commands, that wasn't possible for years 😅).

@valtzu
Copy link
Contributor

valtzu commented Sep 9, 2024

I managed to create a naive user-space implementation on this 👇 It works by defining a non-shared service in DI which has a factory that fetches the message from HandlersLocator and calls __invoke when fetching it 😄 This means, no support for HandlerArgumentsStamp, batch handling etc. as all arguments are resolved by "normal" DI (making all autowiring attributes work out of the box).

namespace App\Message;

use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

final readonly class DoSomething
{
    public function __construct(public string $person)
    {
    }

    #[AsMessageHandler(handles: self::class)]
    public function __invoke(LoggerInterface $logger): void
    {
        $logger->alert("Hello $this->person");
    }
}
namespace App\Messenger;

use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Handler\HandlerDescriptor;
use Symfony\Component\Messenger\Handler\HandlersLocatorInterface;

#[AsDecorator('messenger.bus.default.messenger.handlers_locator')]
class SelfHandlingLocator implements HandlersLocatorInterface
{
    private ?object $message = null;

    public function __construct(
        private readonly HandlersLocatorInterface $inner,
        #[AutowireLocator('messenger.self_handling_message')]
        private readonly ContainerInterface $handlers,
    ) {
    }

    public function getCurrentMessage(): ?object
    {
        return ([$this->message] = [null, $this->message])[1];
    }

    public function getHandlers(Envelope $envelope): iterable
    {
        if ($this->handlers->has($envelope->getMessage()::class)) {
            $this->message = $envelope->getMessage();
            // Note: this probably does not support from_transport etc options
            yield new HandlerDescriptor(fn () => $this->handlers->get($envelope->getMessage()::class));
            return; // this implementation does not support having both, self-handler & normal handler on a single message class
        }

        yield from $this->inner->getHandlers($envelope);
    }
}
namespace App;

use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel implements CompilerPassInterface
{
    use MicroKernelTrait;

    public function process(ContainerBuilder $container): void
    {
        foreach ($container->findTaggedServiceIds('messenger.message_handler') as $serviceId => $tags) {
            foreach ($tags as $attributes) {
                $handles = $attributes['handles'] ?? null;
                $definition = $container->getDefinition($serviceId);
                $class = $definition->getClass();
                if ($handles !== $class) {
                    continue;
                }

                $definition
                    ->addMethodCall($attributes['method'] ?? '__invoke')
                    ->addTag('messenger.self_handling_message')
                    ->setShared(false)
                    ->setFactory([new Reference('messenger.bus.default.messenger.handlers_locator'), 'getCurrentMessage']);
            }
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants
@kbond @stof @Wirone @valtzu @alexander-schranz @chalasr @carsonbot and others