Page 1 of 1

New plugin handling (sort of an observer)

Posted: Sat Dec 09, 2006 3:10 pm
by Chris Corbyn
I've re-thought the plugin system I had crafted in one of my projects and just wondered what thoughts people have on my choice of direction here.

If you know Java/AWT you'll probably see where I've stolen the idea from ;)

Previously what happened was that the Pluggable copied itself as a property into a plugin, as well as copying the plugin into itself. It created a recursive object which allowed plugins to control the pluggable and vice-versa. Plugins could contain a set of methods which had pre-defined names like onSend() and the Pluggable would check each plugin in turn to see if that method existed and then run it if it so. It meant that I had to copy lots of local variables into properties of the pluggable before notifying plugins so that they could make changes.

Now what happens instead is that "event objects" are passed around and "event listeners" receive this information and do what they need with it. So for example, when the pluggable beings sending a message it creates a SendEvent object and puts inside it what it needs to. It then notifties all SendListeners of this event and then carries on what it was doing. The event objects all also have a reference to the Pluggable which allows the same as the old design but avoids the recursion. It also means I don't need to keep storing silly things in properties of the pluggable, but rather I can put them in the event object as event data.

A plugin implements whichever of the interfaces it wants to respond to events for and the pluggable places references in containers according to the interfaces the plugin implements.

Here's the example :)

A mapper to check what's in the interface (built-in):

Code: Select all

<?php

/**
 * Swift Mailer Mapper for Event Listeners
 * Please read the LICENSE file
 * @copyright Chris Corbyn <chris@w3style.co.uk>
 * @author Chris Corbyn <chris@w3style.co.uk>
 * @package Swift::Events
 * @license GNU Lesser General Public License
 */

/**
 * Maps event listener names to the methods they implement
 * @package Swift::Events
 * @author Chris Corbyn <chris@w3style.co.uk>
 */
class Swift_Events_ListenerMapper
{
	/**
	 * The mapped names (Class => Method(s))
	 * @var array
	 */
	protected static $map = array(
		"SendListener" => "sendPerformed",
		"BeforeSendListener" => "beforeSendPerformed",
		"CommandListener" => "commandSent",
		"BeforeCommandListener" => "beforeCommandSent",
		"ResponseListener" => "responseReceived",
		"ConnectListener" => "connectPerformed",
		"DisconnectListener" => "disconnectPerformed"
	);
	
	/**
	 * Get the name of the method which needs running based upon the listener name
	 * @return string
	 */
	public static function getNotifyMethod($listener)
	{
		if (isset(self::$map[$listener])) return self::$map[$listener];
		else return false;
	}
}
The relevant part of the pluggable (built-in):

Code: Select all

/**
 * Add a new plugin to Swift
 * Plugins must implement one or more event listeners
 * @param Swift_Events_Listener The plugin to load
 */
public function attachPlugin(Swift_Events_Listener $plugin, $id)
{
	foreach (array_keys($this->listeners) as $key)
	{
		$listener = "Swift_Events_" . $key;
		if ($plugin instanceof $listener) $this->listeners[$key][$id] = $plugin;
	}
}

// ... SNIP ...

/**
 * Send a new type of event to all objects which are listening for it
 * @param Swift_Events The event to send
 * @param string The type of event
 */
public function notifyListeners($e, $type)
{
	if (!empty($this->listeners[$type]) && $notifyMethod = Swift_Events_ListenerMapper::getNotifyMethod($type))
	{
		$e->setSwift($this);
		foreach ($this->listeners[$type] as $k => $listener)
		{
			$listener->$notifyMethod($e);
		}
	}
	else $e = null;
}

// ... SNIP ...

/**
 * Send a message to any number of recipients
 * @param Swift_Message The message to send.  This does not need to (and shouldn't really) have any of the recipient headers set.
 * @param mixed The recipients to send to.  Can be Swift_Address or Swift_RecipientList. Note that all addresses apart from Bcc recipients will appear in the message headers
 * @param Swift_Address The address to send the message from
 * @return int The number of successful recipients
 * @throws Swift_Connection_Exception If sending fails for any reason.
 */
public function send(Swift_Message $message, Swift_AddressContainer $recipients, Swift_Address $from)
{
	$send_event = new Swift_Events_SendEvent($message, $list, $from, 0);
	
	$this->notifyListeners($send_event, "BeforeSendListener");
	
	// ... SNIP ...
	// ... ALL CODE WHICH DEALS WITH SENDING ...
	// ... MAKES USE OF $send_event
	
	$send_event->setNumSent($num_sent);
	$this->notifyListeners($send_event, "SendListener");
	
	// ... SNIP ...
	
	return $num_sent;
}
The SendEvent and SendListener (built-in):

