Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

[CallableWrapper] Add CallableWrapper component and framework integration #58076

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

Closed
wants to merge 1 commit into from

Conversation

yceruto
Copy link
Member

@yceruto yceruto commented Aug 24, 2024

Q A
Branch? 7.3
Bug fix? no
New feature? yes
Deprecations? no
Issues Fix #57079
License MIT

This is an attempt to solve the linked issue in a generic way, making it applicable in other areas where it might be useful as well.

Note

Inspired by Python decorators

This component implements the Decorator Pattern around any PHP callable, allowing you to:

  • Execute logic before or after a callable is executed
  • Skip the execution of a callable by returning earlier
  • Modify the result of a callable

It solves issues related to subclass explosion and inflexibility in inheritance-based designs. The pattern is useful for extending object behavior without modifying the original code, making it ideal for scenarios where different combinations of behaviors are needed.

The component contains two main building blocks:

  1. The CallableWrapperInterface adapter (the wrapper): contains the wrapping implementation, essentially defining what should be done before and after the targeted callable is executed.
  2. The wrapper attribute which must extend from CallableWrapperAttribute or CallableWrapperAttributeInterface: links a callable to a wrapper and collects its options if needed.

Example:

#[\Attribute(\Attribute::TARGET_METHOD)]
class Debug extends CallableWrapperAttribute
{
}

class DebugCallableWrapper implements CallableWrapperInterface
{
    public function wrap(\Closure $func): \Closure
    {
        return function (mixed ...$args) use ($func): mixed {
            echo "Do something before\n";

            $result = $func(...$args);

            echo "Do something after\n";

            return $result;
        };
    }
}
#[AsController]
class GreetingController
{
    #[Debug]
    public function hello(string $name): void
    {
        echo "Hello $name\n"
    }
}
#[AsMessageHandler]
class GreetingMessageHandler
{
    #[Debug]
    public function __invoke(Message $message): void
    {
        echo "Hello $message->name \n"
    }
}

The Debug attribute holds all metadata needed for the linked wrapper, which must be referenced using the CallableWrapperAttributeInterface::wrappedBy() method. By default, the abstract CallableWrapperAttribute class returns static::class.'CallableWrapper' as convention.

Wrappers can be nested, so the order in which you define them around the targeted callable really matters. This is a visual representation of multiple wrappers around a function and how they wrap around each other:

image

In short, the closer a wrapper is to the targeted callable, the higher its execution priority.

Callable wrappers might require some options. To handle this, you can define them using the constructor method and add the metadata attribute as a new argument in your wrapper implementation:

#[\Attribute(\Attribute::TARGET_METHOD)]
class Debug extends CallableWrapperAttribute
{
    public function __construct(
        public readonly string $prefix = '',
    ) {
    }
}
class DebugCallableWrapper implements CallableWrapperInterface
{
    public function wrap(\Closure $func, Debug $debug = new Debug()): \Closure
    {
        // do something with $debug->prefix
    }
}

Final usage:

class GreetingHandler
{
    #[Debug(prefix: 'say: ')]
    public function hello(string $name): void
    {
        echo "Hello $name\n"
    }
}

The component requires a middleware layer to wrap the targeted callable before it's actually called. So, after the framework integration, a service named callable_wrapper -> CallableWrapper -> aliasing to CallableWrapperInterface will be available with all collected wrapper services tagged with callable_wrapper (see CallableWrapperPass). Then, you'll do:

# In any middleware layer
$wrapped = $callableWrapper->wrap($callable(...)); // to apply the wrappers around that callable
$wrapped(...$args); // to execute it
Pros Cons
✅ You can extend an object's behavior without making a new subclass ❌ It’s hard to remove a specific wrapper from the wrappers stack
✅ You can combine several behaviors by wrapping a callable into multiple wrappers ❌ It’s hard to implement a wrapper in such a way that its behavior doesn’t depend on the order in the wrapper stack
✅ Single Responsibility Principle. You can divide a monolithic callable that implements many possible variants of behavior into several smaller callable.

Refer to the tests for more details on use cases and how wrappers are executed in the final stage.

Important

