Yet Another EventManager Class in PHP (

Some months ago I published an article about How to add events to a PHP project (1st EventManager). It should have been the basis for a later article about how to use events to index a site, still to be written.

The 1st EventManager class was ready to use, but some days ago I discovered the Event Dispatcher component of symfony (sfEventDispatcher class), whose Recipes page shows some usage examples. It proved that my EventManager class needed some reworking to have comparable power.

So I developed my 2nd EventManager class, whose differences with respect to the sfEventDispatcher class are explained below.

  1. EventManager is a Singleton
  2. EventManager events are strings
  3. EventManager bind() can attach handlers to regular expressions
  4. EventManager event handler arguments are loosely structured
  5. EventManager event handler return value is loosely structured
  6. EventManager gathers, stores, and eventually returns all handlers results

EventManager is a Singleton

The 1st EventManager I described was a singleton by itself, but the 2nd EventManager extends the Singleton class I talked about in the article Yet Another Singleton Class in PHP (<5.3).

In the Recipes page of the sfEventDispatcher class it is said that a reason for not being it a singleton is that “you might want to have several concurrent event dispatchers in a single PHP request”. That’s not an issue with my Singleton class.

require_once 'event_manager.php';

class AnotherEventManager extends EventManager {}
Singleton::instance('AnotherEventManager');

function hello($event, $world)
{
    echo "Hello $world !<br>";
}

EventManager()->bind('hello', 'hello');
//AnotherEventManager()->bind('hello', 'hello');

AnotherEventManager()->trigger('hello', 'Popeye');

If you run the previous program, you’ll see nothing, which confirms that AnotherEventManager is not the same EventManager it extends. If you uncomment the commented line, you’ll see

Hello Popeye !

In fact, about a year ago, I used an event manager very similar to sfEventDispatcher (needed injection too) to automatically reindex a website. It worked fine, but I found it a little hard to use and explain to my colleagues.

EventManager events are strings

Strings are perfect for representing events in PHP, and they don’t need to be created before triggering, so this approach helps keeping code clean.

EventManager bind() can attach handlers to regular expressions

Events management is inherently asymmetric: it asks for genericity when you bind handlers and specificity when you call them. That’s why EventManager let’s you attach a handler to a regular expression (which is a special string in PHP). The handler will then get called whenever a triggered event matches the regular expression it is attached to.

EventManager event handler arguments are loosely structured

All the arguments a trigger is called with, are passed by value (see the note below) to each matching handler.

This is very intuitive. For example, if your trigger is

EventManager()->trigger($foo, $bar, $baz);

then a matching handler of yours could be

function handler($foo, $bar, $baz) 
{
  //...
}

The first argument of a trigger must be an event, so the first argument of a handler is that event. All other arguments are absolutely free form.

EventManager event handler return value is loosely structured

The return value of a handler can be anything.

As a special case, if a handler wants to stop the propagation of the same event to the rest of the handlers, then it can return an array with two keys: ‘result’ and ‘stopPropagation’. The former’s value will be the real result of the handler (and the trigger) and the latter’s one, if true, will cause EventManager to quit immediately, without further handler processing.

EventManager gathers, stores, and eventually returns all handlers results

Results are pushed on a stack, and eventually the stack is returned to the trigger caller. If the trigger caller wants to know the last result all it needs to do is pop the stack.

The current stack will also be passed by reference (see the note below) to each handler. The last argument a handler receives is in fact the stack that holds the results from all previous handlers, plus a slot (on top) with a bit more data. Those data are just one, for now. It’s the event template (normal string or regular expression) the current handler was attached to.

Two use cases for the stack follow:

  1. If a handler wants to cancel it’s execution so that it appears as if it was never called, then all it needs to do is pop the stack and return.
  2. If a handler wants to act on the result of a previous handler, then all it needs to do is get to the desired element of the stack.

When filtering, the result of a handler feeds the next handler in the pipeline. Such a handler cannot know a priori if it is being executed first or not, so it needs to decide if it takes the input from an argument or from a previous result. This is easy to do using the stack, but EventManager offers a special static method that does it all at once:

$input = EventManager::pipeline($anything, $stack);

When using stopPropagation, the neat result value is pushed on the stack.

Arguments are passed as described, no matter how parameters are declared in the handler: this is a known PHP feature.

Recipes

Now I’ll show what the recipes of sfEventDispatcher become when using EventManager.

  1. Action Wrapping, called Doing something before or after a Method Call in sfEventDispatcher docs
  2. Runtime Class Extension, called Adding Methods to a Class in sfEventDispatcher docs
  3. Filters Pipeline, called Modifying Arguments in sfEventDispatcher docs

Action Wrapping

class Foo
{
  public function foo()
  {
    //...
    EventManager()->trigger('action:one:before', 3);
    sleep(3); //an action you want to wrap
    EventManager()->trigger('action:one:after', null);
    //...
  }
}

class Bar
{
  public function bar()
  {
    //...
    EventManager()->trigger('action:two:before', 2);
    sleep(2); //another action you want to wrap
    EventManager()->trigger('action:two:after', null);
    //...
  }
}

class ActionTracerPlugin
{
    public function trace($event, $data)
    {
        if (preg_match('/^action:(.*)$/', $event, $matches))
        {
            $action = $matches[1];
            $time = date('Y-m-d H:i:s');
            $data = var_export($data, true);
            file_put_contents('trace.txt', "$time: $action: $datan", FILE_APPEND);
        }
    }
}



require_once 'event_manager.php';

$tracer = new ActionTracerPlugin();
EventManager()->bind('/:(before|after)$/', array($tracer, 'trace'));

$foo = new Foo();
$foo->foo();

$bar = new Bar();
$bar->bar();

$foo->foo();

echo '<pre>', file_get_contents('trace.txt'), '</pre>';

Trace

2009-10-24 18:31:16: one:before: 3
2009-10-24 18:31:19: one:after: NULL
2009-10-24 18:31:19: two:before: 2
2009-10-24 18:31:21: two:after: NULL
2009-10-24 18:31:21: one:before: 3
2009-10-24 18:31:24: one:after: NULL

Runtime Class Extension

class Foo
{
    public function __call($method, $arguments)
    {
        action('__call:start', $method);
        
        $stack = EventManager()->trigger(__METHOD__, $this, $method, $arguments);
        if (! count($stack))
        {
            action('__call:exception');
            throw new Exception(sprintf('Call to undefined method %s::%s.', get_class($this), $method));
        }

        $result = array_pop($stack);
        action('__call:end', $result);
        return $result;
    }
}

class Bar
{
    public function barMethodForFoo($event, $foo, $method, $arguments, $stack)
    {
        action('barMethodForFoo:start', $arguments);
        if (! ($foo instanceof Foo && 'bar' == $method))
        {
            array_pop($stack);
            return;
        }
        action('barMethodForFoo:begin');
        
        $someValue = 'Hello ' . $arguments[0] . ' !';

        action('barMethodForFoo:end', $someValue);
        return $someValue;
    }
}

class Baz
{
    public function bazMethodForFoo($event, $foo, $method, $arguments, $stack)
    {
        action('bazMethodForFoo:start', $arguments);
        if (! ($foo instanceof Foo && 'baz' == $method))
        {
            array_pop($stack);
            return;
        }
        action('bazMethodForFoo:begin');
        
        $someValue = 'I love ' . $arguments[0] . ' !';

        action('bazMethodForFoo:end', $someValue);
        return $someValue;
    }
}

class ActionTracerPlugin
{
    public function trace($event, $data)
    {
        if (preg_match('/^action:(.*)$/', $event, $matches))
        {
            $action = $matches[1];
            $time = date('Y-m-d H:i:s');
            $data = var_export($data, true);
            file_put_contents('trace.txt', "$time: $action: $datan", FILE_APPEND);
        }
    }
}

function action($action, $data = null)
{
    EventManager()->trigger("action:$action", $data);
}



require_once 'event_manager.php';

$tracer = new ActionTracerPlugin();
EventManager()->bind('/^action:/', array($tracer, 'trace'));

$foo = new Foo();
$bar = new Bar();
$baz = new Baz();

EventManager()->bind('Foo::__call', array($bar, 'barMethodForFoo'));

$foo->bar("Olivia");
    
try
{
    action('baz without bind:before');
    $foo->baz("spinach");
    action('baz without bind:after');
}
catch (Exception $e)
{
    EventManager()->bind('Foo::__call', array($baz, 'bazMethodForFoo'));
    
    action('baz with bind:before');
    $foo->baz("you");
    action('baz with bind:after');
}

echo '<pre>', file_get_contents('trace.txt'), '</pre>';

Trace

2009-10-24 15:12:44: __call:start: 'bar'
2009-10-24 15:12:44: barMethodForFoo:start: array (
  0 => 'Olivia',
)
2009-10-24 15:12:44: barMethodForFoo:begin: NULL
2009-10-24 15:12:44: barMethodForFoo:end: 'Hello Olivia !'
2009-10-24 15:12:44: __call:end: 'Hello Olivia !'
2009-10-24 15:12:44: baz without bind:before: NULL
2009-10-24 15:12:44: __call:start: 'baz'
2009-10-24 15:12:44: barMethodForFoo:start: array (
  0 => 'spinach',
)
2009-10-24 15:12:44: __call:exception: NULL
2009-10-24 15:12:44: baz with bind:before: NULL
2009-10-24 15:12:44: __call:start: 'baz'
2009-10-24 15:12:44: barMethodForFoo:start: array (
  0 => 'you',
)
2009-10-24 15:12:44: bazMethodForFoo:start: array (
  0 => 'you',
)
2009-10-24 15:12:44: bazMethodForFoo:begin: NULL
2009-10-24 15:12:44: bazMethodForFoo:end: 'I love you !'
2009-10-24 15:12:44: __call:end: 'I love you !'
2009-10-24 15:12:44: baz with bind:after: NULL

Filters Pipeline

class Foo
{
    public function show($someThing)
    {
        $stack = EventManager()->trigger('filter:show', $someThing);
        $result = array_pop($stack);
        echo $result;
    }
}

class Bar
{
    public function upper4($event, $someThing, $stack)
    {
        if (! preg_match('/^filter:/', $event))
        {
            return array_pop($stack);
        }
        $result = EventManager::pipeline($someThing, $stack);
        $result = preg_replace('/b(w{4})b/e', 'strtoupper("1")', $result);
        return $result;
    }
    
    public function bold3($event, $someThing, $stack)
    {
        if (! preg_match('/^filter:/', $event))
        {
            return array_pop($stack);
        }
        $result = EventManager::pipeline($someThing, $stack);
        $result = preg_replace('/b(w{3})b/', '<strong>1</strong>', $result);
        return $result;
    }
    
    public function the_to_a($event, $someThing, $stack)
    {
        if (! preg_match('/^filter:/', $event))
        {
            return array_pop($stack);
        }
        $result = EventManager::pipeline($someThing, $stack);
        $result = preg_replace('/btheb/i', 'a', $result);
        return array('result' => $result, 'stopPropagation' => true);
    }
    
    public function markdown($event, $someThing, $stack)
    {
        if (! preg_match('/^filter:/', $event))
        {
            return array_pop($stack);
        }
        $result = EventManager::pipeline($someThing, $stack);
        $result = preg_replace('/_([^_]+)_/', '<em>1</em>', $result);
        $result = preg_replace('/*([^*]+)*/', '<strong>1</strong>', $result);
        return $result;
    }
}



require_once 'event_manager.php';

$bar = new Bar();
EventManager()->bind('filter:show', array($bar, 'upper4'));
EventManager()->bind('filter:show', array($bar, 'bold3'));
EventManager()->bind('filter:show', array($bar, 'the_to_a'));
EventManager()->bind('filter:show', array($bar, 'markdown'));

$foo = new Foo();
$foo->show('The *quick* red fox jumps over the _lazy_ brown dog.');

Output

a *quick* red fox jumps OVER a _lazy_ brown dog.

EventManager class

Last but not least, here is the code of the EventManager class

require_once "singleton.php";

class EventManager extends Singleton {
     
    /**
     * Stores bindings between events and their handlers
     *
     * @var array
     */
    protected $_bindings = array();
    
    /**
     * Counts bindings
     *
     * @var integer
     */
    protected $_count = 0;
     
    /**
     * Returns the id of the given $handler
     *
     * @param $handler a callable expression
     * @return string
     */
    public function handlerId( $handler )
    {
        if (! is_callable( $handler ))
        {
            throw new Exception('Expected a callable expression');
        }
        if (is_string( $handler ))
        {
            $handler_id = $handler;
        }
        else
        {
            $container = $handler[0];
            $method = $handler[1];
            if (is_string( $container ))
            {
                $class = $container;
                $handler_id = "$class::$method";
            }
            else
            {
                $class = get_class( $container );
                $object = spl_object_hash( $container );
                $handler_id = "$class::$method::$object";
            }
        }
        return $handler_id;
    }
     
     
    /**
     * Returns TRUE if the given $expression is a regular expression in PHP
     * Matching delimiters (), [], {}, <> are not supported
     *
     * @param $regex
     * @return boolean
     */
    protected function isRegExp( $expression )
    {
        if (is_string( $expression )
                && preg_match( '/^([^w \\])[^1]+1$/', $expression ))
        {
            return true;
        }
        return false;
    }
    
    /**
     * Returns the order of a new handler
     *
     * @return integer
     */
    protected function order()
    {
        return ++$this->_count;
    }
     
     
    /**
     * Binds a $handler to an $event_template
     *
     * @param string $event_template
     * @param callable $handler
     * @return EventManager
     */
    public function bind( $event_template, $handler )
    {
        $handler_id = $this->handlerId( $handler );
        $this->_bindings[ $event_template ][ $handler_id ] = array(
            'handler' => $handler,
            'ordered' => $this->order(),
        );
        return $this;
    }
     
     
    /**
     * Unbinds a $handler from an $event_template
     *
     * @param string $event_template
     * @param callable $handler
     * @return EventManager
     */
    public function unbind( $event_template, $handler = null )
    {
        if (is_null( $handler ))
        {
            unset( $this->_bindings[ $event_template ] );
        }
        else
        {
            $handler_id = $this->handlerId( $handler );
            unset( $this->_bindings[ $event_template ][ $handler_id ] );
        }
        return $this;
    }
    
    /**
     * Returns true if the $triggered_event is compatible with the $event_template
     *
     * @param string $event_template
     * @param string $triggered_event
     * @return boolean
     */
    protected function isCompatible( $event_template, $triggered_event )
    {
        if ($this->isRegExp( $event_template ))
        {
            if (! preg_match( $event_template, $triggered_event ))
            {
                return false;
            }
        }
        else
        {
            if ($event_template != $triggered_event)
            {
                return false;
            }
        }
        return true;
    }
    
    /**
     * Detects the result of an event handler and if stopPropagation has been requested
     *
     * @param mixed $result
     * @return array
     */
    protected function detectResult( $result )
    {
        if (is_array($result) && isset($result['stopPropagation']))
        {
            $stopPropagation = $result['stopPropagation'];
            $result = isset($result['result']) ? $result['result'] : null;
            return array($result, $stopPropagation);
        }
        return array($result, false);
    }
    
    /**
     * Returns an array with all the handlers that must be called
     * preserving the order in which the bind occurred
     *
     * @param $triggered_event
     * @return array
     */
    protected function filteredHandlers( $triggered_event )
    {
        $result = array();
        foreach ($this->_bindings as $event_template => $handlers)
        {
            if (! (is_array( $handlers )
                    && $this->isCompatible($event_template, $triggered_event)))
            {
                continue;
            }
            foreach ($handlers as $handler_id => $handler)
            {
                if (! is_callable($handler['handler']))
                {
                    continue;
                }
                $result[ $handler['ordered'] ] = array(
                    'event_template' => $event_template,
                    'handler_id'     => $handler_id,
                    'handler'        => $handler['handler'],
                );
            }
        }
        ksort($result);
        return $result;
    }
    
    /**
     * 1: Dispatches a $triggered_event to all of its matching handlers
     * 2: Passes to each matching handler all received arguments plus a stack
     * with previous results (see below)
     * 3: Returns a stack with all the results (see below)
     *
     * The same stack is passed to each handler by reference (all other
     * arguments are passed by value) and finally is returned to the trigger
     * caller.
     *
     * When the stack is returned, each element has a key and a value. The key
     * is a handler id and the value is its result. The order is the same in
     * which the handlers were processed.
     *
     * When the stack is passed to a handler, its elements are all the previous
     * handlers results as described above, plus a special last element whose
	 * id is the current handler id.
     *
     * The value of that special element is an array with an element whose key
     * is 'event_template' and whose value is the event_template matched by the
     * $triggered_event.
     *
     * @param string $triggered_event
     * @return array
     */
    public function trigger( $triggered_event )
    {
        $stack = array();
        $args = func_get_args();
        $filtered_handlers = $this->filteredHandlers($triggered_event);
        foreach ($filtered_handlers as $filtered)
        {
            $event_template = $filtered['event_template'];
            $handler_id     = $filtered['handler_id'];
            $handler        = $filtered['handler'];
            
            $stack[ $handler_id ] = array('event_template' => $event_template);
            $result = call_user_func_array($handler, array_merge($args, array(& $stack)));
            list($result, $stopPropagation) = $this->detectResult($result);
            if (isset($stack[ $handler_id ]))
            {
                $stack[ $handler_id ] = $result;
            }
            if ($stopPropagation)
            {
                break;
            }
        }
        return $stack;
    }
    
    /**
     * 1: Returns the second to last value of $stack, if it exists,
     * else returns $someThing
     * 2: It's a utility method for using with handlers that act as filters
     *
     * @param mixed $someThing
     * @param array $stack
     * @return mixed
     */
    static public function pipeline($someThing, $stack)
    {
        if (count($stack) > 1)
        {
            $values = array_values($stack);
            $input = $values[ count($stack) - 2 ];
        }
        else
        {
            $input = $someThing;
        }
        return $input;
    }
}

Singleton::instance('EventManager');

Leave a Reply

Your email address will not be published. Required fields are marked *