Code: Select all

<?php

/**
 * Swift Mailer Send Event
 * Please read the LICENSE file
 * @copyright Chris Corbyn <chris@w3style.co.uk>
 * @author Chris Corbyn <chris@w3style.co.uk>
 * @package Swift::Events
 * @license GNU Lesser General Public License
 */

require_once dirname(__FILE__) . "/../Events.php";

/**
 * Generated every time a message is sent with Swift
 * @package Swift::Events
 * @author Chris Corbyn <chris@w3style.co.uk>
 */
class Swift_Events_SendEvent extends Swift_Events
{
	/**
	 * A reference to the message being sent
	 * @var Swift_Message
	 */
	protected $message = null;
	/**
	 * A reference to the sender address object
	 * @var Swift_Address
	 */
	protected $sender = null;
	/**
	 * A reference to the recipients being sent to
	 * @var Swift_RecipientList
	 */
	protected $recipients = null;
	/**
	 * The number of recipients sent to so
	 * @var int
	 */
	protected $sent = null;
	
	/**
	 * Constructor
	 * @param Swift_Message The message being sent
	 * @param Swift_RecipientList The recipients
	 * @param Swift_Address The sender address
	 * @param int The number of addresses sent to
	 */
	public function __construct(Swift_Message $message, Swift_RecipientList $list, Swift_Address $from, $sent=0)
	{
		$this->message = $message;
		$this->recipients = $list;
		$this->sender = $from;
		$this->sent = $sent;
	}
	/**
	 * Get the message being sent
	 * @return Swift_Message
	 */
	public function getMessage()
	{
		return $this->message;
	}
	/**
	 * Get the list of recipients
	 * @return Swift_RecipientList
	 */
	public function getRecipients()
	{
		return $this->recipients;
	}
	/**
	 * Get the sender's address
	 * @return Swift_Address
	 */
	public function getSender()
	{
		return $this->sender;
	}
	/**
	 * Set the number of recipients to how many were sent
	 * @param int
	 */
	public function setNumSent($sent)
	{
		$this->sent = (int) $sent;
	}
	/**
	 * Get the total number of addresses to which the email sent successfully
	 * @return int
	 */
	public function getNumSent()
	{
		return $this->sent;
	}
}

Code: Select all

<?php

/**
 * Swift Mailer Send Event Listener Interface
 * Please read the LICENSE file
 * @copyright Chris Corbyn <chris@w3style.co.uk>
 * @author Chris Corbyn <chris@w3style.co.uk>
 * @package Swift::Events
 * @license GNU Lesser General Public License
 */

require_once dirname(__FILE__) . "/Listener.php";
require_once dirname(__FILE__) . "/SendEvent.php";

/**
 * Contains the list of methods a plugin requiring the use of a SendEvent must implement
 * @package Swift::Events
 * @author Chris Corbyn <chris@w3style.co.uk>
 */
interface Swift_Events_SendListener extends Swift_Events_Listener
{
	/**
	 * Executes when Swift sends a message
	 * @param Swift_Events_SendEvent Information about the message being sent
	 */
	public function sendPerformed(Swift_Events_SendEvent $e);
}
And finally the plugin which makes use of all that underbelly (coded by a developer!):

Code: Select all

<?php

/**
 * Swift Mailer Rotating Connection Controller
 * Please read the LICENSE file
 * @author Chris Corbyn <chris@w3style.co.uk>
 * @package Swift::Plugin
 * @license GNU Lesser General Public License
 */

/**
 * Swift Rotating Connection Controller
 * Invokes the nextConnection() method of Swift_Connection_Rotator upon sending a given number of messages
 * @package Swift::Plugin
 * @author Chris Corbyn <chris@w3style.co.uk>
 */
class Swift_Plugin_ConnectionRotator implements Swift_Events_SendListener
{
	/**
	 * The number of emails which must be sent before the connection is rotated
	 * @var int Threshold number of emails
	 */
	protected $threshold = 1;
	/**
	 * The total number of emails sent on this connection
	 * @var int
	 */
	protected $count = 0;
	
