Intercepting method calls

For a long time I’ve been pondering how to modernize the concept of “hooks”, as seen in Drupal.

The hook system is basically a global event manager - although I don’t have a list of good things to say about the Drupal implementation in particular, the pattern is very interesting, and it’s what makes Drupal so extensible.

For a while I’ve been wondering how to implement this pattern in a clean, modern way in PHP, and tonight I came up with something interesting.




<?php


interface Interceptable

{

  public function __dispatch($method, $params);

}


abstract class Interceptor

{

  public static function dispatch(Interceptable $object, $method, $params)

  {

    echo "Intercepted a call to $method... ";

    return call_user_func_array(array($object, '__dispatch'), array($method, $params));

  }

}


class Test implements Interceptable

{

  public function __call($method, $params)

  {

    return Interceptor::dispatch($this, $method, $params);

  }

  

  public function __dispatch($method, $params)

  {

    return call_user_func_array(array($this,$method), $params);

  }

  

  protected function callMe()

  {

    echo "Test::callMe() was invoked!";

  }

}


$test = new Test;

$test->callMe();



In this example, the abstract Interceptor class provides the global "hook" that intercepts method calls, in the form of a dispatch() method.

Any class that wishes to implement interceptable method calls, must implement the Interceptable interface, which provides a means for the Interceptor to invoke a private or protected method inside an interceptable class.

The Test class in this example must implement two methods: the __call() method, from which it delegates control up to Interceptor::dispatch() - and the __dispatch() method, which enables the Interceptor to invoke the protected callMe() method.

Clearly a lot of things are still missing here, but basically this mechanism lets me intercept method calls, which would enable me to to pre-process parameters, and post-process the return value, for any method call.

This technique could enable basic aspect-oriented programming, leveraging cross-cutting concerns, etc.

Clearly this is a hack - but it’s a lot cleaner than code generation, eh? :wink:

The reason this prototype works is because callMe() is protected and you are calling it outside of the class. As a result, __call() gets invoked, giving the interceptor the chance to intercept the call. This will not work if you call callMe() within the class.

Right.

You also have no control over which protected/private methods become callable from the outside - essentially, every method becomes exposed.

Just a proof on concept :slight_smile:

Okay, round two:





<?php


interface Interceptable

{

  public function __dispatch($method, $params);

}


abstract class Interceptor

{

  public static function dispatch(Interceptable $object, $method, $params)

  {

    if (substr($method, 0, 2)=='i_')

      throw new Exception("Undefined method ".get_class($object).'::'.substr($method,2));

    

    echo "Intercepted a call to $method...\n";

    

    return call_user_func_array(array($object, '__dispatch'), array('i_'.$method, $params));

  }

}


class Test implements Interceptable

{

  public function __call($method, $params)

  {

    return Interceptor::dispatch($this, $method, $params);

  }

  

  public function __dispatch($method, $params)

  {

    return call_user_func_array(array($this,$method), $params);

  }

  

  // protected/private methods prefixed with an "i_" become interceptible methods.

  protected function i_callMe()

  {

    echo "Test::callMe() was invoked!\n";

  }

  

  protected function cantCallMe()

  {

  }

}


header('Content-type: text/plain');


$test = new Test;

$test->callMe();


try

{

  $test->cantCallMe();

}

catch (Exception $e)

{

  echo "cantCallMe() cannot be invoked from the outside";

}



You now declare interceptible methods by prefixing the method-name with "i_" - this way, we can control which methods are exposed by this mechanism.

To your point, that invoking $this->callMe() from inside the class, this will now result in interception. More importantly, it’s now voluntary - if for any reason you need to call one interceptible method from within another, you can choose to invoke it as $this->i_callMe() to bypass the interception, or $this->callMe() with interception.

Bypassing the interception from within would probably be practical in a number of cases, such as to prevent recursive loops when interceptible methods are invoked from within hierarchies of objects of the same class.

http://vanillaforums.org/page/Plugins

Similar to what I was first doing, but it’s now developing into something different - an extensible utility class that supports different patterns involving dynamic method dispatch and property read/write. So far supporting interception, decoration and accessor patterns. Needs some more work still…

But I like the idea of decoupling these features, so you don’t need to extend a base class. It adds a little bit of overhead, but it makes way for more unrestricted class hierarchies…

Keep us updated, I’m very interested

Status update:

