okapi/aop

PHP AOP is a PHP library that provides a powerful Aspect Oriented Programming (AOP) implementation for PHP.

1.2.15 2024-09-07 17:19 UTC

This package is auto-updated.

Last update: 2024-12-07 17:53:14 UTC


README

PHP AOP

License: MIT Twitter: @WalterWoshid PHP: >=8.1 Packagist Build

Coverage - PHP 8.1 Coverage - PHP 8.2

PHP AOP is a PHP library that provides a powerful Aspect Oriented Programming (AOP) implementation for PHP.

Installation

composer require okapi/aop

Usage

📖 List of contents

Terminology

  • AOP: Aspect Oriented Programming - A programming paradigm that aims to increase modularity by allowing the separation of cross-cutting concerns.

  • Aspect: A class that implements the logic that you want to apply to your target classes. Aspects must be annotated with the #[Aspect] attribute.

  • Advice: The logic that you want to apply to your target classes. Advice methods must be annotated with the #[Before], #[Around] or #[After] attributes.

  • Join Point: A point in the execution of your target classes where you can apply your advice. Join points are defined by the #[Before], #[Around] or #[After] attributes.

  • Pointcut: A set of join points where you can apply your advice. Pointcuts are defined by the #[Pointcut] attribute.

  • Weaving: The process of applying your advice to your target classes.

  • Implicit Aspects: The aspects are applied without any modification to the target classes. The aspect itself specifies the classes or methods it applies to.

  • Class-Level Explicit Aspects: The aspects are applied by modifying the target classes, typically by adding the aspect as an attribute to the target class.

  • Method-Level Explicit Aspects: The aspects are applied by modifying the target classes, typically by adding the aspect as an attribute to the target method.

Implicit Aspects

Click to expand

Create a Kernel

<?php

use Okapi\Aop\AopKernel;

// Extend from the "AopKernel" class
class MyKernel extends AopKernel
{
    // Define a list of aspects
    protected array $aspects = [
        DiscountAspect::class,
        PaymentProcessorAspect::class,
    ];   
}

Create an Aspect

// Discount Aspect

<?php

use Okapi\Aop\Attributes\Aspect;
use Okapi\Aop\Attributes\After;
use Okapi\Aop\Invocation\AfterMethodInvocation;

// Aspects must be annotated with the "Aspect" attribute
#[Aspect]
class DiscountAspect
{
    // Annotate the methods that you want to intercept with
    // "Before", "Around" or "After" attributes
    #[After(
        // Use named arguments
        // You can also use Wildcards (see Okapi/Wildcards package)
        class: Product::class . '|' . Order::class,
        method: 'get(Price|Total)',

        // When using wildcards you can also use some of these options:
        onlyPublicMethods: false, // Intercepts only public methods and ignores protected and private methods (default: false)
        interceptTraitMethods: true, // Also intercepts methods from traits (default: true)
    )]
    public function applyDiscount(AfterMethodInvocation $invocation): void
    {
        // Get the subject of the invocation
        // The subject is the object class that contains the method
        // that is being intercepted
        $subject = $invocation->getSubject();
        
        $productDiscount = 0.1;
        $orderDiscount   = 0.2;
        
        if ($subject instanceof Product) {
            // Get the result of the original method
            $oldPrice = $invocation->proceed();
            $newPrice = $oldPrice - ($oldPrice * $productDiscount);
            
            // Set the new result
            $invocation->setResult($newPrice);
        }
        
        if ($subject instanceof Order) {
            $oldTotal = $invocation->proceed();
            $newTotal = $oldTotal - ($oldTotal * $orderDiscount);
            
            $invocation->setResult($newTotal);
        }
    }
}
// PaymentProcessor Aspect

<?php

use InvalidArgumentException;
use Okapi\Aop\Attributes\After;
use Okapi\Aop\Attributes\Around;
use Okapi\Aop\Attributes\Aspect;
use Okapi\Aop\Attributes\Before;
use Okapi\Aop\Invocation\AroundMethodInvocation;
use Okapi\Aop\Invocation\AfterMethodInvocation;
use Okapi\Aop\Invocation\BeforeMethodInvocation;

#[Aspect]
class PaymentProcessorAspect
{
    #[Before(
        class: PaymentProcessor::class,
        method: 'processPayment',
    )]
    public function checkPaymentAmount(BeforeMethodInvocation $invocation): void
    {
        $payment = $invocation->getArgument('amount');
        
        if ($payment < 0) {
            throw new InvalidArgumentException('Invalid payment amount');
        }
    }
    
    #[Around(
        class: PaymentProcessor::class,
        method: 'processPayment',
    )]
    public function logPayment(AroundMethodInvocation $invocation): void
    {
        $startTime = microtime(true);
        
        // Proceed with the original method
        $invocation->proceed();
        
        $endTime     = microtime(true);
        $elapsedTime = $endTime - $startTime;
        
        $amount = $invocation->getArgument('amount');
        
        $logMessage = sprintf(
            'Payment processed for amount $%.2f in %.2f seconds',
            $amount,
            $elapsedTime,
        );
        
        // Singleton instance of a logger
        $logger = Logger::getInstance();
        $logger->log($logMessage);
    }
    
    #[After(
        class: PaymentProcessor::class,
        method: 'processPayment',
    )]
    public function sendEmailNotification(AfterMethodInvocation $invocation): void
    {
        // Proceed with the original method
        $result = $invocation->proceed();
        $amount = $invocation->getArgument('amount');
        
        $message = sprintf(
            'Payment processed for amount $%.2f',
            $amount,
        );
        if ($result === true) {
            $message .= ' - Payment successful';
        } else {
            $message .= ' - Payment failed';
        }
        
        // Singleton instance of an email queue
        $mailQueue = MailQueue::getInstance();
        $mailQueue->addMail($message);
    }
}

