Skip to content

[RFC] Attribute-based Interception for Service Methods #59730

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
yceruto opened this issue Feb 8, 2025 · 14 comments
Open

[RFC] Attribute-based Interception for Service Methods #59730

yceruto opened this issue Feb 8, 2025 · 14 comments
Labels
DependencyInjection DX DX = Developer eXperience (anything that improves the experience of using Symfony) Feature RFC RFC = Request For Comments (proposals about features that you want to be discussed)

Comments

@yceruto
Copy link
Member

yceruto commented Feb 8, 2025

This is a post-mortem research of #58076 to understand the outcomes, what worked, and what didn't, with the goal of finding actionable insights.

The topic focuses on AOP paradigm, in simple words: how to separate common extra features—like logging, caching, or handling transactions—from the code in your service methods. Let's picture what I mean exactly:

The What

(Logging method calls example)

Instead of doing this everywhere:

class MyBusinessService
{
    public function __construct(
        private LoggerInterface $logger,
    ) {
    }

    public function execute(int $arg): int
    {
        $result = // some complex logic that return a result...

        $this->logger->debug(sprintf('Method "%s()" was called with "$arg = %s" and return "%s".', __METHOD__, var_export($arg), var_export($result)));

        return $result;
    } 
}

I want to do this:

class ImportantBusinessService
{
    #[Log]
    public function execute(int $arg): int
    {
        return // some complex logic that return a result...
    } 
}

acting #[Log] as a join point (marker), indicating that an aspect (interceptor) should wrap the method execution:

class Log extends Options
{
    public function interceptedBy(): string
    {
        return LogMethodCall::class;
    }
}

class LogMethodCall implements CallableInterceptorInterface
{
    public function __construct(
        private LoggerInterface $logger,
    ) {
    }

    public function intercept(Closure $func, Options $options): Closure
    {
        // prepare something before the wrapping phase

        return function (mixed ...$args) use ($func) {
            // do something before the call

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

            // do something after the call. $this->logger->debug('...');

            return $result;
        };
    }
}

Consider other practical examples like caching the execution of slow, resource-intensive methods or managing database transactions (e.g. using Doctrine $em->wrapInTransaction()). This way, it lets you add extra behavior that aren't crucial to the business logic without cluttering the core code.

The How

#58076 proposed implementing this in a new component, but I now believe it should be part of the DI component instead, since the main issue here is "how to intercept the method call" when MyBusinessService::execute() is invoked.

Initially, I proposed a CallableWrapper to wrap the method before it's called, like a Controller listener or Messenger middleware where a callable invocation is controlled. However, this approach would restrict the feature to controllers or message handlers instead of applying it universally.

I'm now considering that an auto-generated proxy service is a better approach, yet it's more complex to implement. Suppose another service uses our MyBusinessService:

class OtherService
{
    public function __construct(
        private MyBusinessService $service,
    ) {
    }

    public function doSomething()
    {
        $this->service->execute(1); // do something with it
    }
}

imaging now that the DI component generates a proxy for MyBusinessService, like this:

// auto-generated proxy class (in cache)
class MyBusinessServiceProxy12345 extends MyBusinessService
{
    private InterceptorManagerInterface $manager;

    public static function createProxy12345(InterceptorManagerInterface $manager, array $parentArgs = []): self
    {
        $self = new self(...$parentArgs);
        $self->manager = $manager;

        return $self;
    }

    // only those public methods marked with interceptors would be overridden
    public function execute(mixed ...$args): int
    {
        return isset($this->manager) ? $this->manager->call(parent::execute(...), ...$args) : parent::execute(...$args);
    }
}

and it's the one injected into OtherService as a decorator of the MyBusinessService service. If the concrete service is injected using an interface, the auto-generated proxy should be different but can still be achieved using the decoration pattern.

Another possible solution -> #59730 (comment)

Known Limitations

This proxy approach will work if the method is called from another service (which is the most common case we aim to solve here). However, calling the method from another class excluded from the DI scope will not trigger any interceptor (as expected).

Are there any other limitations I might not be aware of?

Questions

The big questions are:

  • Do we want this feature to be implemented in the framework? I believe so, due to its complexity and the level of cohesion required for integration with the DI system.
  • Is the proxy solution a good alternative? So far, this is the best I can think of.
  • How to implement this proxy class properly?

related issues: #47184, #57079, #58076
references for similar approach:

Cheers!

@carsonbot carsonbot added the RFC RFC = Request For Comments (proposals about features that you want to be discussed) label Feb 8, 2025
@yceruto yceruto added Feature DependencyInjection DX DX = Developer eXperience (anything that improves the experience of using Symfony) labels Feb 8, 2025
@tourze
Copy link
Contributor