Why create a new component? Because it can be implemented across various components of our Symfony project and application, such as HttpKernel/Controllers, Messenger/Handlers, and potentially other custom producer/consumer mechanisms that contain a middleware layer.

Cheers!

@94noni
Copy link
Contributor

94noni commented Aug 24, 2024

Hello @yceruto
Interesting feature, may i ask you if such component can be used to leverage thing like this
#47184
thank you

@yceruto
Copy link
Member Author

yceruto commented Aug 24, 2024

@94noni In principle, yes, but not by default for every service. It would require a middleware layer to hook into it before the method is called. Currently, only proxies can do that for services. Somehow, collect the class/method decorators for each service and call them from within? but not sure about performance though.

@yceruto
Copy link
Member Author

yceruto commented Aug 25, 2024

✅ Added Framework integration and controller decoration support

✅ As well as a new Doctrine transaction decorator through #[Transactional] attribute. This decorator can easily help decouple the business logic layer from the infrastructure/persistence layer in any layered-arch app. For example, using the collection-oriented Repository pattern, we can write an API endpoint like this one:

#[Route('/tasks', methods: 'POST')]
class CreateTaskController
{
    public function __construct(private TaskRepositoryInterface $repository)
    {
    }

    #[Serialize(format: 'json')]
    #[Transactional]
    public function __invoke(#[MapRequestPayload] TaskPayload $payload): Task
    {
        $task = new Task($payload->description, $payload->dueDate);

        $this->repository->add($task); // $this->entityManager->persist($task);

        return $task;
    }
}

Keeping the controller code free of persistence and presentation deps. The #[Serialize] decorator can be used as a Serializer (food for another PR), which serializes the result of the given function according to the request format.

This is just an example. There are more approaches and architectures where decorators can be useful.

Copy link
Member

@alexandre-daubois alexandre-daubois left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a big fan on putting readonly on each and every class. I'm not sure it does make much sense on services, it makes more sense on DTO/VOs to me. But that's just my two cents. 🙂

However, I like very much the idea behind this component. Thank you for proposing it!

src/Symfony/Component/Decorator/DecoratorInterface.php Outdated Show resolved Hide resolved
src/Symfony/Component/Decorator/Attribute/Decorate.php Outdated Show resolved Hide resolved
@yceruto yceruto changed the title [Decorator] Add new component for callable decorators Add new Decorator component Aug 29, 2024
@yceruto yceruto force-pushed the decorator branch 3 times, most recently from 3dc5c9c to 8017d9c Compare August 29, 2024 12:25
Copy link
Member

@alexandre-daubois alexandre-daubois left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New round 😄

@yceruto
Copy link
Member Author

yceruto commented Aug 29, 2024

I updated the PR description to include the Pros and Cons

@maxhelias
Copy link
Contributor

Interesting! If I understand correctly, I see another useful decorators like “memoize” to cache the result of a method. Looking forward to seeing more feedback 😉

@yceruto yceruto force-pushed the decorator branch 2 times, most recently from 29a47fc to b1a6ae6 Compare September 4, 2024 05:21
@yceruto yceruto changed the title Add new Decorator component [Decorator] Add component for callable decorators Sep 17, 2024
@yceruto yceruto force-pushed the decorator branch 3 times, most recently from d2a7c34 to 4e0e297 Compare September 20, 2024 04:05
@yceruto yceruto force-pushed the decorator branch 3 times, most recently from 333c3a5 to 222ee58 Compare October 3, 2024 17:05
@yceruto
Copy link
Member Author

yceruto commented Oct 14, 2024

@ostrolucky I understand that this might be a bit confusing at first, especially since we’re already familiar with a decoration method that addresses this kind of problem. However, this new component and its integration are intentionally designed to solve the decoration need in a different way for two main reasons:

