From b269badfbe8daa77c4d80d9de1106160d6206d91 Mon Sep 17 00:00:00 2001 From: Marcus Nyeholt Date: Wed, 9 May 2012 22:26:29 +1000 Subject: [PATCH] FEATURE: Added dependency injector for managing creation of new objects and their dependencies. API CHANGE: Pass Object::create() calls to Injector::create(). API CHANGE: Add "RequestProcessor" injection point in Director, that Director will call preRequest() and postRequest() on. --- _config/RequestProcessor.yml | 6 + control/Director.php | 37 +- control/RequestFilter.php | 24 + control/RequestProcessor.php | 37 + control/injector/AfterCallAspect.php | 27 + control/injector/AopProxyService.php | 41 + control/injector/BeforeCallAspect.php | 27 + control/injector/Injector.php | 827 ++++++++++++++++++ core/Core.php | 4 + core/Object.php | 6 +- docs/en/reference/director.md | 20 + docs/en/reference/injector.md | 184 ++++ tests/injector/InjectorTest.php | 597 +++++++++++++ .../injector/testservices/AnotherService.php | 6 + tests/injector/testservices/SampleService.php | 12 + 15 files changed, 1839 insertions(+), 16 deletions(-) create mode 100644 _config/RequestProcessor.yml create mode 100644 control/RequestFilter.php create mode 100644 control/RequestProcessor.php create mode 100644 control/injector/AfterCallAspect.php create mode 100644 control/injector/AopProxyService.php create mode 100644 control/injector/BeforeCallAspect.php create mode 100644 control/injector/Injector.php create mode 100644 docs/en/reference/injector.md create mode 100644 tests/injector/InjectorTest.php create mode 100644 tests/injector/testservices/AnotherService.php create mode 100644 tests/injector/testservices/SampleService.php diff --git a/_config/RequestProcessor.yml b/_config/RequestProcessor.yml new file mode 100644 index 000000000..9309f0265 --- /dev/null +++ b/_config/RequestProcessor.yml @@ -0,0 +1,6 @@ +name: RequestProcessor +--- +# Providing an empty config so it can be overridden at a later point +Injector: + RequestProcessor: + 0: \ No newline at end of file diff --git a/control/Director.php b/control/Director.php index 23316cd90..335cffd77 100644 --- a/control/Director.php +++ b/control/Director.php @@ -97,8 +97,14 @@ class Director implements TemplateGlobalProvider { if(!isset($_SESSION) && (isset($_COOKIE[session_name()]) || isset($_REQUEST[session_name()]))) Session::start(); // Initiate an empty session - doesn't initialize an actual PHP session until saved (see belwo) $session = new Session(isset($_SESSION) ? $_SESSION : null); + + $output = Injector::inst()->get('RequestProcessor')->preRequest($req, $session, $model); - // Main request handling + if ($output === false) { + // @TODO Need to NOT proceed with the request in an elegant manner + throw new SS_HTTPResponse_Exception(_t('Director.INVALID_REQUEST', 'Invalid request'), 400); + } + $result = Director::handleRequest($req, $session, $model); // Save session data (and start/resume it if required) @@ -108,8 +114,10 @@ class Director implements TemplateGlobalProvider { if(is_string($result) && substr($result,0,9) == 'redirect:') { $response = new SS_HTTPResponse(); $response->redirect(substr($result, 9)); - $response->output(); - + $res = Injector::inst()->get('RequestProcessor')->postRequest($req, $response, $model); + if ($res !== false) { + $response->output(); + } // Handle a controller } else if($result) { if($result instanceof SS_HTTPResponse) { @@ -120,15 +128,22 @@ class Director implements TemplateGlobalProvider { $response->setBody($result); } - // ?debug_memory=1 will output the number of bytes of memory used for this request - if(isset($_REQUEST['debug_memory']) && $_REQUEST['debug_memory']) { - Debug::message(sprintf( - "Peak memory usage in bytes: %s", - number_format(memory_get_peak_usage(),0) - )); + $res = Injector::inst()->get('RequestProcessor')->postRequest($req, $response, $model); + if ($res !== false) { + // ?debug_memory=1 will output the number of bytes of memory used for this request + if(isset($_REQUEST['debug_memory']) && $_REQUEST['debug_memory']) { + Debug::message(sprintf( + "Peak memory usage in bytes: %s", + number_format(memory_get_peak_usage(),0) + )); + } else { + $response->output(); + } } else { - $response->output(); + // @TODO Proper response here. + throw new SS_HTTPResponse_Exception("Invalid response"); } + //$controllerObj->getSession()->inst_save(); } @@ -255,7 +270,7 @@ class Director implements TemplateGlobalProvider { } else { Director::$urlParams = $arguments; - $controllerObj = new $controller(); + $controllerObj = Injector::inst()->create($controller); $controllerObj->setSession($session); try { diff --git a/control/RequestFilter.php b/control/RequestFilter.php new file mode 100644 index 000000000..35fbb4a1b --- /dev/null +++ b/control/RequestFilter.php @@ -0,0 +1,24 @@ +filters = $filters; + } + + public function preRequest(SS_HTTPRequest $request, Session $session, DataModel $model) { + foreach ($this->filters as $filter) { + $res = $filter->preRequest($request, $session, $model); + if ($res === false) { + return false; + } + } + } + + /** + * Filter executed AFTER a request + */ + public function postRequest(SS_HTTPRequest $request, SS_HTTPResponse $response, DataModel $model) { + foreach ($this->filters as $filter) { + $res = $filter->postRequest($request, $response, $model); + if ($res === false) { + return false; + } + } + } +} \ No newline at end of file diff --git a/control/injector/AfterCallAspect.php b/control/injector/AfterCallAspect.php new file mode 100644 index 000000000..3da0df056 --- /dev/null +++ b/control/injector/AfterCallAspect.php @@ -0,0 +1,27 @@ + + * @package sapphire + * @subpackage injector + * @license BSD http://silverstripe.org/BSD-license + */ +interface AfterCallAspect { + + /** + * Call this aspect after a method is executed + * + * @param object $proxied + * The object having the method called upon it. + * @param string $method + * The name of the method being called + * @param string $args + * The arguments that were passed to the method call + */ + public function afterCall($proxied, $method, $args); +} diff --git a/control/injector/AopProxyService.php b/control/injector/AopProxyService.php new file mode 100644 index 000000000..bb52ef8bf --- /dev/null +++ b/control/injector/AopProxyService.php @@ -0,0 +1,41 @@ +proxied, $method)) { + $continue = true; + if (isset($this->beforeCall[$method])) { + $result = $this->beforeCall[$method]->beforeCall($this->proxied, $method, $args); + if ($result === false) { + $continue = false; + } + } + + if ($continue) { + $result = call_user_func_array(array($this->proxied, $method), $args); + + if (isset($this->afterCall[$method])) { + $this->afterCall[$method]->afterCall($this->proxied, $method, $args, $result); + } + + return $result; + } + } + } +} \ No newline at end of file diff --git a/control/injector/BeforeCallAspect.php b/control/injector/BeforeCallAspect.php new file mode 100644 index 000000000..350dd1c8d --- /dev/null +++ b/control/injector/BeforeCallAspect.php @@ -0,0 +1,27 @@ + + * @package sapphire + * @subpackage injector + * @license BSD http://silverstripe.org/BSD-license + */ +interface BeforeCallAspect { + + /** + * Call this aspect before a method is executed + * + * @param object $proxied + * The object having the method called upon it. + * @param string $method + * The name of the method being called + * @param string $args + * The arguments that were passed to the method call + */ + public function beforeCall($proxied, $method, $args); +} diff --git a/control/injector/Injector.php b/control/injector/Injector.php new file mode 100644 index 000000000..994cc2915 --- /dev/null +++ b/control/injector/Injector.php @@ -0,0 +1,827 @@ +get('ClassName') + * + * and a new instance of ClassName will be created and returned to you. + * + * Classes can have specific configuration defined for them to + * indicate dependencies that should be injected. This takes the form of + * a static variable $dependencies defined in the class (or configuration), + * which indicates the name of a property that should be set. + * + * eg + * + * + * class MyController extends Controller { + * + * public $permissions; + * public $defaultText; + * + * static $dependencies = array( + * 'defaultText' => 'Override in configuration', + * 'permissions' => '%$PermissionService', + * ); + * } + * + * + * will result in an object of type MyController having the defaultText property + * set to 'Override in configuration', and an object identified + * as PermissionService set into the property called 'permissions'. The %$ + * syntax tells the injector to look the provided name up as an item to be created + * by the Injector itself. + * + * A key concept of the injector is whether to manage the object as + * + * * A pseudo-singleton, in that only one item will be created for a particular + * identifier (but the same class could be used for multiple identifiers) + * * A prototype, where the same configuration is used, but a new object is + * created each time + * * unmanaged, in which case a new object is created and injected, but no + * information about its state is managed. + * + * Additional configuration of items managed by the injector can be done by + * providing configuration for the types, either by manually loading in an + * array describing the configuration, or by specifying the configuration + * for a type via SilverStripe's configuration mechanism. + * + * Specify a configuration array of the format + * + * array( + * array( + * 'id' => 'BeanId', // the name to be used if diff from the filename + * 'priority' => 1, // priority. If another bean is defined with the same ID, + * // but has a lower priority, it is NOT overridden + * 'class' => 'ClassName', // the name of the PHP class + * 'src' => '/path/to/file' // the location of the class + * 'type' => 'singleton|prototype' // if you want prototype object generation, set it as the type + * // By default, singleton is assumed + * + * 'construct' => array( // properties to set at construction + * 'scalar', + * '%$BeanId', + * ) + * 'properties' => array( + * 'name' => 'value' // scalar value + * 'name' => '%$BeanId', // a reference to another bean + * 'name' => array( + * 'scalar', + * '%$BeanId' + * ) + * ) + * ) + * // alternatively + * 'MyBean' => array( + * 'class' => 'ClassName', + * ) + * // or simply + * 'OtherBean' => 'SomeClass', + * ) + * + * In addition to specifying the bindings directly in the configuration, + * you can simply create a publicly accessible property on the target + * class which will automatically be injected if the autoScanProperties + * option is set to true. This means a class defined as + * + * + * class MyController extends Controller { + * + * private $permissionService; + * + * public setPermissionService($p) { + * $this->permissionService = $p; + * } + * } + * + * + * will have setPermissionService called if + * + * * Injector::inst()->setAutoScanProperties(true) is called and + * * A service named 'PermissionService' has been configured + * + * @author marcus@silverstripe.com.au + * @package sapphire + * @subpackage injector + * @license BSD License http://silverstripe.org/bsd-license/ + */ +class Injector { + + /** + * Local store of all services + * + * @var array + */ + private $serviceCache; + + /** + * Cache of items that need to be mapped for each service that gets injected + * + * @var array + */ + private $injectMap; + + /** + * A store of all the service configurations that have been defined. + * + * @var array + */ + private $specs; + + /** + * A map of all the properties that should be automagically set on all + * objects instantiated by the injector + */ + private $autoProperties; + + /** + * A singleton if you want to use it that way + * + * @var Injector + */ + private static $instance; + + /** + * Indicates whether or not to automatically scan properties in injected objects to auto inject + * stuff, similar to the way grails does things. + * + * @var boolean + */ + private $autoScanProperties = false; + + /** + * The object used to create new class instances + * + * Use a custom class here to change the way classes are created to use + * a custom creation method. By default the InjectionCreator class is used, + * which simply creates a new class via 'new', however this could be overridden + * to use, for example, SilverStripe's Object::create() method. + * + * @var InjectionCreator + */ + protected $objectCreator; + + /** + * Create a new injector. + * + * @param array $config + * Service configuration + */ + public function __construct($config = null) { + $this->injectMap = array(); + $this->serviceCache = array(); + $this->autoProperties = array(); + $this->specs = array(); + + $creatorClass = isset($config['creator']) ? $config['creator'] : 'InjectionCreator'; + $locatorClass = isset($config['locator']) ? $config['locator'] : 'ServiceConfigurationLocator'; + + $this->objectCreator = new $creatorClass; + $this->configLocator = new $locatorClass; + + if ($config) { + $this->load($config); + } + + self::$instance = $this; + } + + /** + * If a user wants to use the injector as a static reference + * + * @param array $config + * @return Injector + */ + public static function inst($config=null) { + if (!self::$instance) { + self::$instance = new Injector($config); + } + return self::$instance; + } + + /** + * Indicate whether we auto scan injected objects for properties to set. + * + * @param boolean $val + */ + public function setAutoScanProperties($val) { + $this->autoScanProperties = $val; + } + + /** + * Sets the object to use for creating new objects + * + * @param InjectionCreator $obj + */ + public function setObjectCreator($obj) { + $this->objectCreator = $obj; + } + + /** + * Add in a specific mapping that should be catered for on a type. + * This allows configuration of what should occur when an object + * of a particular type is injected, and what items should be injected + * for those properties / methods. + * + * @param type $class + * The class to set a mapping for + * @param type $property + * The property to set the mapping for + * @param type $injectType + * The registered type that will be injected + * @param string $injectVia + * Whether to inject by setting a property or calling a setter + */ + public function setInjectMapping($class, $property, $toInject, $injectVia = 'property') { + $mapping = isset($this->injectMap[$class]) ? $this->injectMap[$class] : array(); + + $mapping[$property] = array('name' => $toInject, 'type' => $injectVia); + + $this->injectMap[$class] = $mapping; + } + + /** + * Add an object that should be automatically set on managed objects + * + * This allows you to specify, for example, that EVERY managed object + * will be automatically inject with a log object by the following + * + * $injector->addAutoProperty('log', new Logger()); + * + * @param string $property + * the name of the property + * @param object $object + * the object to be set + */ + public function addAutoProperty($property, $object) { + $this->autoProperties[$property] = $object; + return $this; + } + + /** + * Load services using the passed in configuration for those services + * + * @param array $config + */ + public function load($config = array()) { + $services = array(); + + foreach ($config as $specId => $spec) { + if (is_string($spec)) { + $spec = array('class' => $spec); + } + + $file = isset($spec['src']) ? $spec['src'] : null; + $name = null; + + if (file_exists($file)) { + $filename = basename($file); + $name = substr($filename, 0, strrpos($filename, '.')); + } + + // class is whatever's explicitly set, + $class = isset($spec['class']) ? $spec['class'] : $name; + + // or the specid if nothing else available. + if (!$class && is_string($specId)) { + $class = $specId; + } + + // make sure the class is set... + $spec['class'] = $class; + + $id = is_string($specId) ? $specId : (isset($spec['id']) ? $spec['id'] : $class); + + $priority = isset($spec['priority']) ? $spec['priority'] : 1; + + // see if we already have this defined. If so, check priority weighting + if (isset($this->specs[$id]) && isset($this->specs[$id]['priority'])) { + if ($this->specs[$id]['priority'] > $priority) { + return; + } + } + + // okay, actually include it now we know we're going to use it + if (file_exists($file)) { + require_once $file; + } + + // make sure to set the id for later when instantiating + // to ensure we get cached + $spec['id'] = $id; + +// We've removed this check because new functionality means that the 'class' field doesn't need to refer +// specifically to a class anymore - it could be a compound statement, ala SilverStripe's old Object::create +// functionality +// +// if (!class_exists($class)) { +// throw new Exception("Failed to load '$class' from $file"); +// } + + // store the specs for now - we lazy load on demand later on. + $this->specs[$id] = $spec; + + // EXCEPT when there's already an existing instance at this id. + // if so, we need to instantiate and replace immediately + if (isset($this->serviceCache[$id])) { + $this->instantiate($spec, $id); + } + } + + return $this; + } + + /** + * Update the configuration of an already defined service + * + * Use this if you don't want to register a complete new config, just append + * to an existing configuration. Helpful to avoid overwriting someone else's changes + * + * updateSpec('RequestProcessor', 'filters', '%$MyFilter') + * + * @param string $id + * The name of the service to update the definition for + * @param string $property + * The name of the property to update. + * @param mixed $value + * The value to set + * @param boolean $append + * Whether to append (the default) when the property is an array + */ + public function updateSpec($id, $property, $value, $append = true) { + if (isset($this->specs[$id]['properties'][$property])) { + // by ref so we're updating the actual value + $current = &$this->specs[$id]['properties'][$property]; + if (is_array($current) && $append) { + $current[] = $value; + } else { + $this->specs[$id]['properties'][$property] = $value; + } + + // and reload the object; existing bindings don't get + // updated though! (for now...) + if (isset($this->serviceCache[$id])) { + $this->instantiate($spec, $id); + } + } + } + + /** + * Recursively convert a value into its proper representation with service references + * resolved to actual objects + * + * @param string $value + */ + public function convertServiceProperty($value) { + if (is_array($value)) { + $newVal = array(); + foreach ($value as $k => $v) { + $newVal[$k] = $this->convertServiceProperty($v); + } + return $newVal; + } + + if (is_string($value) && strpos($value, '%$') === 0) { + $id = substr($value, 2); + return $this->get($id); + } + return $value; + } + + /** + * Instantiate a managed object + * + * Given a specification of the form + * + * array( + * 'class' => 'ClassName', + * 'properties' => array('property' => 'scalar', 'other' => '%$BeanRef') + * 'id' => 'ServiceId', + * 'type' => 'singleton|prototype' + * ) + * + * will create a new object, store it in the service registry, and + * set any relevant properties + * + * Optionally, you can pass a class name directly for creation + * + * To access this from the outside, you should call ->get('Name') to ensure + * the appropriate checks are made on the specific type. + * + * + * @param array $spec + * The specification of the class to instantiate + */ + protected function instantiate($spec, $id=null) { + if (is_string($spec)) { + $spec = array('class' => $spec); + } + $class = $spec['class']; + + // create the object, using any constructor bindings + $constructorParams = array(); + if (isset($spec['constructor']) && is_array($spec['constructor'])) { + $constructorParams = $spec['constructor']; + } + + $object = $this->objectCreator->create($this, $class, $constructorParams); + + // figure out if we have a specific id set or not. In some cases, we might be instantiating objects + // that we don't manage directly; we don't want to store these in the service cache below + if (!$id) { + $id = isset($spec['id']) ? $spec['id'] : null; + } + + // now set the service in place if needbe. This is NOT done for prototype beans, as they're + // created anew each time + $type = isset($spec['type']) ? $spec['type'] : null; + if ($id && (!$type || $type != 'prototype')) { + // this ABSOLUTELY must be set before the object is injected. + // This prevents circular reference errors down the line + $this->serviceCache[$id] = $object; + } + + // now inject safely + $this->inject($object, $id); + + return $object; + } + + /** + * Inject $object with available objects from the service cache + * + * @todo Track all the existing objects that have had a service bound + * into them, so we can update that binding at a later point if needbe (ie + * if the managed service changes) + * + * @param object $object + * The object to inject + * @param string $asType + * The ID this item was loaded as. This is so that the property configuration + * for a type is referenced correctly in case $object is no longer the same + * type as the loaded config specification had it as. + */ + public function inject($object, $asType=null) { + $objtype = $asType ? $asType : get_class($object); + $mapping = isset($this->injectMap[$objtype]) ? $this->injectMap[$objtype] : null; + + // first off, set any properties defined in the service specification for this + // object type + if (isset($this->specs[$objtype]) && isset($this->specs[$objtype]['properties'])) { + foreach ($this->specs[$objtype]['properties'] as $key => $value) { + $val = $this->convertServiceProperty($value); + $this->setObjectProperty($object, $key, $val); + } + } + + // now, use any cached information about what properties this object type has + // and set based on name resolution + if (!$mapping) { + if ($this->autoScanProperties) { + // we use an object to prevent array copies if/when passed around + $mapping = new ArrayObject(); + + // This performs public variable based injection + $robj = new ReflectionObject($object); + $properties = $robj->getProperties(); + + foreach ($properties as $propertyObject) { + /* @var $propertyObject ReflectionProperty */ + if ($propertyObject->isPublic() && !$propertyObject->getValue($object)) { + $origName = $propertyObject->getName(); + $name = ucfirst($origName); + if ($this->hasService($name)) { + // Pull the name out of the registry + $value = $this->get($name); + $propertyObject->setValue($object, $value); + $mapping[$origName] = array('name' => $name, 'type' => 'property'); + } + } + } + + // and this performs setter based injection + $methods = $robj->getMethods(ReflectionMethod::IS_PUBLIC); + + foreach ($methods as $methodObj) { + /* @var $methodObj ReflectionMethod */ + $methName = $methodObj->getName(); + if (strpos($methName, 'set') === 0) { + $pname = substr($methName, 3); + if ($this->hasService($pname)) { + // Pull the name out of the registry + $value = $this->get($pname); + $methodObj->invoke($object, $value); + $mapping[$methName] = array('name' => $pname, 'type' => 'method'); + } + } + } + + // we store the information about what needs to be injected for objects of this + // type here + $this->injectMap[get_class($object)] = $mapping; + } + } else { + foreach ($mapping as $prop => $spec) { + if ($spec['type'] == 'property') { + $value = $this->get($spec['name']); + $object->$prop = $value; + } else { + $method = $prop; + $value = $this->get($spec['name']); + $object->$method($value); + } + } + } + + $injections = Config::inst()->get(get_class($object), 'dependencies'); + // If the type defines some injections, set them here + if ($injections && count($injections)) { + foreach ($injections as $property => $value) { + // we're checking isset in case it already has a property at this name + // this doesn't catch privately set things, but they will only be set by a setter method, + // which should be responsible for preventing further setting if it doesn't want it. + if (!isset($object->$property)) { + $value = $this->convertServiceProperty($value); + $this->setObjectProperty($object, $property, $value); + } + } + } + + foreach ($this->autoProperties as $property => $value) { + if (!isset($object->$property)) { + $value = $this->convertServiceProperty($value); + $this->setObjectProperty($object, $property, $value); + } + } + + // Call the 'injected' method if it exists + if (method_exists($object, 'injected')) { + $object->injected(); + } + } + + /** + * Helper to set a property's value + * + * @param object $object + * Set an object's property to a specific value + * @param string $name + * The name of the property to set + * @param mixed $value + * The value to set + */ + protected function setObjectProperty($object, $name, $value) { + if (method_exists($object, 'set'.$name)) { + $object->{'set'.$name}($value); + } else { + $object->$name = $value; + } + } + + /** + * Does the given service exist, and if so, what's the stored name for it? + * + * We do a special check here for services that are using compound names. For example, + * we might want to say that a property should be injected with Log.File or Log.Memory, + * but have only registered a 'Log' service, we'll instead return that. + * + * Will recursively call hasService for each depth of dotting + * + * @return string + * The name of the service (as it might be different from the one passed in) + */ + public function hasService($name) { + // common case, get it overwith first + if (isset($this->specs[$name])) { + return $name; + } + + // okay, check whether we've got a compound name - don't worry about 0 index, cause that's an + // invalid name + if (!strpos($name, '.')) { + return null; + } + + return $this->hasService(substr($name, 0, strrpos($name, '.'))); + } + + /** + * Register a service object with an optional name to register it as the + * service for + * + * @param stdClass $service + * The object to register + * @param string $replace + * The name of the object to replace (if different to the + * class name of the object to register) + * + */ + public function registerService($service, $replace=null) { + $registerAt = get_class($service); + if ($replace != null) { + $registerAt = $replace; + } + + $this->serviceCache[$registerAt] = $service; + $this->inject($service); + } + + /** + * Register a service with an explicit name + */ + public function registerNamedService($name, $service) { + $this->serviceCache[$name] = $service; + $this->inject($service); + } + + /** + * Get a named managed object + * + * Will first check to see if the item has been registered as a configured service/bean + * and return that if so. + * + * Next, will check to see if there's any registered configuration for the given type + * and will then try and load that + * + * Failing all of that, will just return a new instance of the + * specificied object. + * + * @param string $name + * the name of the service to retrieve. If not a registered + * service, then a class of the given name is instantiated + * @param boolean $asSingleton + * Whether to register the created object as a singleton + * if no other configuration is found + * @param array $constructorArgs + * Optional set of arguments to pass as constructor arguments + * if this object is to be created from scratch + * (ie asSingleton = false) + * + */ + public function get($name, $asSingleton = true, $constructorArgs = null) { + // reassign the name as it might actually be a compound name + if ($serviceName = $this->hasService($name)) { + // check to see what the type of bean is. If it's a prototype, + // we don't want to return the singleton version of it. + $spec = $this->specs[$serviceName]; + $type = isset($spec['type']) ? $spec['type'] : null; + + // if we're a prototype OR we're not wanting a singleton + if (($type && $type == 'prototype') || !$asSingleton) { + if ($spec) { + $spec['constructor'] = $constructorArgs; + } + return $this->instantiate($spec, $serviceName); + } else { + if (!isset($this->serviceCache[$serviceName])) { + $this->instantiate($spec, $serviceName); + } + return $this->serviceCache[$serviceName]; + } + } + + $config = $this->configLocator->locateConfigFor($name); + if ($config) { + $this->load(array($name => $config)); + if (isset($this->specs[$name])) { + $spec = $this->specs[$name]; + return $this->instantiate($spec, $name); + } + } + + // If we've got this far, we're dealing with a case of a user wanting + // to create an object based on its name. So, we need to fake its config + // if the user wants it managed as a singleton service style object + $spec = array('class' => $name, 'constructor' => $constructorArgs); + if ($asSingleton) { + // need to load the spec in; it'll be given the singleton type by default + $this->load(array($name => $spec)); + return $this->instantiate($spec, $name); + } + + return $this->instantiate($spec); + } + + /** + * Similar to get() but always returns a new object of the given type + * + * Additional parameters are passed through as + * + * @param type $name + */ + public function create($name) { + $constructorArgs = func_get_args(); + array_shift($constructorArgs); + return $this->get($name, false, count($constructorArgs) ? $constructorArgs : null); + } + + /** + * Creates an object with the supplied argument array + * + * @param string $name + * Name of the class to create an object of + * @param array $args + * Arguments to pass to the constructor + * @return mixed + */ + public function createWithArgs($name, $constructorArgs) { + return $this->get($name, false, $constructorArgs); + } +} + +/** + * A class for creating new objects by the injector + */ +class InjectionCreator { + /** + * + * @param string $object + * A string representation of the class to create + * @param array $params + * An array of parameters to be passed to the constructor + */ + public function create(Injector $injector, $class, $params = array()) { + $reflector = new ReflectionClass($class); + if (count($params)) { + return $reflector->newInstanceArgs($injector->convertServiceProperty($params)); + } + return $reflector->newInstance(); + } +} + +class SilverStripeInjectionCreator { + /** + * + * @param string $object + * A string representation of the class to create + * @param array $params + * An array of parameters to be passed to the constructor + */ + public function create(Injector $injector, $class, $params = array()) { + $class = Object::getCustomClass($class); + $reflector = new ReflectionClass($class); + return $reflector->newInstanceArgs($injector->convertServiceProperty($params)); + } +} + +/** + * Used to locate configuration for a particular named service. + * + * If it isn't found, return null + */ +class ServiceConfigurationLocator { + public function locateConfigFor($name) { + + } +} + +/** + * Use the SilverStripe configuration system to lookup config for a particular service + */ +class SilverStripeServiceConfigurationLocator { + + private $configs = array(); + + public function locateConfigFor($name) { + + if (isset($this->configs[$name])) { + return $this->configs[$name]; + } + + $config = Config::inst()->get('Injector', $name); + if ($config) { + $this->configs[$name] = $config; + return $config; + } + + // do parent lookup if it's a class + if (class_exists($name)) { + $parents = array_reverse(array_keys(ClassInfo::ancestry($name))); + array_shift($parents); + foreach ($parents as $parent) { + // have we already got for this? + if (isset($this->configs[$parent])) { + return $this->configs[$parent]; + } + $config = Config::inst()->get('Injector', $parent); + if ($config) { + $this->configs[$name] = $config; + return $config; + } else { + $this->configs[$parent] = false; + } + } + + // there is no parent config, so we'll record that as false so we don't do the expensive + // lookup through parents again + $this->configs[$name] = false; + } + } +} \ No newline at end of file diff --git a/core/Core.php b/core/Core.php index ac090a33b..1359ffffd 100644 --- a/core/Core.php +++ b/core/Core.php @@ -281,6 +281,10 @@ if(Director::isLive()) { */ Debug::loadErrorHandlers(); +// initialise the dependency injector +$default_options = array('locator' => 'SilverStripeServiceConfigurationLocator'); +Injector::inst($default_options)->addAutoProperty('injector', Injector::inst()); + /////////////////////////////////////////////////////////////////////////////// // HELPER FUNCTIONS diff --git a/core/Object.php b/core/Object.php index 2b0614fd8..1fb1c60a2 100755 --- a/core/Object.php +++ b/core/Object.php @@ -104,11 +104,7 @@ abstract class Object { $class = self::getCustomClass($class); - $reflector = new ReflectionClass($class); - if($reflector->getConstructor()) { - return $reflector->newInstanceArgs($args); - } - return new $class; + return Injector::inst()->createWithArgs($class, $args); } private static $_cache_inst_args = array(); diff --git a/docs/en/reference/director.md b/docs/en/reference/director.md index 2ab4978be..1ee1bcd3a 100644 --- a/docs/en/reference/director.md +++ b/docs/en/reference/director.md @@ -29,6 +29,26 @@ following two conditions are true: redirectBack(). +## Request processing + +The `[api:Director]` is the entry point in Silverstring Framework for processing a request. You can read through +the execution steps in `[api:Director]``::direct()`, but in short + +* File uploads are first analysed to remove potentially harmful uploads (this will likely change!) +* The `[api:SS_HTTPRequest]` object is created +* The session object is created +* The `[api:Injector]` is first referenced, and asks the registered `[api:RequestProcessor]` to pre-process + the request object. This allows for analysis of the current request, and allow filtering of parameters + etc before any of the core of the application executes +* The request is handled and response checked +* The `[api:RequestProcessor]` is called to post-process the request to allow further filtering before + content is sent to the end user. +* The response is output + +The framework provides the ability to hook into the request both before and after it is handled to allow +developers to bind in their own custom pre- or post- request logic; see the `[api:RequestFilter]` to see how +this can be used to authenticate the request before the request is handled. + ## Custom Rewrite Rules You can influence the way URLs are resolved one of 2 ways diff --git a/docs/en/reference/injector.md b/docs/en/reference/injector.md new file mode 100644 index 000000000..aca41aba9 --- /dev/null +++ b/docs/en/reference/injector.md @@ -0,0 +1,184 @@ +# Injector + +## Introduction + +The `[api:Injector]` class is the central manager of inter-class dependencies +in the SilverStripe Framework. In its simplest form it can be considered as +a replacement for Object::create and singleton() calls, but also offers +developers the ability to declare the dependencies a class type has, or +to change the nature of the dependencies defined by other developers. + +Some of the goals of dependency injection are + +* Simplified instantiation of objects +* Providing a uniform way of declaring and managing inter-object dependencies +* Making class dependencies configurable +* Simplifying the process of overriding or replacing core behaviour +* Improve testability of code +* Promoting abstraction of logic + +A key concept of the injector is whether the object should be managed as + +* A pseudo-singleton, in that only one item will be created for a particular + identifier (but the same class could be used for multiple identifiers) +* A prototype, where the same configuration is used, but a new object is + created each time +* unmanaged, in which case a new object is created and injected, but no + information about its state is managed. + +These concepts will be discussed further below + +## Some simple examples + +The following sums up the simplest usage of the injector + +Assuming no other configuration is specified + + :::php + $object = Injector::inst()->create('ClassName'); + +Creates a new object of type ClassName + + :::php + $object = Injector::inst()->create('ClassName'); + $object2 = Injector::inst()->create('ClassName'); + $object !== $object2; + +Repeated calls to create() create a new class each time. To create a singleton +object instead, use **get()** + + :::php + // sets up ClassName as a singleton + $object = Injector::inst()->get('ClassName'); + $object2 = Injector::inst()->get('ClassName'); + $object === $object2; + +The subsequent call returns the SAME object as the first call. + + :::php + class MyController extends Controller { + // both of these properties will be automatically + // set by the injector on object creation + public $permissions; + public $textProperty; + + static $dependencies = array( + 'textProperty' => 'a string value', + 'permissions' => '%$PermissionService', + ); + } + + $object = Injector::inst()->get('MyController'); + + // results in + $object->permissions instanceof PermissionService; + $object->textProperty == 'a string value'; + +In this case, on creation of the MyController object, the injector will +automatically instantiate the PermissionService object and set it as +the **permissions** property. + + +## Configuring objects managed by the dependency injector + +The above declarative style of dependency management would cover a large +portion of usecases, but more complex dependency structures can be defined +via configuration files. + +Configuration can be specified for two areas of dependency management + +* Defining dependency overrides for individual classes +* Injector managed 'services' + +### Dependency overrides + +To override the **static $dependency;** declaration for a class, you could +define the following configuration file (module/_config/MyController.yml) + + name: MyController + --- + MyController: + dependencies: + textProperty: a string value + permissions: %$PermissionService + +At runtime, the **dependencies** configuration would be read and used in +place of that declared on the object. + +### Managed objects + +Simple dependencies can be specified by the **dependencies**, but more complex +configurations are possible by specifying constructor arguments, or by +specifying more complex properties such as lists. + +These more complex configurations are defined in 'Injector' configuration +blocks and are read by the injector at runtime + +Assuming a class structure such as + + :::php + class RestrictivePermissionService { + private $database; + public function setDatabase($d) { + $this->database = $d; + } + } + + class MySQLDatabase { + private $username; + private $password; + + public function __construct($username, $password) { + $this->username = $username; + $this->password = $password; + } + } + +and the following configuration + + name: MyController + --- + MyController: + dependencies: + permissions: %$PermissionService + Injector: + PermissionService: + class: RestrictivePermissionService + properties: + database: %$MySQLDatabase + MySQLDatabase + constructor: + 0: 'dbusername' + 1: 'dbpassword' + +calling + + :::php + // sets up ClassName as a singleton + $controller = Injector::inst()->get('MyController'); + +would + +* Create an object of type MyController +* Look through the **dependencies** and call get('PermissionService') +* Load the configuration for PermissionService, and create an object of + type RestrictivePermissionService +* Look at the properties to be injected and look for the config for + MySQLDatabase +* Create a MySQLDatabase class, passing dbusername and dbpassword as the + parameters to the constructor + + +### What are Services? + +Without diving too deep down the rabbit hole, the term 'Service' is commonly +used to describe a piece of code that acts as an interface between the +controller layer and model layer of an MVC architecture. Rather than having +a controller action directly operate on data objects, a service layer provides +that logic abstraction, stopping controllers from implementing business logic, +and keeping that logic packaged in a way that is easily reused from other +classes. + +By default, objects are managed like a singleton, in that there is only one +object instance used for a named service, and all references to that service +are returned the same object. \ No newline at end of file diff --git a/tests/injector/InjectorTest.php b/tests/injector/InjectorTest.php new file mode 100644 index 000000000..8ecbccaeb --- /dev/null +++ b/tests/injector/InjectorTest.php @@ -0,0 +1,597 @@ +setAutoScanProperties(true); + $config = array(array('src' => TEST_SERVICES . '/SampleService.php',)); + + $injector->load($config); + $this->assertTrue($injector->hasService('SampleService') == 'SampleService'); + + $myObject = new TestObject(); + $injector->inject($myObject); + + $this->assertEquals(get_class($myObject->sampleService), 'SampleService'); + } + + public function testConfiguredInjector() { + $injector = new Injector(); + $services = array( + array( + 'src' => TEST_SERVICES . '/AnotherService.php', + 'properties' => array('config_property' => 'Value'), + ), + array( + 'src' => TEST_SERVICES . '/SampleService.php', + ) + ); + + $injector->load($services); + $this->assertTrue($injector->hasService('SampleService') == 'SampleService'); + // We expect a false because the 'AnotherService' is actually + // just a replacement of the SampleService + $this->assertTrue($injector->hasService('AnotherService') == 'AnotherService'); + + $item = $injector->get('AnotherService'); + + $this->assertEquals('Value', $item->config_property); + } + + public function testIdToNameMap() { + $injector = new Injector(); + $services = array( + 'FirstId' => 'AnotherService', + 'SecondId' => 'SampleService', + ); + + $injector->load($services); + + $this->assertTrue($injector->hasService('FirstId') == 'FirstId'); + $this->assertTrue($injector->hasService('SecondId') == 'SecondId'); + + $this->assertTrue($injector->get('FirstId') instanceof AnotherService); + $this->assertTrue($injector->get('SecondId') instanceof SampleService); + } + + public function testReplaceService() { + $injector = new Injector(); + $injector->setAutoScanProperties(true); + + $config = array(array('src' => TEST_SERVICES . '/SampleService.php')); + + // load + $injector->load($config); + + // inject + $myObject = new TestObject(); + $injector->inject($myObject); + + $this->assertEquals(get_class($myObject->sampleService), 'SampleService'); + + // also tests that ID can be the key in the array + $config = array('SampleService' => array('src' => TEST_SERVICES . '/AnotherService.php')); // , 'id' => 'SampleService')); + // load + $injector->load($config); + + $injector->inject($myObject); + $this->assertEquals('AnotherService', get_class($myObject->sampleService)); + } + + public function testUpdateSpec() { + $injector = new Injector(); + $services = array( + 'AnotherService' => array( + 'src' => TEST_SERVICES . '/AnotherService.php', + 'properties' => array( + 'filters' => array( + 'One', + 'Two', + ) + ), + ) + ); + + $injector->load($services); + + $injector->updateSpec('AnotherService', 'filters', 'Three'); + $another = $injector->get('AnotherService'); + + $this->assertEquals(3, count($another->filters)); + $this->assertEquals('Three', $another->filters[2]); + } + + public function testAutoSetInjector() { + $injector = new Injector(); + $injector->setAutoScanProperties(true); + $injector->addAutoProperty('auto', 'somevalue'); + $config = array(array('src' => TEST_SERVICES . '/SampleService.php',)); + $injector->load($config); + + $this->assertTrue($injector->hasService('SampleService') == 'SampleService'); + // We expect a false because the 'AnotherService' is actually + // just a replacement of the SampleService + + $myObject = new TestObject(); + + $injector->inject($myObject); + + $this->assertEquals(get_class($myObject->sampleService), 'SampleService'); + $this->assertEquals($myObject->auto, 'somevalue'); + } + + public function testSettingSpecificProperty() { + $injector = new Injector(); + $config = array('AnotherService'); + $injector->load($config); + $injector->setInjectMapping('TestObject', 'sampleService', 'AnotherService'); + $testObject = $injector->get('TestObject'); + + $this->assertEquals(get_class($testObject->sampleService), 'AnotherService'); + } + + public function testSettingSpecificMethod() { + $injector = new Injector(); + $config = array('AnotherService'); + $injector->load($config); + $injector->setInjectMapping('TestObject', 'setSomething', 'AnotherService', 'method'); + + $testObject = $injector->get('TestObject'); + + $this->assertEquals(get_class($testObject->sampleService), 'AnotherService'); + } + + public function testInjectingScopedService() { + $injector = new Injector(); + + $config = array( + 'AnotherService', + 'AnotherService.DottedChild' => 'SampleService', + ); + + $injector->load($config); + + $service = $injector->get('AnotherService.DottedChild'); + $this->assertEquals(get_class($service), 'SampleService'); + + $service = $injector->get('AnotherService.Subset'); + $this->assertEquals(get_class($service), 'AnotherService'); + + $injector->setInjectMapping('TestObject', 'sampleService', 'AnotherService.Geronimo'); + $testObject = $injector->create('TestObject'); + $this->assertEquals(get_class($testObject->sampleService), 'AnotherService'); + + $injector->setInjectMapping('TestObject', 'sampleService', 'AnotherService.DottedChild.AnotherDown'); + $testObject = $injector->create('TestObject'); + $this->assertEquals(get_class($testObject->sampleService), 'SampleService'); + + } + + public function testInjectUsingConstructor() { + $injector = new Injector(); + $config = array(array( + 'src' => TEST_SERVICES . '/SampleService.php', + 'constructor' => array( + 'val1', + 'val2', + ) + )); + + $injector->load($config); + $sample = $injector->get('SampleService'); + $this->assertEquals($sample->constructorVarOne, 'val1'); + $this->assertEquals($sample->constructorVarTwo, 'val2'); + + $injector = new Injector(); + $config = array( + 'AnotherService', + array( + 'src' => TEST_SERVICES . '/SampleService.php', + 'constructor' => array( + 'val1', + '%$AnotherService', + ) + ) + ); + + $injector->load($config); + $sample = $injector->get('SampleService'); + $this->assertEquals($sample->constructorVarOne, 'val1'); + $this->assertEquals(get_class($sample->constructorVarTwo), 'AnotherService'); + } + + public function testInjectUsingSetter() { + $injector = new Injector(); + $injector->setAutoScanProperties(true); + $config = array(array('src' => TEST_SERVICES . '/SampleService.php',)); + + $injector->load($config); + $this->assertTrue($injector->hasService('SampleService') == 'SampleService'); + + $myObject = new OtherTestObject(); + $injector->inject($myObject); + + $this->assertEquals(get_class($myObject->s()), 'SampleService'); + + // and again because it goes down a different code path when setting things + // based on the inject map + $myObject = new OtherTestObject(); + $injector->inject($myObject); + + $this->assertEquals(get_class($myObject->s()), 'SampleService'); + } + + // make sure we can just get any arbitrary object - it should be created for us + public function testInstantiateAnObjectViaGet() { + $injector = new Injector(); + $injector->setAutoScanProperties(true); + $config = array(array('src' => TEST_SERVICES . '/SampleService.php',)); + + $injector->load($config); + $this->assertTrue($injector->hasService('SampleService') == 'SampleService'); + + $myObject = $injector->get('OtherTestObject'); + $this->assertEquals(get_class($myObject->s()), 'SampleService'); + + // and again because it goes down a different code path when setting things + // based on the inject map + $myObject = $injector->get('OtherTestObject'); + $this->assertEquals(get_class($myObject->s()), 'SampleService'); + } + + public function testCircularReference() { + $services = array('CircularOne', 'CircularTwo'); + $injector = new Injector($services); + $injector->setAutoScanProperties(true); + + $obj = $injector->get('NeedsBothCirculars'); + + $this->assertTrue($obj->circularOne instanceof CircularOne); + $this->assertTrue($obj->circularTwo instanceof CircularTwo); + } + + public function testPrototypeObjects() { + $services = array('CircularOne', 'CircularTwo', array('class' => 'NeedsBothCirculars', 'type' => 'prototype')); + $injector = new Injector($services); + $injector->setAutoScanProperties(true); + $obj1 = $injector->get('NeedsBothCirculars'); + $obj2 = $injector->get('NeedsBothCirculars'); + + // if this was the same object, then $obj1->var would now be two + $obj1->var = 'one'; + $obj2->var = 'two'; + + $this->assertTrue($obj1->circularOne instanceof CircularOne); + $this->assertTrue($obj1->circularTwo instanceof CircularTwo); + + $this->assertEquals($obj1->circularOne, $obj2->circularOne); + $this->assertNotEquals($obj1, $obj2); + } + + public function testSimpleInstantiation() { + $services = array('CircularOne', 'CircularTwo'); + $injector = new Injector($services); + + // similar to the above, but explicitly instantiating this object here + $obj1 = $injector->create('NeedsBothCirculars'); + $obj2 = $injector->create('NeedsBothCirculars'); + + // if this was the same object, then $obj1->var would now be two + $obj1->var = 'one'; + $obj2->var = 'two'; + + $this->assertEquals($obj1->circularOne, $obj2->circularOne); + $this->assertNotEquals($obj1, $obj2); + } + + public function testCreateWithConstructor() { + $injector = new Injector(); + $obj = $injector->create('CircularTwo', 'param'); + $this->assertEquals($obj->otherVar, 'param'); + } + + public function testSimpleSingleton() { + $injector = new Injector(); + + $one = $injector->create('CircularOne'); + $two = $injector->create('CircularOne'); + + $this->assertFalse($one === $two); + + $one = $injector->get('CircularTwo'); + $two = $injector->get('CircularTwo'); + + $this->assertTrue($one === $two); + } + + public function testOverridePriority() { + $injector = new Injector(); + $injector->setAutoScanProperties(true); + $config = array( + array( + 'src' => TEST_SERVICES . '/SampleService.php', + 'priority' => 10, + ) + ); + + // load + $injector->load($config); + + // inject + $myObject = new TestObject(); + $injector->inject($myObject); + + $this->assertEquals(get_class($myObject->sampleService), 'SampleService'); + + $config = array( + array( + 'src' => TEST_SERVICES . '/AnotherService.php', + 'id' => 'SampleService', + 'priority' => 1, + ) + ); + // load + $injector->load($config); + + $injector->inject($myObject); + $this->assertEquals('SampleService', get_class($myObject->sampleService)); + } + + /** + * Specific test method to illustrate various ways of setting a requirements backend + */ + public function testRequirementsSettingOptions() { + $injector = new Injector(); + $config = array( + 'OriginalRequirementsBackend', + 'NewRequirementsBackend', + 'DummyRequirements' => array( + 'constructor' => array( + '%$OriginalRequirementsBackend' + ) + ) + ); + + $injector->load($config); + + $requirements = $injector->get('DummyRequirements'); + $this->assertEquals('OriginalRequirementsBackend', get_class($requirements->backend)); + + // just overriding the definition here + $injector->load(array( + 'DummyRequirements' => array( + 'constructor' => array( + '%$NewRequirementsBackend' + ) + ) + )); + + // requirements should have been reinstantiated with the new bean setting + $requirements = $injector->get('DummyRequirements'); + $this->assertEquals('NewRequirementsBackend', get_class($requirements->backend)); + } + + /** + * disabled for now + */ + public function testStaticInjections() { + $injector = new Injector(); + $config = array( + 'NewRequirementsBackend', + ); + + $injector->load($config); + + $si = $injector->get('TestStaticInjections'); + $this->assertEquals('NewRequirementsBackend', get_class($si->backend)); + } + + public function testCustomObjectCreator() { + $injector = new Injector(); + $injector->setObjectCreator(new SSObjectCreator()); + $config = array( + 'OriginalRequirementsBackend', + 'DummyRequirements' => array( + 'class' => 'DummyRequirements(\'%$OriginalRequirementsBackend\')' + ) + ); + $injector->load($config); + + $requirements = $injector->get('DummyRequirements'); + $this->assertEquals('OriginalRequirementsBackend', get_class($requirements->backend)); + } + + public function testInheritedConfig() { + $injector = new Injector(array('locator' => 'SilverStripeServiceConfigurationLocator')); + Config::inst()->update('Injector', 'MyParentClass', array('properties' => array('one' => 'the one'))); + $obj = $injector->get('MyParentClass'); + $this->assertEquals($obj->one, 'the one'); + + $obj = $injector->get('MyChildClass'); + $this->assertEquals($obj->one, 'the one'); + } +} + +class TestObject { + + public $sampleService; + + public function setSomething($v) { + $this->sampleService = $v; + } + +} + +class OtherTestObject { + + private $sampleService; + + public function setSampleService($s) { + $this->sampleService = $s; + } + + public function s() { + return $this->sampleService; + } + +} + +class CircularOne { + + public $circularTwo; + +} + +class CircularTwo { + + public $circularOne; + + public $otherVar; + + public function __construct($value = null) { + $this->otherVar = $value; + } +} + +class NeedsBothCirculars { + + public $circularOne; + public $circularTwo; + public $var; + +} + +class MyParentClass { + public $one; +} + +class MyChildClass extends MyParentClass { + +} + +class DummyRequirements { + + public $backend; + + public function __construct($backend) { + $this->backend = $backend; + } + + public function setBackend($backend) { + $this->backend = $backend; + } + +} + +class OriginalRequirementsBackend { + +} + +class NewRequirementsBackend { + +} + +class TestStaticInjections { + + public $backend; + static $dependencies = array( + 'backend' => '%$NewRequirementsBackend' + ); + +} + +/** + * An example object creator that uses the SilverStripe class(arguments) mechanism for + * creating new objects + * + * @see https://github.com/silverstripe/sapphire + */ +class SSObjectCreator extends InjectionCreator { + + public function create(Injector $injector, $class, $params = array()) { + if (strpos($class, '(') === false) { + return parent::create($injector, $class, $params); + } else { + list($class, $params) = self::parse_class_spec($class); + return parent::create($injector, $class, $params); + } + } + + /** + * Parses a class-spec, such as "Versioned('Stage','Live')", as passed to create_from_string(). + * Returns a 2-elemnent array, with classname and arguments + */ + static function parse_class_spec($classSpec) { + $tokens = token_get_all("constructorVarOne = $v1; + $this->constructorVarTwo = $v2; + } +} \ No newline at end of file