Skip to content
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

[Decorator] Add component for callable decorators #58076

Open
wants to merge 1 commit into
base: 7.2
Choose a base branch
from

Conversation

yceruto
Copy link
Member

@yceruto yceruto commented Aug 24, 2024

Q A
Branch? 7.2
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 that users must know:

  1. The DecoratorInterface adapter (the decorator): contains the decoration implementation, essentially defining what should be done before and after the targeted callable is executed.
  2. The decorator attribute which must extend from DecoratorAttribute: links a callable to a decorator and collects its options if needed.

Example:

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

class DebugDecorator implements DecoratorInterface
{
    public function decorate(\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;
        };
    }
}
class GreetingController
{
    #[Debug]
    public function hello(string $name): void
    {
        echo "Hello $name\n"
    }
}

The Debug attribute holds all metadata needed for the linked decorator, which must be referenced using the DecoratorAttribute::decoratedBy() method. By default, this method returns static::class.'Decorator', following a convention similar to that of Constraint/Validator.

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

decorators

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

Decorators 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 decorator implementation:

#[\Attribute(\Attribute::TARGET_METHOD)]
class Debug extends DecoratorAttribute
{
    public function __construct(
        public readonly string $prefix = '',
    ) {
    }
}
class DebugDecorator implements DecoratorInterface
{
    public function decorate(\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 decorator.callable_decorator -> CallableDecorator -> aliasing to DecoratorInterface will be available with all collected decorator services tagged with decorator (see DecoratorsPass). Then, you'll do:

# In any middleware layer
$decorated = $callableDecorator->decorate($callable(...)); // to apply the decorators around that callable
$decorated(...$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 decorator from the decorators stack
✅ You can combine several behaviors by wrapping a callable into multiple decorators ❌ It’s hard to implement a decorator in such a way that its behavior doesn’t depend on the order in the decorators 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 decorators 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 request/response mechanisms that contain a middleware layer.

TODO:

  • Add framework integration to support decorators for controllers firstly.
  • Add some useful decorators (e.g. Transactional for Doctrine transactions).

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
Contributor

@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!

@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
Contributor

@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
Copy link
Member Author

yceruto commented Sep 4, 2024

The last commit simplifies decorator definition by allowing it to extend Decorate attribute and implement the DecoratorInterface at the same time (in an all-in-one approach) which is very convenient when your decorator doesn't require dependencies and doesn't need to be a service.

Example:

#[\Attribute(\Attribute::TARGET_METHOD)]
class MyDecorator extends DecoratorAttribute implements DecoratorInterface
{
    public function decorate(\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;
        };
    }

    public function decoratedBy(): string
    {
        return self::class;
    }
}

class GreetingController
{
    #[MyDecorator]
    public function sayHello(string $name): void
    {
        echo "Hello $name!\n";
    }
}

@yceruto
Copy link
Member Author

yceruto commented Sep 4, 2024

Well, we could also apply this simple approach for decorator services by implementing ServiceSubscriberInterface + ServiceMethodsSubscriberTrait and leaving the constructor free for decorator options only:

#[\Attribute(\Attribute::TARGET_METHOD)]
class MyDecorator extends DecoratorAttribute implements DecoratorInterface, ServiceSubscriberInterface
{
    use ServiceMethodsSubscriberTrait;

    public function __construct(public string $foo = 'bar')
    {
    }

    public function decorate(\Closure $func, self $metadata = new self()): \Closure
    {
        // do something with $this->someDependency()
 
        return function (mixed ...$args) use ($func, $metadata): mixed
        {
            echo "Do something with $metadata->foo before\n";

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

            echo "Do something with $metadata->foo after\n";

            return $result;
        };
    }

    public function decoratedBy(): string
    {
        return self::class;
    }

    #[SubscribedService]
    protected function someDependency(): SomeDependency
    {
        return $this->container->get(__METHOD__);
    }
}

class GreetingController
{
    #[MyDecorator(foo: 'baz')]
    public function sayHello(string $name): void
    {
        echo "Hello $name!\n";
    }
}

@yceruto
Copy link
Member Author

yceruto commented Sep 6, 2024

Status: Improving proposal design

Still not happy with the way attribute options are passed through the decorator implementation, it feels a bit hacky and requires knowledge/documentation.

parent::__construct(self::class, ['foo' => $foo]);

The way this function decoration works will require knowledge to wrap the function call:

return function (mixed ...$args) use ($func): mixed { // <-- not obvious you've to do it like this to wrap the func call
    // return $func(...$args) 
}

I was looking into the Constraint/Validator design and I found it very similar to what I'd like to achieve here. So I'm going to make some changes to improve the DX.

@yceruto yceruto force-pushed the decorator branch 2 times, most recently from 3540c1b to 3b2dbc4 Compare September 6, 2024 21:48
@yceruto
Copy link
Member Author

yceruto commented Sep 6, 2024

Proposal update for better DX, please recheck PR description.

@ostrolucky
Copy link
Contributor

ostrolucky commented Sep 8, 2024

I had a need for this couple times, but it was always with passing inheritance checks. This component is not doing that, it doesn't try to create a class that follows the hierarchy of decorated class, does it? I'm doing that eg. at https://github.com/snc/SncRedisBundle/blob/c095a0dadc6fd263e54e064f82b9dc4c8e7f9823/src/Factory/PhpredisClientFactory.php#L320 (thanks to proxy-manager). Without this, component is going to be much less useful, as you can't inject it in place whatever else was being injected before without changing the place where it's getting injected.

@yceruto yceruto force-pushed the decorator branch 2 times, most recently from 3738e84 to 4fb4628 Compare September 17, 2024 03:12
@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 2 times, most recently from c2a80cf to 333c3a5 Compare October 3, 2024 16:49
@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 issue 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 a generic 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!

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
8 participants