Aspect Oriented Programming in PHP

Glynn Forrest

me@glynnforrest.com

Introduction

A common problem

Adding logs to an application

class DataRepository
{
    protected $connection;

    public function __construct(Connection $connection)
    {
        $this->connection = $connection;
    }

    public function fetchData()
    {
        return $this->connection->select('some_table');
    }

    public function fetchMoreData()
    {
        return $this->connection->select('some_other_table');
    }
}

Boss: "Please write to a log file every time this class accesses the database"

Add logging

class DataRepository
{
    protected $connection;
    protected $logger;

    public function __construct(Connection $connection)
    {
        $this->connection = $connection;
        $this->logger = new Logger(new StreamHandler('/path/to/log/file'));
    }

    public function fetchData()
    {
        $this->logger->debug('fetching some data');

        return $this->connection->select('some_table');
    }

    public function fetchMoreData()
    {
        $this->logger->debug('fetching some other data');

        return $this->connection->select('some_other_table');
    }
}

This works but isn't very flexible

Logger is hard coded into the class

Class is inflexible, breaks Single Responsibility Principle

What if the log file needs to change?

Boss: "Please write all logs to a centralised logging server"

Use Dependency Injection to inject the logger

public function __construct(Connection $connection, LoggerInterface $logger = null)
{
    $this->connection = $connection;
    $this->logger = $logger;
}

Configure the logger outside of the class to aid reuse

$connection = new Connection();

$logger = new Logger(new StreamHandler('/path/to/log/file'));

//Send to a Graylog2 server
$logger = new Logger(new GelfHandler(new MessagePublisher()));

//No logger
$logger = null;

$repository = new DataRepository($connection, $logger);

Now that the logger is optional, the methods need to be updated

public function fetchData()
{
    if ($this->logger) {
        $this->logger->debug('fetching some data');
    }

    return $this->connection->select('some_table');
}

public function fetchMoreData()
{
    if ($this->logger) {
        $this->logger->debug('fetching some other data');
    }

    return $this->connection->select('some_other_table');
}

The "null logger problem"

Repeating this for every method will grow tedious

Or even other classes

There has to be a smarter way

Create a dynamic proxy class

class LoggerProxy
{
    protected $object;
    protected $logger;

    public function __construct($object, LoggerInterface $logger = null)
    {
        $this->object = $object;
        $this->logger = $logger;
    }

    public function __call($method, $arguments)
    {
        if ($this->logger) {
            $this->logger->debug(sprintf('Calling method %s()', $method));
        }

        return call_user_func_array([$this->object, $method], $arguments);
    }
}

Removes the logger dependency from the service

Usage

$connection = new Connection();
$repository = new DataRepository($connection);

$logger = new Logger(new GelfHandler(new MessagePublisher()));
$loggerProxy = new LoggerProxy($repository, $logger);

$loggerProxy->getData();
//Calling method getData()

But there is a major problem with this approach

public function expectsDataRepository(DataRepository $repository)
{
    $data = $repository->getData();

    return $this->mangle($data);
}

LoggerProxy won't satisfy the type hint

What about a decorator?

class LoggingDataRepository extends DataRepository
{
    protected $logger;

    public function __construct(Connection $connection, LoggerInterface $logger)
    {
        $this->logger = $logger;
        parent::__construct($connection);
    }

    public function fetchData()
    {
        $this->logger->debug('fetching some data');

        return parent::fetchData();
    }

    public function fetchMoreData()
    {
        $this->logger->debug('fetching some other data');

        return parent::fetchMoreData();
    }
}

These are tedious to write but can be auto-generated

Feature creep

Boss: "Cache the result of some of these methods to speed up data access"

"But only on our peak times of 5pm - 8pm, Friday, Saturday and Sunday, where we can tolerate slightly stale data"

"And some methods should have a higher TTL than others"

"Oh, and I want stopwatch measurements taken when the database is accessed to check the performance isn't degrading"

The more features we add, the more the class does

Breaks SRP even more

Repeat this across a whole codebase

Solving with AOP

Dynamically do stuff before or after a method call, or override it entirely

Without modifying the original code

Terminology

Cross cutting concerns

Common functionality that is used in many classes

"Even though most classes in an OO model will perform a single, specific function, they often share common, secondary requirements with other classes" - W. Kipedia

E.g.

All classes that fetch data from an API or database should cache their results.