Target Classes

// Product

<?php

class Product
{
    private float $price;
    
    public function getPrice(): float
    {
        return $this->price;
    }
}
// Order

<?php

class Order
{
    private float $total = 500.00;
    
    public function getTotal(): float
    {
        return $this->total;
    }
}
// PaymentProcessor

<?php

class PaymentProcessor
{
    public function processPayment(float $amount): bool
    {
        // Process payment
        
        return true;
    }
}

Initialize the Kernel

// Initialize the kernel early in the application lifecycle
// Preferably after the autoloader is registered

<?php

use MyKernel;

require_once __DIR__ . '/vendor/autoload.php';

// Initialize the AOP Kernel
$kernel = MyKernel::init();

Result

<?php

// Just use your classes as usual

$product = new Product();

// Before AOP: 100.00
// After AOP: 90.00
$productPrice = $product->getPrice();


$order = new Order();

// Before AOP: 500.00
// After AOP: 400.00
$orderTotal = $order->getTotal();



$paymentProcessor = new PaymentProcessor();

// Invalid payment amount
$amount = -50.00;

// Before AOP: true
// After AOP: InvalidArgumentException
$paymentProcessor->processPayment($amount);


// Valid payment amount
$amount = 100.00;

// Value: true
$paymentProcessor->processPayment($amount);


$logger   = Logger::getInstance();
$logs     = $logger->getLogs();

// Value: Payment processed for amount $100.00 in 0.00 seconds
$firstLog = $logs[0]; 


$mailQueue = MailQueue::getInstance();
$mails     = $mailQueue->getMails();

// Value: Payment processed for amount $100.00 - Payment successful
$firstMail = $mails[0];

Class-Level Explicit Aspects

Click to expand

Adding the custom Aspect to the Kernel is not required for class-level explicit aspects as they are registered automatically at runtime.

Create an Aspect

// Logging Aspect

<?php

use Attribute;
use Okapi\Aop\Attributes\Aspect;
use Okapi\Aop\Attributes\Before;
use Okapi\Aop\Invocation\BeforeMethodInvocation;

// Class-Level Explicit Aspects must be annotated with the "Aspect" attribute
// and the "Attribute" attribute
#[Attribute]
#[Aspect]
class LoggingAspect
{
    // The "class" argument is not required
    // The "method" argument is optional
    //   Without the argument, the aspect will be applied to all methods
    //   With the argument, the aspect will be applied to the specified method
    #[Before]
    public function logAllMethods(BeforeMethodInvocation $invocation): void
    {
        $methodName = $invocation->getMethodName();
        
        $logMessage = sprintf(
            "Method '%s' executed.",
            $methodName,
        );
        
        $logger = Logger::getInstance();
        $logger->log($logMessage);
    }
    
    #[Before(
        method: 'updateInventory',
    )]
    public function logUpdateInventory(BeforeMethodInvocation $invocation): void
    {
        $methodName = $invocation->getMethodName();

        $logMessage = sprintf(
            "Method '%s' executed.",
            $methodName,
        );

        $logger = Logger::getInstance();
        $logger->log($logMessage);
    }
}

Target Classes

// Inventory Tracker

<?php

// Custom Class-Level Explicit Aspect added to the class
#[LoggingAspect]
class InventoryTracker
{
    private array $inventory = [];
    
    public function updateInventory(int $productId, int $quantity): void
    {
         $this->inventory[$productId] = $quantity;
    }
    
    public function checkInventory(int $productId): int
    {
        return $this->inventory[$productId] ?? 0;
    }
}

Initialize the Kernel

// Initialize the kernel early in the application lifecycle
// Preferably after the autoloader is registered

// The kernel must still be initialized, even if it has no Aspects

<?php

use MyKernel;

require_once __DIR__ . '/vendor/autoload.php';

// Initialize the AOP Kernel
$kernel = MyKernel::init();

Result

<?php

// Just use your classes as usual

$inventoryTracker = new InventoryTracker();
$inventoryTracker->updateInventory(1, 100);
$inventoryTracker->updateInventory(2, 200);

$countProduct1 = $inventoryTracker->checkInventory(1);
$countProduct2 = $inventoryTracker->checkInventory(2);



$logger = Logger::getInstance();

// Value:
//   Method 'updateInventory' executed. (4 times)
//   Method 'checkInventory' executed. (2 times)
$logs = $logger->getLogs();

Method-Level Explicit Aspects

Click to expand

Adding the custom Aspect to the Kernel is not required for method-level explicit aspects as they are registered automatically at runtime.

Create an Aspect

// Performance Aspect

<?php

use Attribute;
use Okapi\Aop\Attributes\Around;
use Okapi\Aop\Invocation\AroundMethodInvocation;
use Okapi\Aop\Attributes\Aspect;

// Method-Level Explicit Aspects must be annotated with the "Aspect" attribute
// and the "Attribute" attribute
#[Attribute]
#[Aspect]
class PerformanceAspect
{
    // The "class" argument is not required
    // The "method" argument is optional
    //   Without the argument, the aspect will be applied to all methods
    //   With the argument, the aspect will be applied to the specified method
    #[Around]
    public function measure(AroundMethodInvocation $invocation): void
    {
        $start = microtime(true);
        $invocation->proceed();
        $end = microtime(true);

        $executionTime = $end - $start;

        $class  = $invocation->getClassName();
        $method = $invocation->getMethodName();

        $logMessage = sprintf(
            "Method %s::%s executed in %.2f seconds.",
            $class,
            $method,
            $executionTime,
        );

        $logger = Logger::getInstance();
        $logger->log($logMessage);
    }
}

Target Classes

// Customer Service

<?php

class CustomerService
{
    #[PerformanceAspect]
    public function createCustomer(): void
    {
        // Logic to create a customer
    }
}

Initialize the Kernel

// Initialize the kernel early in the application lifecycle
// Preferably after the autoloader is registered

// The kernel must still be initialized, even if it has no Aspects

<?php

use MyKernel;

require_once __DIR__ . '/vendor/autoload.php';

// Initialize the AOP Kernel
$kernel = MyKernel::init();

Result

<?php

// Just use your classes as usual

$customerService = new CustomerService();
$customerService->createCustomer();



$logger = Logger::getInstance();
$logs   = $logger->getLogs();

// Value: Method CustomerService::createCustomer executed in 0.01 seconds.
$firstLog = $logs[0];

Features

  • Advice types: "Before", "Around" and "After"

  • Intercept "private" and "protected" methods (Will show errors in IDEs)

  • Access "private" and "protected" properties and methods of the subject (Will show errors in IDEs)

  • Intercept "final" methods and classes

  • Use Transformers from the "Okapi/Code-Transformer" package in your Kernel to modify and transform the source code of a loaded PHP class (See "Okapi/Code-Transformer" package for more information)

Limitations

  • Internal "private" and "protected" methods cannot be intercepted

How it works

  • This package extends the "Okapi/Code-Transformer" package with Dependency Injection and AOP features

  • The AopKernel registers multiple services

    • The TransformerManager service stores the list of aspects and their configuration

    • The CacheStateManager service manages the cache state

    • The StreamFilter service registers a PHP Stream Filter which allows to modify the source code before it is loaded by PHP

    • The AutoloadInterceptor service overloads the Composer autoloader, which handles the loading of classes

General workflow when a class is loaded

  • The AutoloadInterceptor service intercepts the loading of a class

  • The AspectMatcher matches the class and method names with the list of aspects and their configuration

  • If the class and method names match an aspect, query the cache state to see if the source code is already cached

    • Check if the cache is valid:

      • Modification time of the caching process is less than the modification time of the source file or the aspect file
      • Check if the cache file, the source file and the aspect file exist
    • If the cache is valid, load the proxied class from the cache

    • If not, return a stream filter path to the AutoloadInterceptor service

  • The StreamFilter modifies the source code by applying the aspects

    • Convert the original source code to a proxied class (MyClass -> MyClass__AopProxied)
    • The proxied class should have the same amount of lines as the original class (because the debugger will point to the original class)
    • The proxied class extends a woven class which contains the logic of applying the aspects
    • The woven class will be included at the bottom of the proxied class
    • The woven class will also be cached

Testing

  • Run composer run-script test
    or
  • Run composer run-script test-coverage

Contributing

  • To contribute to this project, fire up an aspect in any application that works or has 100% working tests, and match every class and method with '*' with any advice type.
  • If the application throws an error, then it's a bug.
  • Example:
<?php

use Okapi\Aop\Attributes\After;
use Okapi\Aop\Attributes\Aspect;
use Okapi\Aop\Invocation\AfterMethodInvocation;

#[Aspect]
class EverythingAspect
{
    #[After(
        class: '*',
        method: '*',
    )]
    public function everything(AfterMethodInvocation $invocation): void
    {
        echo $invocation->getClassName() . "\n";
        echo $invocation->getMethodName() . "\n";
    }
}

Show your support

Give a ⭐ if this project helped you!

🙏 Thanks

📝 License

Copyright © 2023 Valentin Wotschel.
This project is MIT licensed.