The interceptor itself became part of a more generic library supporting various kinds of run-time extensions: interception, decoration and accessors. Clearly this overlaps with existing Yii features. Also in the works is support for dependency injection - to support that, I need to tie in my annotations library, so the whole thing is growing rather large.

The pattern itself does not transparently tie into Yii’s component model, as the whole approach is radically different from how Yii does things - integrating the interception piece with Yii would require quite a few modifications, probably to core Yii classes as too.

So I’m not sure where all of this is going, but I have no plans to integrate this work closely with Yii at the moment. If this thing turns out to be useful, it will probably eventually be released as open-source, but for the time being, it’s staying on my harddrive…

Someone asked me for the code, and wanted to integrate it into Yii - I figured it would be just as much work to try to explain the idea as to just go ahead and implement a working example myself, so I did.

Attached is “interception.zip”, which you can unpack as “extensions/interception” and add to you application’s import paths.

The attached “TestController.php” you can add to your application’s controller folder, and then run it from a browser - in this simple example, a very simple Menu with a render() method is intercepted, demonstrating how an external class is able to add menu items, and decorate the HTML produced by the function.

This is by no means a supported or official extension, just a quick demo!

See what you make of this…

Oh, and just a word of caution here - please don’t go and get all drupal about things! This code was unit tested, and it’s stable, but it isn’t at all feature complete. Please don’t start building out modules and applications with this component yet - with such a low-level component, it is very important that it is feature complete and optimized before you start building out tons of code…