All classes that delete objects from the database should log the deletion using an AuditLogger class.

Pointcut

Code or configuration that specifies the code to be intercepted

Apply to all methods that begin with 'save'

Apply to all classes in the Foo\Bar namespace that extend EntityRepository

Advice

Code that does something before or after a method call

Can also be called 'interceptors'

Aspect

Advice combined with Pointcuts are called Aspects.

A 'database logging aspect' logs calls to repository classes when the method name begins with 'save'

JMSAopBundle

Add AOP capabilities to Symfony

Install

composer require jms/aop-bundle
// app/AppKernel.php
public function registerBundles()
{
    $bundles = array(
        //...
        new JMS\AopBundle\JMSAopBundle(),
    );

    return $bundles;
}

Configure

#config.yml
jms_aop:
      cache_dir: '%kernel.cache_dir%/jms_aop'

Logger problem continued

Log all calls to a repository class

Create a pointcut

use JMS\AopBundle\Aop\PointcutInterface;

class BookRepositoryPointcut implements PointcutInterface
{
    public function matchesClass(\ReflectionClass $class)
    {
        return $class->name === 'AppBundle\Repository\BookRepository';
    }

    public function matchesMethod(\ReflectionMethod $method)
    {
        //match all methods
        return true;
    }
}

Create an interceptor

use CG\Proxy\MethodInterceptorInterface;
use CG\Proxy\MethodInvocation;
use Psr\Log\LoggerInterface;

class LoggingInterceptor implements MethodInterceptorInterface
{
    protected $logger;

    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    public function intercept(MethodInvocation $invocation)
    {
        $this->logger->debug(sprintf('Before method %s()', $invocation));

        return $invocation->proceed();
    }
}

$invocation->proceed() actually runs the advised method

Register pointcut and interceptor services

#services.yml

book_repo_pointcut:
    class: AppBundle\Pointcut\BookRepositoryPointcut
    tags:
        - { name: jms_aop.pointcut, interceptor: logging_interceptor }

logging_interceptor:
    class: AppBundle\Interceptor\LoggingInterceptor
    arguments:
        - @logger

Tell JmsAopBundle about pointcuts using 'jms\\aop.pointcut' tag

Usage

public function latestAction()
{
    $books = $this->get('book_repo')->findLatest();
    // Before method findLatest()

    return $this->render(
        'AppBundle:Bookshop:list.html.twig', [
            'books' => $books
        ]
    );
}

Create a pointcut using an annotation

Disable access to a method during weekdays

Create an annotation

namespace AppBundle\Annotation;

/**
 * @Annotation
 **/
class WeekendsOnly
{
}

Create a pointcut

use JMS\AopBundle\Aop\PointcutInterface;
use Doctrine\Common\Annotations\Reader;

class WeekendsOnlyPointcut implements PointcutInterface
{
    public function __construct(Reader $reader)
    {
        $this->reader = $reader;
    }

    public function matchesClass(\ReflectionClass $class)
    {
        //match all classes
        return true;
    }

    public function matchesMethod(\ReflectionMethod $method)
    {
        //match methods with the WeekendsOnly annotation
        return null !== $this->reader->getMethodAnnotation($method, 'AppBundle\Annotation\WeekendsOnly');
    }
}

Create an interceptor

use CG\Proxy\MethodInterceptorInterface;
use CG\Proxy\MethodInvocation;

class WeekendsOnlyInterceptor implements MethodInterceptorInterface
{
    public function intercept(MethodInvocation $invocation)
    {
        $now = new \DateTime();
        if ((int) $now->format('N') < 6) {
            throw new \Exception('This method may only be called on weekends');
        }

        return $invocation->proceed();
    }
}

Register pointcut and interceptor services

#services.yml

weekends_only_pointcut:
    class: AppBundle\Pointcut\WeekendsOnlyPointcut
    arguments:
        - @annotation_reader
    tags:
        - { name: jms_aop.pointcut, interceptor: weekends_only_interceptor }

weekends_only_interceptor:
    class: AppBundle\Interceptor\WeekendsOnlyInterceptor

Usage

//AppBundle\Repository\BookRepository

/**
 * @WeekendsOnly
 */
public function findWeekendSpecials()
public function listSpecialsAction()
{
    $books = $this->get('book_repo')->findWeekendSpecials();
    // Exception: This method may only be called on weekends

    return $this->render(
        'AppBundle:Bookshop:list.html.twig', [
            'books' => $books
        ]
    );
}

