diff --git a/control/injector/AfterCallAspect.php b/control/injector/AfterCallAspect.php index ce7ab303d..3e663b510 100644 --- a/control/injector/AfterCallAspect.php +++ b/control/injector/AfterCallAspect.php @@ -22,6 +22,8 @@ interface AfterCallAspect { * The name of the method being called * @param string $args * The arguments that were passed to the method call + * @param mixed $result + * The result of calling the method on the real object */ - public function afterCall($proxied, $method, $args); + public function afterCall($proxied, $method, $args, $result); } diff --git a/control/injector/AopProxyService.php b/control/injector/AopProxyService.php index 65eb334c0..4520696d0 100644 --- a/control/injector/AopProxyService.php +++ b/control/injector/AopProxyService.php @@ -13,14 +13,35 @@ class AopProxyService { public $afterCall = array(); public $proxied; + + /** + * Because we don't know exactly how the proxied class is usually called, + * provide a default constructor + */ + public function __construct() { + + } public function __call($method, $args) { if (method_exists($this->proxied, $method)) { $continue = true; + $result = null; + if (isset($this->beforeCall[$method])) { - $result = $this->beforeCall[$method]->beforeCall($this->proxied, $method, $args); - if ($result === false) { - $continue = false; + $methods = $this->beforeCall[$method]; + if (!is_array($methods)) { + $methods = array($methods); + } + foreach ($methods as $handler) { + $alternateReturn = null; + $proceed = $handler->beforeCall($this->proxied, $method, $args, $alternateReturn); + if ($proceed === false) { + $continue = false; + // if something is set in, use it + if ($alternateReturn) { + $result = $alternateReturn; + } + } } } @@ -28,11 +49,20 @@ class AopProxyService { $result = call_user_func_array(array($this->proxied, $method), $args); if (isset($this->afterCall[$method])) { - $this->afterCall[$method]->afterCall($this->proxied, $method, $args, $result); + $methods = $this->afterCall[$method]; + if (!is_array($methods)) { + $methods = array($methods); + } + foreach ($methods as $handler) { + $return = $handler->afterCall($this->proxied, $method, $args, $result); + if (!is_null($return)) { + $result = $return; + } + } } - - return $result; } + + return $result; } } } diff --git a/control/injector/BeforeCallAspect.php b/control/injector/BeforeCallAspect.php index b2bca8f2b..4a53ce2f2 100644 --- a/control/injector/BeforeCallAspect.php +++ b/control/injector/BeforeCallAspect.php @@ -20,6 +20,9 @@ interface BeforeCallAspect { * The name of the method being called * @param string $args * The arguments that were passed to the method call + * @param mixed $alternateReturn + * An alternative return value that should be passed + * to the caller. Only has effect of beforeCall returns false */ - public function beforeCall($proxied, $method, $args); + public function beforeCall($proxied, $method, $args, &$alternateReturn); } diff --git a/docs/en/reference/aspects.md b/docs/en/reference/aspects.md new file mode 100644 index 000000000..185842bb6 --- /dev/null +++ b/docs/en/reference/aspects.md @@ -0,0 +1,189 @@ +# Aspects + +## Introduction + +Aspect oriented programming is the idea that some logic abstractions can be +applied across various type hierarchies "after the fact", altering the +behaviour of the system without altering the code structures that are already +in place. + +> In computing, aspect-oriented programming (AOP) is a programming paradigm +> which isolates secondary or supporting functions from the main program's +> business logic. It aims to increase modularity by allowing the separation of +> cross-cutting concerns, forming a basis for aspect-oriented software +> development. + +[The wiki article](http://en.wikipedia.org/wiki/Aspect-oriented_programming) +provides a much more in-depth explanation! + + +In the context of this dependency injector, AOP is achieved thanks to PHP's +__call magic method combined with the Proxy design pattern. + +## In practice + +* Assume an existing service declaration exists called MyService +* An AopProxyService class instance is created, and the existing MyService object is bound in as a member variable of the AopProxyService class +* Objects are added to the AopProxyService instance's "beforeCall" and "afterCall" lists; each of these implements either the beforeCall or afterCall method +* When client code declares a dependency on MyService, it is actually passed in the AopProxyService instance +* Client code calls a method `myMethod` that it knows exists on MyService - this doesn't exist on AopProxyService, so __call is triggered. +* All classes bound to the beforeCall list are executed; if any explicitly returns 'false', `myMethod` is not executed. +* Otherwise, myMethod is executed +* All classes bound to the afterCall list are executed + + +## A worked example + +To provide some context, imagine a situation where we want to direct all 'write' queries made in the system to a specific +database server, whereas all read queries can be handled by slave servers. A simplified implementation might look +like the following - note that this doesn't cover all cases used by SilverStripe so is not a complete solution, more +just a guide to how it would be used. + + +``` + + * @license BSD License http://www.silverstripe.org/bsd-license + */ +class MySQLWriteDbAspect implements BeforeCallAspect { + /** + * + * @var MySQLDatabase + */ + public $writeDb; + + public $writeQueries = array('insert','update','delete','replace'); + + public function beforeCall($proxied, $method, $args, &$alternateReturn) { + if (isset($args[0])) { + $sql = $args[0]; + $code = isset($args[1]) ? $args[1] : E_USER_ERROR; + if (in_array(strtolower(substr($sql,0,strpos($sql,' '))), $this->writeQueries)) { + $alternateReturn = $this->writeDb->query($sql, $code); + return false; + } + } + } +} + + +``` + +To actually make use of this class, a few different objects need to be configured. First up, define the `writeDb` +object that's made use of above + +``` + WriteMySQLDatabase: + class: MySQLDatabase + constructor: + - type: MySQLDatabase + server: write.hostname.db + username: user + password: pass + database: write_database +``` + +This means that whenever something asks the injector for the `WriteMySQLDatabase` object, it'll receive an object of +type `MySQLDatabase`, configured to point at the 'write' database + +Next, this should be bound into an instance of the aspect class + +``` + MySQLWriteDbAspect: + properties: + writeDb: %$WriteMySQLDatabase +``` + + +Next, we need to define the database connection that will be used for all non-write queries + +``` + ReadMySQLDatabase: + class: MySQLDatabase + constructor: + - type: MySQLDatabase + server: slavecluster.hostname.db + username: user + password: pass + database: read_database +``` + +The final piece that ties everything together is the AopProxyService instance that will be used as the replacement +object when the framework creates the database connection in DB.php + +``` + MySQLDatabase: + class: AopProxyService + properties: + proxied: %$ReadMySQLDatabase + beforeCall: + query: + - %$MySQLWriteDbAspect +``` + +The two important parts here are in the `properties` declared for the object + +- **proxied** : This is the 'read' database connectino that all queries should be initially directed through +- **beforeCall** : A hash of method\_name => array containing objects that are to be evaluated _before_ a call to the defined method\_name + + +Overall configuration for this would look as follows + +``` + +Injector: + ReadMySQLDatabase: + class: MySQLDatabase + constructor: + - type: MySQLDatabase + server: slavecluster.hostname.db + username: user + password: pass + database: read_database + MySQLWriteDbAspect: + properties: + writeDb: %$WriteMySQLDatabase + WriteMySQLDatabase: + class: MySQLDatabase + constructor: + - type: MySQLDatabase + server: write.hostname.db + username: user + password: pass + database: write_database + MySQLDatabase: + class: AopProxyService + properties: + proxied: %$ReadMySQLDatabase + beforeCall: + query: + - %$MySQLWriteDbAspect + +``` + + +## Changing what a method returns + +One major feature of an aspect is the ability to modify what is returned from the client's call to the proxied method. +As seen in the above example, the `beforeCall` method modifies the byref `&$alternateReturn` variable, and returns +`false` after doing so. + +``` + $alternateReturn = $this->writeDb->query($sql, $code); + return false; +``` + +By returning false from the `beforeCall()` method, the wrapping proxy class will _not_ call any additional `beforeCall` +handlers defined for the called method. Assigning the $alternateReturn variable also indicates to return that value +to the caller of the method. + + +Similarly the `afterCall()` aspect can be used to manipulate the value to be returned to the calling code. All the +`afterCall()` method needs to do is return a non-null value, and that value will be returned to the original calling +code instead of the actual return value of the called method. + + diff --git a/model/DB.php b/model/DB.php index 558fb72f5..5f215a679 100644 --- a/model/DB.php +++ b/model/DB.php @@ -171,7 +171,7 @@ class DB { self::$connection_attempted = true; $dbClass = $databaseConfig['type']; - $conn = new $dbClass($databaseConfig); + $conn = Injector::inst()->create($dbClass, $databaseConfig); self::setConn($conn, $label); diff --git a/tests/injector/AopProxyTest.php b/tests/injector/AopProxyTest.php new file mode 100644 index 000000000..57e62c407 --- /dev/null +++ b/tests/injector/AopProxyTest.php @@ -0,0 +1,107 @@ + + * @license BSD License http://www.silverstripe.org/bsd-license + */ +class AopProxyTest extends SapphireTest { + public function testBeforeMethodsCalled() { + $proxy = new AopProxyService(); + $aspect = new BeforeAfterCallTestAspect(); + $proxy->beforeCall = array( + 'myMethod' => $aspect + ); + + $proxy->proxied = new ProxyTestObject(); + + $result = $proxy->myMethod(); + + $this->assertEquals('myMethod', $aspect->called); + $this->assertEquals(42, $result); + } + + public function testBeforeMethodBlocks() { + $proxy = new AopProxyService(); + $aspect = new BeforeAfterCallTestAspect(); + $aspect->block = true; + + $proxy->beforeCall = array( + 'myMethod' => $aspect + ); + + $proxy->proxied = new ProxyTestObject(); + + $result = $proxy->myMethod(); + + $this->assertEquals('myMethod', $aspect->called); + + // the actual underlying method will NOT have been called + $this->assertNull($result); + + // set up an alternative return value + $aspect->alternateReturn = 84; + + $result = $proxy->myMethod(); + + $this->assertEquals('myMethod', $aspect->called); + + // the actual underlying method will NOT have been called, + // instead the alternative return value + $this->assertEquals(84, $result); + } + + public function testAfterCall() { + $proxy = new AopProxyService(); + $aspect = new BeforeAfterCallTestAspect(); + + $proxy->afterCall = array( + 'myMethod' => $aspect + ); + + $proxy->proxied = new ProxyTestObject(); + + $aspect->modifier = function ($value) { + return $value * 2; + }; + + $result = $proxy->myMethod(); + $this->assertEquals(84, $result); + } + +} + +class ProxyTestObject { + public function myMethod() { + return 42; + } +} + +class BeforeAfterCallTestAspect implements BeforeCallAspect, AfterCallAspect { + public $block = false; + + public $called; + + public $alternateReturn; + + public $modifier; + + public function beforeCall($proxied, $method, $args, &$alternateReturn) { + $this->called = $method; + + if ($this->block) { + if ($this->alternateReturn) { + $alternateReturn = $this->alternateReturn; + } + return false; + } + } + + public function afterCall($proxied, $method, $args, $result) { + if ($this->modifier) { + $modifier = $this->modifier; + return $modifier($result); + } + } +} \ No newline at end of file