Very nice, method overriding could be added too (and the code could be optimized, look here: http://markosullivan.ca/benchmarking-magic-revisited/).

Some special care has to be taken for method overriding - to prevent clashing, we can only allow a single Interceptor (first come, first served) to take exclusive control of an existing method. If multiple interceptors were allowed to override each other, the results would be too unpredictable.

Updated code and example attached: the Menu now has a showTitle() method - in the example, this method is replaced, changing the title of the menu.

I don’t think there’s much to optimize here, but feel free to make a contribution?

Nice.

Do you think we could do something for static methods (__callStatic() was added in PHP 5.3 but most shared hosting providers aren’t 5.3 ready yet)?

I’m going to do some tests.

I would not encourage that. Hopefully you’re not writing too many classes using static methods in the first place - you should only use static methods in PHP when you’re absolutely certain that the class isn’t going to need any kind of extensions to begin with. Because of various complications and issues with static method calls in PHP, you will often get into trouble if you try to extend a static class.

If you’ve attempted to extend and of the lower-level methods in CHtml, for example, I’m sure you know what I’m referring too…

Rather than adding more features, at the moment, I’d like to think about improving the existing features. Like for one, I would like the intecepted methods to actually follow the prototype of the method they’re intercepting.

So for example, if you have Menu::i_setColors($background, $text), an interception method would have a similar prototype, e.g. MenuInterceptor::before_Menu_setColors(&$background, &$text) … not having to deal with $params[0], $params[1] etc. makes for more legible code in Interceptors, and enables me to perform an additional check to verify that the method prototypes are identical. This would greatly help consistency - if the prototype of an intercepted method changes, it will not just attempt to run (possibly causing silent errors), but rather throw an exception so you can upgrade your interceptor to support the new version of the intercepted method. This is one of the sore points in drupal - because there is no formal input/output API defined for "hooks", silent errors happen all the time.

To enforce method prototype consistency, we’ll have to to reflection, and Interceptor::register() starts to become a costly operation, so there is definitely a performance concern on the horizon here.

Two possible approaches to improve this:

  1. Cache the information.

  2. Use code generation and create proxies rather than trying to resolve everything at run-time.

The latter approach has some major advantages, first in terms of performance. It would generate a proxy/wrapper class around the existing class, so after the first run and code-generation, it would perform like native PHP code, without any overhead from resolving or dispatching interception methods.

The drawback is, you would need some kind of new method to create instances of interceptable classes. For example, $menu=Interceptable::create(‘Menu’) which would return an instance of “class Menu_extension extends Menu”, which contains generated methods overriding any intercepted methods.

This would need to tie into the auto-loader though, and it would be a completely different approach requiring a rewrite from scratch…

Here’s an entirely different approach:




<?php


class xProxy

{

  protected $_inner;

  

  public function __construct($inner)

  {

    $this->_inner = is_string($inner) ? new $inner : $inner;

  }

  

  public function __call($name, $params)

  {

    call_user_func_array(array($this->_inner, $name), $params);

  }

  

  public function __get($name)

  {

    return $this->_inner->$name;

  }

  

  public function __set($name, $value)

  {

    $this->_inner->$name = $value;

  }

}


function create($inner)

{

  return new xProxy($inner);

}


class Test

{

  public function hello()

  {

    echo 'hello';

  }

  

  public $hello = 'world';

}


$test = create(new Test);


$test->hello();

echo $test->hello;



Note the create() function is just syntactic sugar - create(new Test) is equivalent to new xProxy(new Test).

There’s a couple of reasons I like this approach better:

It’s more extensible - you can actually take the proxy class an extend it.

And it’s more flexible - using this approach, you can actually add properties (a’la CBehavior) to individual objects, effectively making way for polymorphism.

It also makes it possible to create run-time extensions to any third-party PHP class, without modifying it.

The downside is that create(‘Test’) or create(new Test) results in an instance of xProxy, not an instance of the Test class, which lives inside the proxy.

This gets in the way of IDE awareness - your IDE can’t tell what kind of object is inside.

And it gets in the way of run-time reflection - PHP can’t reflect on the properties of the object inside.

Those are the two major problems with this approach.

There are certainly problems with the other approach too, however - which one do you think has the least drawbacks?

To work around the IDE awareness issue, we could use an auto-loader that dynamically parses class files and replaces "new" statements with "create(new xxx)" statement - transparently to the IDE, we can slightly modify the code and generate a new script which is then loaded instead.

The IDE would see a "new MyClass" statement somewhere, business as usual - but at run-time, the auto-loader would pre-process the source and load a slightly modified version instead.

I’ll be the first to admit that this is probably bordering on insanity.

So I went ahead anyway:


<?php


/**

 * Just a sample class to demonstrate that the parser works

 */

class Test

{

  public function run(Hacky $Hacky = null, $lala='fishy', $bleep=123)

  {

  }

}


/**

 * This class represents a set of classes in a PHP source code file

 */

class Hacky

{

  private $path;

  

  private $open = false;

  private $inClass = false;

  private $currentClass;

  private $indent;

  

  private $visibility;

  private $inMethod = false;

  private $currentMethod;

  private $methodIndent;

  

  private $inParams = false;

  private $currentParam;

  private $paramType;

  private $inDefault = false;

  

  private $classes = array();

  

  public function __construct($path)

  {

    $this->path = $path;

    

    static $chars = array(

      '{' => 'OPEN_CURLY',

      '}' => 'CLOSE_CURLY',

      '(' => 'OPEN_PAREN',

      ')' => 'CLOSE_PAREN',

      '=' => 'ASSIGN',

    );

    

    $tokens = token_get_all(file_get_contents($path));

    

    if (!isset($tokens[0][0]) || $tokens[0][0]!==T_OPEN_TAG)

      throw new Exception('only pure class files can be parsed');

    

    foreach ($tokens as $token)

    {

      if (is_array($token))

      {

        $name = substr(token_name($token[0]),2);

        $value = $token[1];

      }

      else

      {

        $name = isset($chars[$token]) ? $chars[$token] : 'CHAR';

        $value = $token;

      }

      

      $method = 'parse'.str_replace(' ', '', ucwords(strtr(strtolower($name), '_', ' ')));

      

      $inMethod = $this->methodIndent > 0;

      

      if (method_exists($this, $method))

        $this->$method($value);

      

      if ($inMethod && $this->methodIndent > 0)

        $this->currentMethod->parse($name, $value, $method);

      

      #echo $name.' '.$value."\n";

    }

  }

  

  private function parseOpenTag()

  {

    $this->open = true;

  }

  

  private function parseCloseTag()

  {

    $this->open = false;

  }

  

  private function parseClass()

  {

    $this->inClass = true;

    $this->indent = 0;

  }

  

  private function parseString($value)

  {

    if ($this->inClass && $this->currentClass===null)

    {

      $this->classes[$value] = $this->currentClass = new HackyClass($value);

    }

    

    if ($this->inMethod && $this->currentMethod===null)

    {

      $this->currentClass->methods[$value] = $this->currentMethod = new HackyMethod($value, $this->visibility);

      $this->visibility = null;

    }

    

    if ($this->inParams)

    {

      $this->paramType = $value;

    }

    

    if ($this->inDefault)

    {

      $this->currentParam->default = $value;

      $this->inDefault = false;

    }

  }

  

  private function parseOpenCurly()

  {

    $this->indent++;

    

    if ($this->inMethod)

      $this->methodIndent++;

  }

  

  private function parseCloseCurly()

  {

    if (--$this->indent == 0)

    {

      $this->inClass = false;

      $this->currentClass = null;

    }

    

    if ($this->inMethod)

    {

      if (--$this->methodIndent == 0)

      {

        $this->inMethod = false;

        $this->currentMethod = null;

      }

    }

  }

  

  private function parsePrivate($value)

  {

    $this->visibility = $value;

  }

  

  private function parsePublic($value)

  {

    $this->visibility = $value;

  }

  

  private function parseProtected($value)

  {

    $this->visibility = $value;

  }

  

  private function parseVar($value)

  {

    $this->visibility = 'public';

  }

  

  private function parseFunction($value)

  {

    if ($this->currentClass!==null)

    {

      $this->inMethod = true;

      $this->methodIndent = 0;

    }

  }

  

  private function parseOpenParen()

  {

    if ($this->inMethod && $this->currentMethod->params===null)

    {

      $this->inParams = true;

      $this->currentMethod->params = array();

    }

  }

  

  private function parseCloseParen()

  {

    if ($this->inParams)

    {

      $this->inParams = false;

      $this->currentParam = null;

    }

  }

  

  private function parseVariable($name)

  {

    if ($this->inParams)

    {

      $this->currentMethod->params[$name] = $this->currentParam = new HackyParam($name);

      

      if ($this->paramType!==null)

        $this->currentParam->type = $this->paramType;

      

      $this->paramType = null;

    }

  }

  

  private function parseAssign()

  {

    if ($this->inParams && $this->currentParam!==null)

    {

      $this->inDefault = true;

    }

  }

  

  private function _parseValue($value)

  {

    if ($this->inDefault)

    {

      $this->currentParam->default = $value;

      $this->inDefault = false;

    }

  }

  

  private function parseConstantEncapsedString($value)

  {

    $this->_parseValue($value);

  }

  

  private function parseLnumber($value)

  {

    $this->_parseValue($value);

  }

  

  private function parseDnumber($value)

  {

    $this->_parseValue($value);

  }

}


/**

 * This class represents a class in a PHP source code file

 */

class HackyClass

{

  public $name;

  public $methods = array();

  

  public function __construct($name)

  {

    $this->name = $name;

  }

}


/**

 * This class represents a method inside a class

 */

class HackyMethod

{

  public $name;

  public $visibility = 'public';

  public $params = null;

  public $code;

  

  private $copy = true;

  private $inNew = false;

  private $parenLevel = 0;

  

  public function __construct($name, $visibility)

  {

    $this->name = $name;

    $this->visibility = $visibility;

  }

  

  private function parseNew()

  {

    $this->inNew = true;

    $this->code .= 'create(';

  }

  

  private function parseOpenParen()

  {

    if ($this->inNew)

      $this->parenLevel++;

  }

  

  private function parseCloseParen()

  {

    if ($this->inNew)

    {

      if (--$this->parenLevel == 0)

      {

        $this->inNew = false;

        $this->code .= ')';

      }

    }

  }

  

  /**

   * This callback is invoked while parsing the code inside the method

   */

  public function parse($name, $value, $method)

  {

    if (method_exists($this, $method))

      $this->$method($value);

    

    if ($this->copy)

      $this->code .= $value;

  }

}


/**

 * This class represents a parameter for a method

 */

class HackyParam

{

  public $name;

  public $type;

  public $default;

  

  public function __construct($name)

  {

    $this->name = $name;

  }

}


header('Content-type: text/plain');


$test = new Hacky(__FILE__);


var_dump($test);



As you can see, this class is capable for parsing itself, and replacing the new statements, just as a proof of concept.

I have no real idea how much havoc this could potentially cause, but probably a good deal :slight_smile:

Having an object model representing the source code certainly makes it tempting to attempt to do other crazy things, modifying the source code in other ways - aspect orient programming, for one, since you could easily replace methods, or inject code before/after method calls.

Feel free to speak if you think this an extremely bad idea. I’m almost positive it is :wink:

Oh I’m currently involved with some problem that needs same mechanism as this solution but I think the first solution was really simpler to use. anyway, after one year - can you tell me more? any new idea - working code for Yii? thanks. :mellow:

I continued working on this through november, and it is reasonable complete and stable - but it is currently part of another package, unrelated to Yii or any public framework; it would take some work to liberate it from this package.

I might decide revisit this, but as it stands, I have no plans to integrate this with Yii.