How does it work?

JmsAopBundle modifies service definitions in the Symfony container using a custom compiler pass that uses tagged pointcut service definitions to dynamically create proxy classes that are registered in the container.

Compiler passes

Symfony Dependency Injection component 'compiles' service definitions into a single cached class

First it collects all the service definitions from XML, Yaml and PHP configuration

<!-- FrameworkBundle/Resources/config/form.xml -->

<!-- FormFactory -->
<service id="form.factory" class="%form.factory.class%">
    <argument type="service" id="form.registry" />
    <argument type="service" id="form.resolved_type_factory" />
</service>
# services.yml
book_repo_pointcut:
    class: AppBundle\Pointcut\BookRepositoryPointcut
    tags:
        - { name: jms_aop.pointcut, interceptor: logging_interceptor }

Then dumps them in the cache as a single php class for quick usage in subsequent requests

/**
 * appDevDebugProjectContainer
 *
 * This class has been auto-generated
 * by the Symfony Dependency Injection Component.
 */
class appDevDebugProjectContainer extends Container
protected function getForm_FactoryService()
{
    return $this->services['form.factory'] = new \Symfony\Component\Form\FormFactory($this->get('form.registry'), $this->get('form.resolved_type_factory'));
}
protected function getBookRepoPointcutService()
{
    return $this->services['book_repo_pointcut'] = new \AppBundle\Pointcut\BookRepositoryPointcut();
}

This is why Symfony requires a cache warmup

CompilerPass classes modify and optimise the container before dumping

Symfony/Component/DependencyInjection/Compiler/

AnalyzeServiceReferencesPass
CheckCircularReferencesPass
CheckDefinitionValidityPass
CheckExceptionOnInvalidReferenceBehaviorPass
CheckReferenceValidityPass
DecoratorServicePass
InlineServiceDefinitionsPass
MergeExtensionConfigurationPass
RemoveAbstractDefinitionsPass
RemovePrivateAliasesPass
RemoveUnusedDefinitionsPass
RepeatedPass
ReplaceAliasByActualDefinitionPass
ResolveDefinitionTemplatesPass
ResolveInvalidReferencesPass
ResolveParameterPlaceHoldersPass
ResolveReferencesToAliasesPass

In the FrameworkBundle

Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler

AddCacheClearerPass
AddCacheWarmerPass
AddConsoleCommandPass
AddConstraintValidatorsPass
AddExpressionLanguageProvidersPass
AddValidatorInitializersPass
CompilerDebugDumpPass
ContainerBuilderDebugDumpPass
FormPass
FragmentRendererPass
LoggingTranslatorPass
ProfilerPass
RoutingResolverPass
SerializerPass
TemplatingAssetHelperPass
TemplatingPass
TranslationDumperPass
TranslationExtractorPass
TranslatorPass

Some passes use tags to alter service definitions

// Symfony/Bundle/TwigBundle/DependencyInjection/Compiler/TwigEnvironmentPass.php

public function process(ContainerBuilder $container)
{
    if (false === $container->hasDefinition('twig')) {
        return;
    }

    $definition = $container->getDefinition('twig');

    $calls = $definition->getMethodCalls();
    $definition->setMethodCalls(array());
    foreach ($container->findTaggedServiceIds('twig.extension') as $id => $attributes) {
        $definition->addMethodCall('addExtension', array(new Reference($id)));
    }
    $definition->setMethodCalls(array_merge($definition->getMethodCalls(), $calls));
}

TwigBundle fetches all services with the twig.extension tag and adds it to the Twig service with the 'addExtension' method

protected function getTwigService()
{
    $this->services['twig'] = $instance = new \Twig_Environment($this->get('twig.loader'), array('debug' => true, 'strict_variables' => true, 'exception_controller' => 'twig.controller.exception:showAction', 'form_themes' => array(0 => 'form_div_layout.html.twig'), 'autoescape' => array(0 => 'Symfony\\Bundle\\TwigBundle\\TwigDefaultEscapingStrategy', 1 => 'guess'), 'cache' => (__DIR__.'/twig'), 'charset' => 'UTF-8', 'paths' => array()));

    $instance->addExtension(new \Symfony\Bundle\SecurityBundle\Twig\Extension\LogoutUrlExtension($this->get('templating.helper.logout_url')));
    $instance->addExtension(new \Symfony\Bridge\Twig\Extension\SecurityExtension($this->get('security.context', ContainerInterface::NULL_ON_INVALID_REFERENCE)));
    $instance->addExtension(new \Symfony\Bridge\Twig\Extension\TranslationExtension($this->get('translator')));

    //many more

    $instance->addGlobal('app', $this->get('templating.globals'));

    return $instance;
}

