Skip to content

Instantly share code, notes, and snippets.

@mindplay-dk
Created July 26, 2012 16:47
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mindplay-dk/3183156 to your computer and use it in GitHub Desktop.
Save mindplay-dk/3183156 to your computer and use it in GitHub Desktop.
strongly-typed configuration-container
<?php
/**
* Configuration Container
* =======================
*
* @author Rasmus Schultz <rasmus@mindplay.dk>
*
* This is an experimental configuration-container for PHP 5.3, attempting to
* solve a number of problems with configuration in general.
*
* Configuration containers in most frameworks are either built into a central
* application-object or array, which typically means that your IDE has no
* awareness of which named objects/values are available in the container, nor
* what type they are. By starting with an abstract configuration-container
* and requiring you to extend it and document the names/types, you configuration
* object can provide IDE-support, and the container can perform type-checking.
*
* Other configuration containers frequently rely on multi-level nested arrays
* as a means of providing late construction - since arrays receive no IDE-support,
* this container instead relies on anonymous functions for late construction.
*
* When configuration happens in layers (e.g. multiple configuration-files for
* different environments) other containers typically merge multi-level nested
* arrays recursively; this container instead allows you to further configure
* a named object/value by using anonymous functions with typed arguments, which
* also provides IDE-support.
*
* Other containes in general are "open", in the sense that you can overwrite
* named objects/values after loading the configuration - and they also don't
* generally care if the configuration is complete or correct. This container
* requires a complete configuration as defined by your property-annotations,
* and requires you to seal the configuration-container once fully populated -
* making it read-only and performing a check for completeness. (If you have
* optional components, you must explicitly set these to null.)
*
* Some containers provide an option to toggle early/late loading - this container
* simply expects you to construct objects that should load early, at the time
* of configuration.
*
* This container also addresses the issue of co-dependency between the configured
* components in the container. For example, let's say two different components
* depend on a configured cache-component - with some configuration-containers, the
* other components need to go back to the configuration-container and obtain the
* cache-component by name; instead, the container lets you define dependencies
* by simply adding parameters to configuration-closures, causing a dependent
* component to automatically initialize when needed somewhere else.
*
* Finally, note that the configuration-container itself is made available in the
* container using the property-name 'config' - this enables you to inject the
* configuration-container itself into late-construction and configuration functions.
*/
namespace mindplay\config;
use Closure;
use Exception;
use ReflectionClass;
use ReflectionProperty;
use ReflectionFunction;
/**
* General exception thrown by the Configuration class on error.
*/
class ConfigurationException extends Exception
{}
/**
* Abstract base-class for a configuration-container.
*
* @property-read Configuration $config self-reference to this configuration-container.
*/
abstract class Configuration
{
/**
* Regular expression used by the constructor to parse @property-annotations
*/
const PROPERTY_PATTERN = '/^\s*\*+\s*\@property(?:\-read|\-write|)\s+([\w\\\\]+)\s+\$(\w+)/im';
/**
* Regular expression used to determine if a path is absolute.
*
* @see load()
*/
const ABS_PATH_PATTERN = '/^(?:\/|\\|\w\:\\).*$/';
/**
* @var array map of property-names to type-names (parsed from PHP-DOC block)
*/
private $_types = array();
/**
* @var array map of property-names to intermediary (un-initialized) objects, closures or values
*/
private $_container = array();
/**
* @var array map of property-names to late configuration-functions
*/
private $_config = array();
/**
* @var array map of property-names to initialized objects or values
*/
private $_objects = array();
/**
* @var bool true if the configuration-container has been sealed
*/
private $_sealed = false;
/**
* @var string the root-path of configuration-files (with trailing directory-separator)
*/
private $_rootPath;
/**
* Map of methods to use for type-checking known PHP pseudo-types.
*
* @see http://www.phpdoc.org/docs/latest/for-users/types.html
*/
public static $checkers = array(
'string' => 'is_string',
'integer' => 'is_int',
'int' => 'is_int',
'boolean' => 'is_bool',
'bool' => 'is_bool',
'float' => 'is_float',
'double' => 'is_float',
'object' => 'is_object',
'array' => 'is_array',
'resource' => 'is_resource',
'null' => 'is_null',
'callback' => 'is_callable',
);
/**
* Initializes the configuration-container by parsing @property-annotations of the concrete class.
*
* @param string $rootPath the root-path of configuration-files, which can be loaded using {@see load()}
*/
public function __construct($rootPath=null)
{
// parse @property-annotations for property-names and types:
$class = new ReflectionClass(get_class($this));
if (preg_match_all(self::PROPERTY_PATTERN, $class->getDocComment(), $matches) === 0) {
throw new ConfigurationException('class '.get_class($this).' has no @property-annotations');
}
for ($i=0; $i<count($matches[0]); $i++) {
$type = $matches[1][$i];
$name = $matches[2][$i];
$this->_types[$name] = $type;
}
// make this Configuration-instance available for parameter-binding:
$this->_types['config'] = get_class($this);
$this->_objects['config'] = $this;
// configure root-path:
$this->_rootPath = rtrim($rootPath === null ? getcwd() : $rootPath, '/\\') . DIRECTORY_SEPARATOR;
}
/**
* Add a configuration-function to the container - the function will be called the first
* time a configured property is accessed. Configuration-functions are called in the
* order in which they were added.
*
* @param mixed $config a single configuration-function (a Closure) or an array of functions
*/
public function configure($config)
{
if ($this->_sealed === true) {
throw new ConfigurationException('attempted configuration of sealed configuration-container');
}
if (!is_array($config)) {
$config = array($config);
}
foreach ($config as $index => $function) {
if (($function instanceof Closure) === false) {
throw new ConfigurationException('parameter #' . $index . ' is not a Closure');
}
// obtain the first parameter-name:
$fn = new ReflectionFunction($function);
$params = $fn->getParameters();
if (count($params) < 1) {
throw new ConfigurationException('configuration-functions must have at least one parameter');
}
$name = $params[0]->getName();
if (!isset($this->_types[$name])) {
throw new ConfigurationException('undefined property: $'.$name.' (all properties must be defined using @property-annotations.)');
}
// add the configuration-function:
if (!array_key_exists($name, $this->_config)) {
$this->_config[$name] = array();
}
$this->_config[$name][] = $function;
}
}
/**
* Load a configuration-file.
*
* (a configuration-file is simply a php-script running in an isolated function-context.)
*
* @param string $path either an absolute path to the configuration-file, or relative to {@see $rootPath}
*/
public function load($path)
{
if (preg_match(self::ABS_PATH_PATTERN, $path) === 0) {
$path = $this->_rootPath . $path;
}
require $path;
}
/**
* Seals the configuration-container, preventing any further changes, and checks
* to make sure that the container is fully configured.
*/
public function seal()
{
foreach ($this->_config as $name => $config) {
if (!array_key_exists($name, $this->_types)) {
throw new ConfigurationException('attempted configuration of undefined component: '.$name);
}
}
$this->_sealed = true;
}
/**
* Injects values from this container into the given object - optionally, you can
* have values injected into protected properties, but by default, only public
* properties are populated; private properties are never injected.
*
* @param object $object the object to populate using values from this container.
* @param bool $protected if true, protected properties will also be injected.
*/
public function inject($object, $protected=false)
{
$class = new ReflectionClass(get_class($object));
do {
foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED) as $property) {
if (!$this->__isset($property->getName())) {
continue; // no value with that name exists in this container
}
if ($property->isProtected()) {
if ($protected === true) {
$property->setAccessible(true);
} else {
continue; // skip protected property
}
}
$property->setValue($object, $this->__get($property->getName()));
}
$class = $class->getParentClass();
} while ($class !== false);
}
/**
* Checks that the specified value conforms to the type defined by the @property-annotations
* of the concrete configuration-class.
*/
protected function checkType($name, $value)
{
$type = $this->_types[$name];
if (strcasecmp($type, 'mixed') === 0) {
return; // any type is allowed
}
if (array_key_exists($type, self::$checkers) === true) {
// check a known PHP pseudo-type:
if (call_user_func(self::$checkers[$type], $value) === false) {
throw new ConfigurationException('property-type mismatch - property $'.$name.' was defined as: '.$type);
}
} else {
// check a class or interface type:
if (($value instanceof $type) === false) {
throw new ConfigurationException('property-type mismatch - property $'.$name.' was defined as: '.$type);
}
}
}
/**
* Invokes a Closure, automatically filling in any missing parameters using configured properties.
*
* @param Closure $closure the Closure to invoke
* @param array $params parameters that have already been determined; optional
* @return mixed the return-value from the invoked Closure
*/
private function _invoke(Closure $closure, $params=array())
{
$fn = new ReflectionFunction($closure);
foreach ($fn->getParameters() as $index => $param) {
if (!array_key_exists($index, $params)) {
$params[$index] = $this->__get($param->getName());
}
}
return call_user_func_array($closure, $params);
}
/**
* @internal write-accessor for configuration-properties
*/
public function __set($name, $value)
{
if ($this->_sealed === true) {
throw new ConfigurationException('attempted access to sealed configuration container');
}
if (array_key_exists($name, $this->_container)) {
throw new ConfigurationException('attempted overwrite of property: $'.$name);
}
if (!isset($this->_types[$name])) {
throw new ConfigurationException('undefined configuration property $'.$name);
}
if (($value instanceof Closure) === false) {
$this->checkType($name, $value);
}
$this->_container[$name] = $value;
}
/**
* @internal read-accessor for configuration-properties
*/
public function __get($name)
{
if ($this->_sealed === false) {
throw new ConfigurationException('configuration container must be sealed before properties can be read');
}
if (!array_key_exists($name, $this->_objects)) {
// first use - initialize the property:
if (!array_key_exists($name, $this->_container)) {
throw new ConfigurationException('undefined configuration property: $'.$name);
}
$object = $this->_container[$name];
if ($object instanceof Closure) {
// "unwrap" an object created by a late-construction Closure:
$object = $this->_invoke($object);
}
$this->checkType($name, $object);
// apply any configuration-functions:
if (array_key_exists($name, $this->_config)) {
foreach ($this->_config[$name] as $config) {
$this->_invoke($config, array($object));
}
}
// store the initialized object:
$this->_objects[$name] = $object;
// destroy the late-construction and/or configuration functions:
unset($this->_config[$name], $this->_container[$name]);
}
return $this->_objects[$name];
}
/**
* @internal isset() overloading for properties
*/
public function __isset($name)
{
return array_key_exists($name, $this->_objects) || array_key_exists($name, $this->_container);
}
}
// ===== EXAMPLE =====
header('Content-type: text/plain');
/**
* A simple class for testing
*/
class Greeter
{
public $name; // this will be set explicitly when the Greeter is constructed
public $day; // this will be injected by the late-construction function
public $mood; // this will be injected by the configuration function
public function sayHello()
{
echo "Hello, {$this->name}! Today is {$this->day} and I'm feeling {$this->mood}!";
}
}
/**
* This class implements my application-specific configuration.
*
* The @property-annotations below will be parsed and used to configure the container.
*
* @property mindplay\config\Greeter $greeter a sample configured object
* @property int $time the time of day
* @property string $mood my current mood
*/
class MyConfig extends Configuration
{}
$config = new MyConfig;
// use an anonymous closure to configure a property for late construction:
$config->greeter = function($time) {
// the $time configuration-value is automatically injected
$g = new Greeter;
$g->day = date('l', $time);
return $g;
};
// property-types must match those defined in the @property-annotations above:
$config->time = time(); // ... setting this to a string would cause an exception
$config->mood = 'happy';
// properties using late construction can be configured using a callback-method:
$config->configure(function(Greeter $greeter, $mood) {
// the parameter-name (greeter) defines the property to be configured
// the $mood configuration-value is automatically injected
$greeter->name = 'World';
$greeter->mood = $mood;
});
// configuration container must be sealed before properties can be read:
$config->seal();
$config->greeter->sayHello();
// we can also inject values into public object properties:
class Foo
{
public $time;
}
class Bar extends Foo
{
private $mood='unaffected';
public function getMood()
{
return $this->mood;
}
}
$foo = new Bar();
$config->inject($foo);
echo "\ninjected time: {$foo->time}";
echo "\nmood: ".$foo->getMood();
@mindplay-dk
Copy link
Author

This has been superseded by:

https://github.com/mindplay-dk/stockpile

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment