Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Best way to implement a decorator pattern for method result caching in PHP

I have a set of classes which have a habit of being called repeatedly with the same arguments. These methods generally run database requests and build arrays of objects and such, and so to cut out this duplication I've constructed a couple of caching methods to optimise. These are used like so:

Before caching applied:

public function method($arg1, $arg2) {
$result = doWork();
return $result;
}

After caching applied:

public function method($arg1, $arg2, $useCached=true) {
if ($useCached) {return $this->tryCache();}
$result = doWork();
return $this->cache($result);
}

Unfortunately I'm now left with the slightly laborious task of manually adding this to all of the methods- I believe this is a use case of the decorator pattern but I can't figure out how to implement it in a simpler way in PHP for this case.

What's the best way to do this, hopefully such that either all methods in any of these classes automatically do this, or I just have to add one line in the method etc?

I've had a look at ways to override the return statement and such but can't really see anything.

Thanks!

like image 233
Marogian Avatar asked Jul 05 '13 09:07

Marogian


2 Answers

If you don't need Type Safety, you can use a generic Cache Decorator:

class Cached
{
    public function __construct($instance, $cacheDir = null)
    {
        $this->instance = $instance;
        $this->cacheDir = $cacheDir === null ? sys_get_temp_dir() : $cacheDir;
    }

    public function defineCachingForMethod($method, $timeToLive) 
    {
        $this->methods[$method] = $timeToLive;
    }

    public function __call($method, $args)
    {
        if ($this->hasActiveCacheForMethod($method, $args)) {
            return $this->getCachedMethodCall($method, $args);
        } else {
            return $this->cacheAndReturnMethodCall($method, $args);
        }
    }

    // … followed by private methods implementing the caching

You would then wrap an instance that needs caching into this Decorator like this:

$cachedInstance = new Cached(new Instance);
$cachedInstance->defineCachingForMethod('foo', 3600);

Obviously, the $cachedInstance does not have a foo() method. The trick here is to utilize the magic __call method to intercept all calls to inaccessible or non-existing methods and delegate them to the decorated instance. This way we are exposing the entire public API of the decorated instance through the Decorator.

As you can see, the __call method also contains the code to check whether there is a caching defined for that method. If so, it will return the cached method call. If not, it will call the instance and cache the return.

Alternatively, you pass in a dedicated CacheBackend to the Decorator instead of implementing the Caching in the decorator itself. The Decorator would then only work as a Mediator between the decorated instance and the backend.

The drawback of this generic approach is that your Cache Decorator will not have the type of the Decorated Instance. When your consuming code expects instances of type Instance, you will get errors.


If you need type-safe decorators, you need to use the "classic" approach:

  1. Create an Interface of the decorated instance public API. You can do that manually or, if it's a lot of work, use my Interface Distiller)
  2. Change the TypeHints on every method expecting the decorated instance to the Interface
  3. Have the Decorated instance implement it.
  4. Have the Decorator implement it and delegate any methods to the decorated instance
  5. Modify all methods that need caching
  6. Repeat for all classes that want to use the decorator

In a nutshell

class CachedInstance implements InstanceInterface
{
    public function __construct($instance, $cachingBackend)
    {
        // assign to properties
    }

    public function foo()
    {
        // check cachingBackend whether we need to delegate call to $instance
    }
}

The drawback is, that it is more work. You need to do that for every class supposed to use caching. You'll also need to put the check to the cache backend into every function (code duplication) as well as delegating any calls that don't need caching to the decorated instance (tedious and error prone).

like image 80
Gordon Avatar answered Oct 09 '22 10:10

Gordon


Use the __call magic method.

class Cachable {
    private $Cache = array();
    public function Method1(){
        return gmstrftime('%Y-%m-%d %H:%M:%S GMT');
    }
    public function __call($Method, array $Arguments){
        // Only 'Cached' or '_Cached' trailing methods are accepted
        if(!preg_match('~^(.+)_?Cached?$~i', $Method, $Matches)){
            trigger_error('Illegal Cached method.', E_USER_WARNING);
            return null;
        }
        // The non 'Cached' or '_Cached' trailing method must exist
        $NotCachedMethod = $Matches[1];
        if(!method_exists($this, $NotCachedMethod)){
            trigger_error('Cached method not found.', E_USER_WARNING);
            return null;
        }
        // Rebuild if cache does not exist or is too old (5+ minutes)
        $ArgumentsHash = md5(serialize($Arguments)); // Each Arguments product different output
        if(
            !isset($this->Cache[$NotCachedMethod])
            or !isset($this->Cache[$NotCachedMethod][$ArgumentsHash])
            or ((time() - $this->Cache[$NotCachedMethod][$ArgumentsHash]['Updated']) > (5 * 60))
        ){
            // Rebuild the Cached Result
            $NotCachedResult = call_user_func_array(array($this, $NotCachedMethod), $Arguments);
            // Store the Cache again
            $this->Cache[$NotCachedMethod][$ArgumentsHash] = array(
                'Method'    => $NotCachedMethod,
                'Result'    => $NotCachedResult,
                'Updated'   => time(),
            );
        }
        // Deliver the Cached result
        return $this->Cache[$NotCachedMethod][$ArgumentsHash]['Result'];
    }
}
$Cache = new Cachable();
var_dump($Cache->Method1());
var_dump($Cache->Method1Cached()); // or $Cache->Method1_Cached()
sleep(5);
var_dump($Cache->Method1());
var_dump($Cache->Method1Cached()); // or $Cache->Method1_Cached()

This is used using internal storage but you can use the DB for this and create your own Transient storage. Just append _Cached or Cached to any method that exists. Obviously, you can change the lifespan and more.

This is just proof of concept. There's room for much improvement :)

like image 30
CodeAngry Avatar answered Oct 09 '22 08:10

CodeAngry