	/**
	 * Constructor
	 * @param int The number of emails to send before rotating
	 */
	public function __construct($threshold=1)
	{
		$this->setThreshold($threshold);
	}
	/**
	 * Set the number of emails to send before a connection rotation is tried
	 * @param int Number of emails
	 */
	public function setThreshold($threshold)
	{
		$this->threshold = (int) $threshold;
	}
	/**
	 * Get the number of emails which must be sent before a rotation occurs
	 * @return int
	 */
	public function getThreshold()
	{
		return $this->threshold;
	}
	/**
	 * Swift's SendEvent listener.
	 * Invoked when Swift sends a message
	 * @param Swift_Events_SendEvent The event information
	 * @throws Swift_Connection_Exception If the connection cannot be rotated
	 */
	public function sendPerformed(Swift_Events_SendEvent $e)
	{
		if (!is_a($e->getSwift()->connection, "Swift_Connection_Rotator"))
		{
			throw new Swift_Connection_Exception("The ConnectionRotator plugin cannot be used with connections other than Swift_Connection_Rotator.");
		}
		
		$this->count++;
		if ($this->count >= $this->getThreshold())
		{
			$e->getSwift()->connection->nextConnection();
			$this->count = 0;
		}
	}
}

Posted: Sat Dec 09, 2006 4:13 pm
by Chris Corbyn
Unit tests, for TDDers who need clarification (edited for brevity):

Code: Select all

<?php

Mock::Generate("DummyConnection", "FullMockConnection");
Mock::Generate("Swift_Events_SendListener", "MockSendListener");

class TestOfPluginAPI extends UnitTestCase
{
	/** Get a mock connection for testing
	 * @param int The number emails you expect to send
	 * @return FullMockConnection
	 */
	protected function getWorkingMockConnection($send=1)
	{
		$count = 0;
		$conn = new FullMockConnection();
		$conn->setReturnValueAt($count++, "read", "220 xxx ESMTP");
		$conn->setReturnValueAt($count++, "read", "250-Hello xxx\r\n250 HELP");
		for ($i = 0; $i < $send; $i++)
		{
			$conn->setReturnValueAt($count++, "read", "250 Ok");
			$conn->setReturnValueAt($count++, "read", "250 Ok");
			$conn->setReturnValueAt($count++, "read", "354 Go ahead");
			$conn->setReturnValueAt($count++, "read", "250 Ok");
		}
		$conn->setReturnValueAt($count++, "read", "250 Bye");
		return $conn;
	}
	
	/** Get a mock connection for testing
	 * @param int The number emails you expect to send
	 * @return FullMockConnection
	 */
	protected function getFailingMockConnection($send=1)
	{
		$count = 0;
		$conn = new FullMockConnection();
		$conn->setReturnValueAt($count++, "read", "220 xxx ESMTP");
		$conn->setReturnValueAt($count++, "read", "250-Hello xxx\r\n250 HELP");
		for ($i = 0; $i < $send; $i++)
		{
			$conn->setReturnValueAt($count++, "read", "250 Ok");
			$conn->setReturnValueAt($count++, "read", "500 Denied");
			$conn->setReturnValueAt($count++, "read", "250 Reset done");
		}
		$conn->setReturnValueAt($count++, "read", "250 Bye");
		return $conn;
	}
	
	public function testListenersCanBeRetreivedByReference()
	{
		$listener = new MockSendListener();
		$conn = $this->getWorkingMockConnection(1);
		$swift = new Swift($conn);
		$swift->attachPlugin($listener, "myplugin");
		$this->assertReference($listener, $swift->getPlugin("myplugin"));
	}
	
	public function testListenersCanBeRemovedOnceAdded()
	{
		$listener = new MockSendListener();
		$conn = $this->getWorkingMockConnection(1);
		$swift = new Swift($conn);
		$swift->attachPlugin($listener, "myplugin");
		$this->assertReference($listener, $swift->getPlugin("myplugin"));
		$swift->removePlugin("myplugin");
		$this->assertNull($swift->getPlugin("myplugin"));
	}
	
	public function testSendListenerIsNotifiedOnSend()
	{
		$listener = new MockSendListener();
		$listener->expectOnce("sendPerformed");
		$conn = $this->getWorkingMockConnection(1);
		$message = new Swift_Message("Subject", "Body");
		$swift = new Swift($conn);
		$swift->attachPlugin($listener, "myplugin");
		$swift->send($message, new Swift_Address("foo@bar.com"), new Swift_Address("me@myplace.com"));
		
		$listener = new MockSendListener();
		$listener->expectCallCount("sendPerformed", 5);
		$conn = $this->getWorkingMockConnection(5);
		$message = new Swift_Message("Subject", "Body");
		$swift = new Swift($conn);
		$swift->attachPlugin($listener, "myplugin");
		for ($i = 0; $i < 5; $i++)
			$swift->send($message, new Swift_Address("foo@bar.com"), new Swift_Address("me@myplace.com"));
	}
	