  1. Support for multiple and nested decorators: this approach allows for several decorators to be applied and even nested, providing greater flexibility in how we handle decorations.
  2. Reusability and decoupling: by decoupling the decorator from the targeted method, we enhance reusability. Decorators no longer need to be aware of any specific class or method signature, making them more versatile.

In other words, instead of decorating the service using inheritance (as we used to do by making the decorator aware of the target class and methods), this decoration method targets any callable. This means that multiple decorators can be applied to any callable, and these decorators can be reused without needing to know the details of the classes or methods they’re decorating.

Of course, this approach is best suited for situations where this kind of decoration is beneficial, such as in handlers or controllers.

@RobinHoutevelts
Copy link

I (at least I think so) like this! 🤩
However, I do think I might get a fatigue of repeating the same decorators.

#[CollectCacheTags]
#[Serialize(format: 'json')]
#[Validate]
public function __invoke(#[MapRequestPayload] TaskPayload $payload): Task

Is there a way to combine these three to eg #[JsonRequest], or should I go "back" to using regular decorators? The use-case above is a bad example, but hopefully my question makes sense. Or would a (future) improvement be to have "compound" decorators like we have for constraints?

@yceruto
Copy link
Member Author

yceruto commented Nov 4, 2024

@RobinHoutevelts great idea! It’s definitely possible to add such a nice feature. In the meantime, I’m backporting it into yceruto/decorator-bundle with support for Compound decorators already ;) thanks!

@fabpot fabpot modified the milestones: 7.2, 7.3 Nov 20, 2024
@yceruto yceruto changed the title [Decorator] Add component for callable decorators [Decorator] Add Callable Decorator Component Dec 2, 2024
@yceruto yceruto changed the title [Decorator] Add Callable Decorator Component Add Callable Decorator Component Dec 2, 2024
@norkunas
Copy link
Contributor

norkunas commented Dec 5, 2024

I think this could be useful to integrate locking.

But if the key is dynamic would it be possible to use something similar like with #[IsGranted(PostVoter::DELETE, subject: new Expression('args["postId"]'))] where you can pass arguments to build the lock key?

@yceruto
Copy link
Member Author

yceruto commented Dec 5, 2024

I think this could be useful to integrate locking.

But if the key is dynamic would it be possible to use something similar like with #[IsGranted(PostVoter::DELETE, subject: new Expression('args["postId"]'))] where you can pass arguments to build the lock key?

It's possible because you have control over the controller's arguments during the decoration phase. At this point, all arguments are already resolved, and their values are the same as what you'll receive in the controller method. However, it's harder to manipulate the arguments since they are just a simple list of values at this stage (not named arguments).

I think that for use cases where you want to add logic based on the controller’s arguments, using a ControllerArgumentsEvent listener is a better and simpler option.

I'd say that a Callable Decorator is a more generic & versatile solution for prepending or appending logic around controllers or any other callable, allowing you to combine decorators or reuse them for different callables. The Transactional decorator is one of the best examples, but I can see many other like caching responses, serializing results, rate limiting, error handling, etc.

@yceruto yceruto changed the title Add Callable Decorator Component Add Callable Decorator component and framework integration Dec 13, 2024
@yceruto
Copy link
Member Author

yceruto commented Dec 13, 2024

I added the Messenger/handler integration since the changes are minimal. Here's what this feature allows you to do:

namespace App\Handler;

use Symfony\Bridge\Doctrine\Decorator\Transactional;

#[AsMessageHandler]
class ProductCreatedEventHandler
{
    #[Transactional]
    public function __invoke(ProductCreatedEvent $event): void
    {
        // update other related data...
    }
}

Similar to controllers, callable decorators are helpful when you need to decorate the implementation of specific handlers. However, if the decorator's logic applies to all handlers on the message bus, it's better to create a middleware instead.

@yceruto yceruto changed the title Add Callable Decorator component and framework integration [CallableWrapper] Add CallableWrapper component and framework integration Dec 13, 2024
@yceruto
Copy link
Member Author

yceruto commented Dec 13, 2024

After an internal discussion about the component's name, we decided to rename it to "CallableWrapper". PR and description updated.

@yceruto
Copy link
Member Author

yceruto commented Feb 8, 2025

I'm closing this for now. I'll revisit this topic soon via an RFC issue with more details on how to implement this feature in a way I believe is better than the one proposed here. Thanks all for your inputs!

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

Successfully merging this pull request may close these issues.

[HttpKernel] Add before and after hooks to controller actions
10 participants
Morty Proxy This is a proxified and sanitized view of the page, visit original site.