tourze commented Feb 8, 2025

AOP finds extensive application in the Java ecosystem. Taking into account the current trends and the evolutionary trajectory of Java, the proxy - based approach appears to be the most viable and efficient solution.
To implement a proxy - based AOP solution, one can make use of ProxyManager\Factory\AccessInterceptorValueHolderFactory and incorporate a CompilerPass that iterates through the services. This combination enables the successful realization of such an AOP solution.
However, it's worth noting that this particular solution has a high degree of reliance on the container implementation. Moreover, a significant number of developers tend to utilize only select Symfony components rather than the entire Symfony framework. Given these circumstances, it seems to me that implementing this within the Symfony framework might not be the most ideal choice. Instead, it could be more appropriately developed as a separate bundle.

@nikophil
Copy link
Contributor

nikophil commented Feb 9, 2025

The idea sounds great.

Nevertheless, the proxy approach have some other limitations: this cannot be done with final class or final methods, and I don't think this proxy stuff can be done thanks to PHP 8.4 proxy mechanism.

If using VarDumper component's virtual proxies, another problem could be that it cannot "proxify" methods which might return this

@yceruto
Copy link
Member Author

yceruto commented Feb 18, 2025

Yes, that would be other limitations, and we could throw an exception at compile time to let devs know that the interceptors cannot be applied if the class or method is declared as final

@yceruto
Copy link
Member Author

yceruto commented Feb 18, 2025

Although if the service is an interface it should viable as decorating the original service will be the only option we'll have, in that case, it doesn't matter if the class or method is final

@nikophil
Copy link
Contributor

yeah it would be actually very nice if the AOP attributes could be added on interface methods 👍

@chalasr
Copy link
Member

chalasr commented Feb 19, 2025

I like this. It's inline with the direction we took with attributes which is to reduce boilerplate around infrastructural concerns, simplifying configuration to allow focusing on business logic.

@yceruto
Copy link
Member Author

yceruto commented Feb 19, 2025

I do believe the PHP engine can improve the implementation by supporting hook calls for methods. I'm imagine something like this:

set_hook_func(Foo::class, 'barMethod', function (Closure $func, mixed ...$args) {
    // do something before or early return

    $result = $func(...$args); // call original or not

    // do something after

    return $result;
});

allowing full control over the func call:

  • do something before (having the func arguments in the context, in case you want to manipulate them)
  • call or skip the original func
  • do something after (having the func "return value" in the context, in case you want to manipulate it)

then a proxy class won't be necessary and all limitations regarding inheritance will be gone \o/

@yceruto
Copy link
Member Author

yceruto commented Feb 19, 2025

I found two functions that closely match the expected behavior via uopz extension:

but none of them allow you to call the original function dynamically, so they're limited to some specific use cases only.

@gaelreyrol
Copy link

@yceruto On this matter, you might want to take a look at the OpenTelemetry PHP Extension, which exposes a hook to wrap any calls, to trace it: https://github.com/open-telemetry/opentelemetry-php-instrumentation?tab=readme-ov-file#usage.

Still, it does not allow you to call the original function but you receive the related object, its params in the pre callback and the return value in the post callback.

@yceruto
Copy link
Member Author

yceruto commented Feb 19, 2025

thanks @gaelreyrol for the link, yes the thing is that if only one hook is allowed per function, we'll need full control over the func call so it can be wrapped into a set of interceptors with different targets (log, cache, transaction, etc.)

having pre/post hooks only will cover some cases, but still limited (e.g. not possible to skip the function call for caching interceptor, or manipulate the arguments before the call or the return value after)

@yceruto
Copy link
Member Author

yceruto commented Feb 19, 2025

we currently know exactly which methods will request interceptors, thanks to #[Attribute] and $builder->registerAttributeForAutoconfiguration(), and we can then compile a runtime artifact that sets up all hooks for those methods and the interceptors involved:

set_hook_func(Foo::class, 'barMethod', function (Closure $func, mixed ...$args) use ($interceptors) {
    return $interceptors->call($func, $args);
});

that way, devs won't have to deal with this high-level function but can work directly with their own interceptor adapter.

@yceruto
Copy link
Member Author

yceruto commented Feb 20, 2025

@ro0NL do you mean between the generated proxy and the targeted service? yes, if that's what you're referring to. Otherwise, no, it doesn't solve the whole problem as you need many interceptors for various methods and each method might be wrapped by multiple interceptors, and they shouldn't know about each other

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
DependencyInjection DX DX = Developer eXperience (anything that improves the experience of using Symfony) Feature RFC RFC = Request For Comments (proposals about features that you want to be discussed)
Projects
None yet
Development

No branches or pull requests

6 participants