	public function testSendListenerDoesntRunWhenSendNotSuccessful()
	{
		$send_listener = new MockSendListener();
		$send_listener->expectNever("sendPerformed");
		$conn = $this->getFailingMockConnection(1);
		$message = new Swift_Message("Subject", "Body");
		$swift = new Swift($conn);
		$swift->attachPlugin($send_listener, "myplugin");
		try {
			$swift->send($message, new Swift_Address("foo@bar.com"), new Swift_Address("me@myplace.com"));
		} catch (Swift_Connection_Exception $e) {
			//
		}
		
		$send_listener = new MockSendListener();
		$send_listener->expectNever("sendPerformed");
		$conn = $this->getFailingMockConnection(5);
		$message = new Swift_Message("Subject", "Body");
		$swift = new Swift($conn);
		$swift->attachPlugin($send_listener, "myplugin");
		for ($i = 0; $i < 5; $i++)
		{
			try {
				$swift->send($message, new Swift_Address("foo@bar.com"), new Swift_Address("me@myplace.com"));
			} catch (Swift_Connection_Exception $e) {
				//
			}
		}
	}
}

Code: Select all

<?php

Mock::Generate("DummyConnection", "FullMockConnection");
Mock::Generate("Swift_Connection_Rotator", "MockRotatorConnection");

class TestOfConnectionRotatorPlugin extends UnitTestCase
{
	/** Get a mock connection for testing
	 * @param int The number emails you expect to send
	 * @param Swift_Connection A mocked object which has not been setup yet
	 * @return FullMockConnection
	 */
	protected function getWorkingMockConnection($send=1, $conn=null)
	{
		$count = 0;
		if (!$conn) $conn = new FullMockConnection();
		$conn->setReturnValueAt($count++, "read", "220 xxx ESMTP");
		$conn->setReturnValueAt($count++, "read", "250-Hello xxx\r\n250 HELP");
		for ($i = 0; $i < $send; $i++)
		{
			$conn->setReturnValueAt($count++, "read", "250 Ok");
			$conn->setReturnValueAt($count++, "read", "250 Ok");
			$conn->setReturnValueAt($count++, "read", "354 Go ahead");
			$conn->setReturnValueAt($count++, "read", "250 Ok");
		}
		$conn->setReturnValueAt($count++, "read", "250 Bye");
		return $conn;
	}
	
	public function testPluginInvokesTheNextConnectionMethod()
	{
		$plugin = new Swift_Plugin_ConnectionRotator();
		$conn = $this->getWorkingMockConnection(5, new MockRotatorConnection());
		$conn->expectCallCount("nextConnection", 5);
		
		$swift = new Swift($conn);
		$swift->attachPlugin(new Swift_Plugin_ConnectionRotator(1), "foo");
		
		for ($i = 0; $i < 5; $i++)
		{
			$swift->send(new Swift_Message("subject", "body"), new Swift_Address("foo@bar.tld"), new Swift_Address("zip@button.com"));
		}
	}
	
	public function testThresholdIsHonouredBeforeRotating()
	{
		$plugin = new Swift_Plugin_ConnectionRotator();
		$conn = $this->getWorkingMockConnection(20, new MockRotatorConnection());
		$conn->expectCallCount("nextConnection", 3);
		
		$swift = new Swift($conn);
		$swift->attachPlugin(new Swift_Plugin_ConnectionRotator(6), "foo");
		
		for ($i = 0; $i < 20; $i++)
		{
			$swift->send(new Swift_Message("subject", "body"), new Swift_Address("foo@bar.tld"), new Swift_Address("zip@button.com"));
		}
	}
}

Posted: Sat Dec 09, 2006 6:16 pm
by johno
Not bad, not bad at all. Looks like a first step to Aspect Oriented Programming to me.

In AOP you can replace this "ugly" part

Code: Select all

public function send(Swift_Message $message, Swift_AddressContainer $recipients, Swift_Address $from)
{
        $send_event = new Swift_Events_SendEvent($message, $list, $from, 0);
       
        $this->notifyListeners($send_event, "BeforeSendListener");
       
        // ... SNIP ...
        // ... ALL CODE WHICH DEALS WITH SENDING ...
        // ... MAKES USE OF $send_event
       
        $send_event->setNumSent($num_sent);
        $this->notifyListeners($send_event, "SendListener");
       
        // ... SNIP ...
       
        return $num_sent;
}
with

Code: Select all