Compiler Pass for JMSAOPBundle

// JMS/AopBundle/DependencyInjection/Compiler/PointcutMatchingPass.php
public function process(ContainerBuilder $container)
{
    $this->container = $container;
    $this->cacheDir = $container->getParameter('jms_aop.cache_dir').'/proxies';
    $pointcuts = $this->getPointcuts();

    $interceptors = array();
    foreach ($container->getDefinitions() as $id => $definition) {
        $this->processDefinition($definition, $pointcuts, $interceptors);

        $this->processInlineDefinitions($pointcuts, $interceptors, $definition->getArguments());
        $this->processInlineDefinitions($pointcuts, $interceptors, $definition->getMethodCalls());
        $this->processInlineDefinitions($pointcuts, $interceptors, $definition->getProperties());
    }

    $container
        ->getDefinition('jms_aop.interceptor_loader')
        ->addArgument($interceptors)
    ;
}

Filter definitions that can't be extended

private function processDefinition(Definition $definition, $pointcuts, &$interceptors)
{
    if ($definition->isSynthetic()) {
        return;
    }

    if ($definition->getFactoryService() || $definition->getFactoryClass()) {
        return;
    }

    if ($file = $definition->getFile()) {
        require_once $file;
    }

    if (!class_exists($definition->getClass())) {
        return;
    }

Passes every service to every pointcut service to check if it matches

$class = new \ReflectionClass($definition->getClass());

// check if class is matched
$matchingPointcuts = array();
foreach ($pointcuts as $interceptor => $pointcut) {
    if ($pointcut->matchesClass($class)) {
        $matchingPointcuts[$interceptor] = $pointcut;
    }
}

if (empty($matchingPointcuts)) {
    return;
}

$this->addResources($class, $this->container);

if ($class->isFinal()) {
    return;
}

Of all the services that match, find the methods that match

$classAdvices = array();
foreach (ReflectionUtils::getOverrideableMethods($class) as $method) {

    if ('__construct' === $method->name) {
        continue;
    }

    $advices = array();
    foreach ($matchingPointcuts as $interceptor => $pointcut) {
        if ($pointcut->matchesMethod($method)) {
            $advices[] = $interceptor;
        }
    }

    if (empty($advices)) {
        continue;
    }

    $classAdvices[$method->name] = $advices;
}

if (empty($classAdvices)) {
    return;
}

Finally, create a proxy class that overrides the matched methods and modifying the service definition in the container

$interceptors[ClassUtils::getUserClass($class->name)] = $classAdvices;

$generator = new InterceptionGenerator();
$generator->setFilter(function(\ReflectionMethod $method) use ($classAdvices) {
    return isset($classAdvices[$method->name]);
});
if ($file) {
    $generator->setRequiredFile($file);
}
$enhancer = new Enhancer($class, array(), array(
    $generator
));
$enhancer->setNamingStrategy(new DefaultNamingStrategy('EnhancedProxy'.substr(md5($this->container->getParameter('jms_aop.cache_dir')), 0, 8)));
$enhancer->writeClass($filename = $this->cacheDir.'/'.str_replace('\\', '-', $class->name).'.php');
$definition->setFile($filename);
$definition->setClass($enhancer->getClassName($class));
$definition->addMethodCall('__CGInterception__setLoader', array(
    new Reference('jms_aop.interceptor_loader')
));

Generated proxy class

namespace EnhancedProxy9c5ca14b_509a75d0bbf13b28c09d230022fce10eb0b436e4\__CG__\AppBundle\Repository;

/**
 * CG library enhanced proxy class.
 *
 * This code was generated automatically by the CG library, manual changes to it
 * will be lost upon next generation.
 */
class BookRepository extends \AppBundle\Repository\BookRepository
{
    private $__CGInterception__loader;

Methods are wrapped with MethodInvocation instances and passed to interceptors

public function findLatest($limit = 20)
{
    $ref = new \ReflectionMethod('AppBundle\\Repository\\BookRepository', 'findLatest');
    $interceptors = $this->__CGInterception__loader->loadInterceptors($ref, $this, array($limit));
    $invocation = new \CG\Proxy\MethodInvocation($ref, $this, array($limit), $interceptors);

    return $invocation->proceed();
}

Modified container definition

protected function getBookRepoService()
{
    require_once (__DIR__.'/jms_aop/proxies/AppBundle-Repository-BookRepository.php');

    $this->services['book_repo'] = $instance = new \EnhancedProxy9c5ca14b_509a75d0bbf13b28c09d230022fce10eb0b436e4\__CG__\AppBundle\Repository\BookRepository($this->get('doctrine.orm.default_entity_manager'), $this->get('book_metadata'));

    $instance->__CGInterception__setLoader($this->get('jms_aop.interceptor_loader'));

    return $instance;
}

Considerations

Services only - will not work for classes instantiated outside of the container

Doesn't work for services with factories - e.g. Doctrine repositories

Can't extend final classes or private methods

Pointcut matching is only run once, when the container is built

Reflection is still used for every method call, so is slightly slower

GO AOP

Standalone framework that brings advanced AOP concepts to PHP

Install and configure

composer require lisachenko/go-aop-php

Create an Aspect Kernel

//app/ApplicationAspectKernel.php

use Go\Core\AspectKernel;
use Go\Core\AspectContainer;

class ApplicationAspectKernel extends AspectKernel
{
    /**
     * Configure an AspectContainer with advisors, aspects and pointcuts
     *
     * @param AspectContainer $container
     *
     * @return void
     */
    protected function configureAop(AspectContainer $container)
    {
        //add aspects here
    }
}

Add the Application Aspect Kernel to the front controller

// web/app_dev.php
require_once __DIR__.'/../app/ApplicationAspectKernel.php';

$applicationAspectKernel = ApplicationAspectKernel::getInstance();
$applicationAspectKernel->init([
    'debug' => true,
    'cacheDir' => __DIR__.'/../app/cache/dev/go-aop',
    'includePaths' => [
        __DIR__.'/../src',
    ],
]);

Add an Aspect

Create an Aspect class

use Go\Aop\Aspect;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Annotation\Before;

class SayHelloAspect implements Aspect
{

    /**
     * @Before("@annotation(AppBundle\Annotation\SayHello)")
     */
    public function before(MethodInvocation $invocation)
    {
        $message = sprintf(
            'Hello from GO AOP before %s->%s()',
            get_class($invocation->getThis()),
            $invocation->getMethod()->name
        );

        \Symfony\Component\VarDumper\VarDumper::dump($message);
    }
}

Add it to the Aspect Kernel

protected function configureAop(AspectContainer $container)
{
    $container->registerAspect(new SayHelloAspect());
}

Usage

//AppBundle\Repository\BookRepository

/**
 * @SayHello
 */
public function createRandom()
public function createRandomAction()
{
    $book = $this->get('book_repo')->createRandom();
    //Hello from GO AOP before BookRepository->createRandom()

    return $this->render(
        'AppBundle:Bookshop:createRandom.html.twig', [
            'book' => $book
        ]
    );
}

Pointcut matching

No need to create a separate pointcut class

Pointcuts can be added with annotations on the aspect method

@Before()

@After()

@Around()

@AfterThrowing()

Each method is passed Go\Aop\Intercept\MethodInvocation

Only @Around needs to call $invocation->proceed()

(This is not the same MethodInvocation class as JmsAopBundle)

Then specify the pointcut in the annotation body

@Before("@annotation(AppBundle\Annotation\SayHello)")


Before any method with the @SayHello annotation
@Before("execution(public AppBundle\Repository\BookRepository->*(*))")


Before any public method on a BookRepository instance

Many more pointcuts available

Method execution

execution(MemberModifiers ClassFilter[::|->]NamePattern(*))
execution(public Example->method(*))

Every execution of public method with the name "method" in the
class "Example"
execution(public Example->method1|method2(*))

Every execution of one of the methods: "method1" or "method2" in
the class "Example"
execution(* Example\Aspect\*->method*(*))

Execution of public or protected methods that have "method" prefix
in their names and that methods are also inside "Example\Aspect"
subnamespace (only single subnamespace)
execution(public **::staticMethods(*))

Every execution of any public static method "staticMethods" in
every namespace (except global)
@annotation(Demo\Annotation\Cacheable)

Every execution of any method that has "Demo\Annotation\Cacheable"
annotation in the docblock

Property access

access(MemberModifiers ClassFilter->NamePattern)
access(public Example\Demo->test)

Every access to the public
property "test" in the class "Example\Demo"
access(* Example\Aspect\*->fieldName)

Every access (read and write) to a property "fieldName" (both
protected and public) which belongs to a classes inside
"Example\Aspect" subnamespace.
access(protected **->someProtected*Property)

Every access to the protected properties with names, that match
"someProtected*Property" pattern in all classes.
@annotation(Demo\Annotation\Cacheable)

Every access to the property that has "Demo\Annotation\Cacheable"
annotation in the docblock Function execution

Built in function modification

execution(NamespacePattern(*))
execution(**\file_get_contents())

Every execution of system function
"file_get_contents" within all namespaces
execution(Example\Aspect\array_*(*))

Every execution of any system function "array_*" within "Example\Aspect" namespace

Initialization

initialization(NamespacePattern)
initialization(Demo\Example)

Every initialization of object "Demo\Example" when "new
Demo\Example()" is called
initialization(Demo\**)

Every initialization of object within "Demo" subnamespaces.

Static initialization

staticinitialization(NamespacePattern)
staticinitialization(Demo\Example)

First time when the class
"Demo\Example" is loaded into the memory of PHP
staticinitialization(Demo\**)

First time of loading of any class
within "Demo" subnamespace into the memory of PHP

Lexical pointcuts

within(ClassFilter)
within(Demo\Example) - Every property access, method execution,
initialization within "Demo\Example" class
@within(Demo\Annotation\Loggable)

Every property access, method execution, initialization within class
that has "Demo\Annotation\Loggable" annotation in the docblock

Logical combination and negation

!execution(public **->*(*))

Every execution of any method that is not public.
execution(* Demo->*(*)) && execution(public **->*(*))

Every execution of public method in the class "Demo"
access(public Demo->foo) || access(public Another->bar)

Every access to the properties "Demo->foo" or "Another->bar"
(access(* Demo->*) || access(* Another->*)) && access(public **->*)

...

Other cool stuff

Advise private methods

Advise final classes

Privileged aspects

Access private variables in aspects

class PrivateObject
{
    private $id;

    public function method1()
    {
        //do stuff
    }
}

Want to access the private $id property in an aspect

use Go\Aop\Aspect;
use Go\Aop\Intercept\MethodInvocation;
use Go\Lang\Annotation\Before;

class PrivilegeTestAspect implements Aspect
{
    /**
     * @Before("execution(public TestPrivileged->method1(*))", scope="target")
     */
    public function beforeTestMethod1(MethodInvocation $invocation)
    {
        /** @var PrivateObject $callee|$this */
        $privateObject = $invocation->getThis();

        echo 'PrivilegeTestAspect:before objectId=', $privateObject->id;
    }
}

Use the scope="target" option on the annotation

Introductions

"Introductions (known also as inter-type declarations) enable an aspect to declare additional interfaces for advised objects, and to provide an implementation of that interface with the help of traits." - go.aopphp.com

@DeclareParents annotation

use Go\Lang\Annotation\DeclareParents;

/**
 * Serialization aspect
 */
class SerializableAspect implements Aspect
{

    /**
     * @DeclareParents(value="Example", interface="Serializable", defaultImpl="Aspect\Introduce\SerializableImpl")
     *
     * @var null
     */
    protected $introduction = null;
}

Example now implements Serializable interface by extending Aspect\Introduce\SerializableImpl

Useful for applying an interface to vendor code

Go (the language) uses this pattern

Gotchas when combining with Symfony

Debug Component autoloader

Injecting services into aspects

Make sure to not mess up container definitions while loading aspect container

How does it work?

GO AOP rewrites class definitions on the fly using a custom stream wrapper autoloader that uses code weaving to interleaf aspect code into the original methods.

Autoloading

A class had to be manually included in the old days

include __DIR__'/../Some/Long/Namespace/MyClass.php';

$class = new \Some\Long\Namespace\MyClass();

Now a custom autoloader function can be used to load these files automatically

spl_autoload_register(function ($class) {
    include '/Some/Long/Namespace/' . $class . '.php';
});

$class = new \Some\Long\Namespace\MyClass();

Composer manages autoloading automatically for every package that is installed using either PSR-0 or PSR-4

include 'vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
use Doctrine\Common\Annotations\Reader;

//classes from different packages are loaded automatically

$request = Request::createFromGlobals();

$reader = new Reader();

GO AOP uses custom autoloading to load classes

// web/app_dev.php
require_once __DIR__.'/../app/ApplicationAspectKernel.php';

$applicationAspectKernel = ApplicationAspectKernel::getInstance();
$applicationAspectKernel->init([
    'debug' => true,
    'cacheDir' => __DIR__.'/../app/cache/dev/go-aop',
    'includePaths' => [
        __DIR__.'/../src',
    ],
]);
// go-aop-php/src/Core/AspectKernel.php
public function init(array $options = array())
{
    $this->options = $this->normalizeOptions($options);
    define('AOP_CACHE_DIR', $this->options['cacheDir']);

    /** @var $container AspectContainer */
    $container = $this->container = new $this->options['containerClass'];
    $container->set('kernel', $this);
    $container->set('kernel.interceptFunctions', $this->hasFeature(Features::INTERCEPT_FUNCTIONS));
    $container->set('kernel.options', $this->options);

    SourceTransformingLoader::register();

    foreach ($this->registerTransformers() as $sourceTransformer) {
        SourceTransformingLoader::addTransformer($sourceTransformer);
    }

    // Register kernel resources in the container for debug mode
    if ($this->options['debug']) {
        $this->addKernelResourcesToContainer($container);
    }

    AopComposerLoader::init();

    // Register all AOP configuration in the container
    $this->configureAop($container);
}

This registers the SourceTransformingLoader and AopComposerLoader autoloaders to perform autoloading before composer does.

When autoloading a class, GO AOP checks if any pointcuts are defined for a class by parsing the pointcut annotations in all Aspects

If any aspects are found, it will generate a new class definition with the aspects 'woven' into the code and load that instead of the original

src/AppBundle/Repository/BookRepository.php

->

app/cache/dev/go-aop/_proxies/symfony-aop-demo/src/AppBundle/Repository/BookRepository.php

The generated class still has the same namespace

namespace AppBundle\Repository;

class BookRepository extends BookRepository__AopProxied implements \Go\Aop\Proxy

Symfony container is unaware of the change and unaffected

This is how final classes can be 'extended' - they are actually redefined

JmsAopBundle vs GO AOP

Ease of use

JmsAopBundle is very easy to set up and configure

Built for symfony applications so integrates well

GO AOP is harder to set up with symfony

May need to mess with autoloaders to get it working correctly

Power and flexibility

JmsAopBundle offers only basic AOP features

Can't extend final classes, alter property access, intercept static methods or built in funcions

GO AOP can do all this plus much more

Performance

JmsAopBundle uses reflection for every advised method

GO AOP alters class definitions themselves through 'code weaving' so incurs almost no performance cost after compilation

Both slow down requests when writing aspects

Drawbacks to AOP

Performance cost

Any dynamic code generation will incur at least a small performance cost

PHP is not usually the bottleneck in a large application however

Magic

Difficult to work out what a class is doing when looking at an empty method

Need to look in multiple places to see the 'big picture'

For this reason I wouldn't use it for critical functionality

e.g. wouldn't normally use it for security

Complexity

Getting acquainted with a new codebase can be extremely difficult

Difficult to debug problems

Power

A misbehaving pointcut could break an entire codebase

VS event based programming

public function someMethod()
{
    // @var Symfony\Component\EventDispatcher\EventDispatcherInterface
    $dispatcher;

    $dispatcher->dispatch('some.event', new FooEvent());
}

Have to explicitly send an event for the methods that you want to modify

Need access to EventDispatcher

Difficult to prevent the method from running or changing the result

Further reading

Warlock

A bridge between Symfony Dependency Injection and GO AOP

Uses witchcraft and compiler passes to alter definitions in the container like JmsAopBundle

EXPERIMENTAL

PHP AOP extension

AOP implemented in a C extension

Roave/StrictPhp

"StrictPhp is a development tool aimed at bringing stricter runtime assertions into PHP applications and libraries."

Built on GO AOP

class Example
{
    /**
     * @var int|null
     */
    public $integer;
}

$object = new Example();
$object->integer = 123;

$object->integer = '123';
//world explodes

https://github.com/Roave/StrictPhp

Examples in other languages

Java

AspectJ framework

Emacs Lisp

Defadvice macro

Demo