public function send(Swift_Message $message, Swift_AddressContainer $recipients, Swift_Address $from) { 
        // ... SNIP ...
        // ... ALL CODE WHICH DEALS WITH SENDING ...
        // ... MAKES USE OF $send_event
        // ... SNIP ...
        return $num_sent;
}
In AOP "events" could be called pointcuts and are defined outside affected classes.

Defining behaviour you need is IMHO just more straightforward. You define a pointcut that matches method invocation (and other weird things we don't need now) on all Pluggable classes.

Code: Select all

pointcut sendMethod() : execution(Pluggable+.send());


and the you create two advices

Code: Select all

before sendMethod() {
    // execute something else
}

Code: Select all

after returning sendMethod() {
    // execute something else that should be made after returing from sendMethod()
}
Of course, you can access method call parameters, returning parameters and so on.

With AOP you can gain much more flexibility and modularity. Its a powerfull gun, so be carefull with it and don't shoot on sparrows.

Posted: Sat Dec 09, 2006 8:45 pm
by Chris Corbyn
I keep hearing about AOP yet I've never played around with it in any language. I've seen AOP patches for PHP in the past although they were self-admittedly buggy. At my first (recursive, nasty) implementation of an event-triggered pluggable system I was thinking of JavaScript's onsend, onclick etc event handlers. This time around I was thinking more of Java/Swing/AWT. I know PHP won't bend over backwards to get a perfect solution but message passing seemed to be an ideal solution :)

Thanks for the feedback.

Posted: Mon Dec 11, 2006 6:29 pm
by Chris Corbyn
Too much code to sift through? :(

Basic implementation:

Code: Select all

class ListenerMapper {
    public static function getNotifyMethod($listener) {
        $map = array("FooListener" => "fooPerformed"); //... and others...
        return $map[$listener];
    }
}

interface FooListener {
    public function fooPerformed(FooEvent $e);
}

class FooEvent {
    public function __construct($some_info) {
    }
    public function getXXX() {
    }
    public function getYYY() {
    }
}

class EventDispatcher { //The pluggable
    public function attachListener($listener) {
        $this->listeners[get_class($listener)][] = $listener;
    }
    protected function notifyListeners($event, $listener_type) {
        $notifyMethod = ListenerMapper::getNotifyMethod($listener_type);
        foreach ($this->listeners[$listener_type] as $listener) {
            $listener->$notifyMethod($event);
        }
    }
    
    public function doFoo($info) {
        $e = new FooEvent($info);
        //some complex processing on $e here
        $this->notifyListeners($e, "FooListener");
    }
}

$example = new EventDispatcher();
$example->attachListener(new FooListener());
$example->doFoo(42);

Posted: Mon Dec 11, 2006 8:32 pm
by wei
This is another design for events, e.g.
An event is defined by the presence of a method whose name starts with 'on'. The event name is the method name and is thus case-insensitive. An event can be attached with one or several methods (called event handlers). An event can be raised by calling raiseEvent method, upon which the attached event handlers will be invoked automatically in the order they are attached to the event. Event handlers must have the following signature,

Code: Select all

function eventHandlerFuncName($sender,$param) { ... }
where $sender refers to the object who is responsible for the raising of the event, and $param refers to a structure that may contain event-specific information. To raise an event (assuming named as 'Click') of a component, use

Code: Select all

$component->raiseEvent('OnClick');
To attach an event handler to an event, use one of the following ways,

Code: Select all

$component->OnClick=$callback; 
$component->OnClick->add($callback);
$component->attachEventHandler('OnClick',$callback);
The first two ways make use of the fact that $component->OnClick refers to the event handler list TList for the 'OnClick' event. The variable $callback contains the definition of the event handler that can be either a string referring to a global function name, or an array whose first element refers to an object and second element a method name/path that is reachable by the object, e.g.

Code: Select all

'buttonClicked' : buttonClicked($sender,$param);
array($object,'buttonClicked') : $object->buttonClicked($sender,$param);
array($object,'MainContent.SubmitButton.buttonClicked') : $object->MainContent->SubmitButton->buttonClicked($sender,$param);
e.g.

Code: Select all

class Foo extends TComponent
{
     function onClick()
     {
            $this->raiseEvent('OnClick', this, null);
      }

    function showMeTheMoney()
   {
       //go rob a bank
   }
}

function handle_foo_clicked($sender, $param)
{
    $sender->showMeTheMoney();
}

$foo = new Foo;
$foo->OnClick='handle_foo_clicked';
$foo->onClick();

Posted: Tue Dec 12, 2006 9:37 am
by Chris Corbyn
Yeah I guess callbacks work quite well for things like this. Using callbacks is pretty much how JavaScript triggers onclick methods etc on objects.