diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 3a95ee5fedb6..4d5af046cbec 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -18,6 +18,8 @@ use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Form\Form; +use Symfony\Component\Lock\Lock; +use Symfony\Component\Lock\Store\SemaphoreStore; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Translation\Translator; use Symfony\Component\Validator\Validation; @@ -129,6 +131,7 @@ public function getConfigTreeBuilder() $this->addCacheSection($rootNode); $this->addPhpErrorsSection($rootNode); $this->addWebLinkSection($rootNode); + $this->addLockSection($rootNode); return $treeBuilder; } @@ -875,6 +878,49 @@ private function addPhpErrorsSection(ArrayNodeDefinition $rootNode) ; } + private function addLockSection(ArrayNodeDefinition $rootNode) + { + $rootNode + ->children() + ->arrayNode('lock') + ->info('Lock configuration') + ->{!class_exists(FullStack::class) && class_exists(Lock::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->beforeNormalization() + ->ifString()->then(function ($v) { return array('enabled' => true, 'resources' => $v); }) + ->end() + ->beforeNormalization() + ->ifTrue(function ($v) { return is_array($v) && !isset($v['resources']); }) + ->then(function ($v) { + $e = $v['enabled']; + unset($v['enabled']); + + return array('enabled' => $e, 'resources' => $v); + }) + ->end() + ->addDefaultsIfNotSet() + ->fixXmlConfig('resource') + ->children() + ->arrayNode('resources') + ->requiresAtLeastOneElement() + ->defaultValue(array('default' => array(class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphore' : 'flock'))) + ->beforeNormalization() + ->ifString()->then(function ($v) { return array('default' => $v); }) + ->end() + ->beforeNormalization() + ->ifTrue(function ($v) { return is_array($v) && array_keys($v) === range(0, count($v) - 1); }) + ->then(function ($v) { return array('default' => $v); }) + ->end() + ->prototype('array') + ->beforeNormalization()->ifString()->then(function ($v) { return array($v); })->end() + ->prototype('scalar')->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } + private function addWebLinkSection(ArrayNodeDefinition $rootNode) { $rootNode diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index ebb594fac159..4c7b4dfe6771 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -38,6 +38,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\EnvVarProcessorInterface; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; use Symfony\Component\DependencyInjection\Exception\LogicException; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\Reference; @@ -54,6 +55,11 @@ use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; use Symfony\Component\HttpKernel\DataCollector\DataCollectorInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\Lock\Factory; +use Symfony\Component\Lock\Lock; +use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Lock\Store\StoreFactory; +use Symfony\Component\Lock\StoreInterface; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; use Symfony\Component\PropertyInfo\PropertyDescriptionExtractorInterface; @@ -298,6 +304,10 @@ public function load(array $configs, ContainerBuilder $container) $this->registerPropertyInfoConfiguration($config['property_info'], $container, $loader); } + if ($this->isConfigEnabled($container, $config['lock'])) { + $this->registerLockConfiguration($config['lock'], $container, $loader); + } + if ($this->isConfigEnabled($container, $config['web_link'])) { if (!class_exists(HttpHeaderSerializer::class)) { throw new LogicException('WebLink support cannot be enabled as the WebLink component is not installed.'); @@ -1672,6 +1682,84 @@ private function registerPropertyInfoConfiguration(array $config, ContainerBuild } } + private function registerLockConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader) + { + $loader->load('lock.xml'); + + foreach ($config['resources'] as $resourceName => $resourceStores) { + if (0 === count($resourceStores)) { + continue; + } + + // Generate stores + $storeDefinitions = array(); + foreach ($resourceStores as $storeDsn) { + $storeDsn = $container->resolveEnvPlaceholders($storeDsn, null, $usedEnvs); + switch (true) { + case 'flock' === $storeDsn: + $storeDefinition = new Reference('lock.store.flock'); + break; + case 'semaphore' === $storeDsn: + $storeDefinition = new Reference('lock.store.semaphore'); + break; + case $usedEnvs || preg_match('#^[a-z]++://#', $storeDsn): + if (!$container->hasDefinition($connectionDefinitionId = $container->hash($storeDsn))) { + $connectionDefinition = new Definition(\stdClass::class); + $connectionDefinition->setPublic(false); + $connectionDefinition->setFactory(array(StoreFactory::class, 'createConnection')); + $connectionDefinition->setArguments(array($storeDsn)); + $container->setDefinition($connectionDefinitionId, $connectionDefinition); + } + + $storeDefinition = new Definition(StoreInterface::class); + $storeDefinition->setPublic(false); + $storeDefinition->setFactory(array(StoreFactory::class, 'createStore')); + $storeDefinition->setArguments(array(new Reference($connectionDefinitionId))); + + $container->setDefinition($storeDefinitionId = 'lock.'.$resourceName.'.store.'.$container->hash($storeDsn), $storeDefinition); + + $storeDefinition = new Reference($storeDefinitionId); + break; + default: + throw new InvalidArgumentException(sprintf('Lock store DSN "%s" is not valid in resource "%s"', $storeDsn, $resourceName)); + } + + $storeDefinitions[] = $storeDefinition; + } + + // Wrap array of stores with CombinedStore + if (count($storeDefinitions) > 1) { + $combinedDefinition = new ChildDefinition('lock.store.combined.abstract'); + $combinedDefinition->replaceArgument(0, $storeDefinitions); + $container->setDefinition('lock.'.$resourceName.'.store', $combinedDefinition); + } else { + $container->setAlias('lock.'.$resourceName.'.store', new Alias((string) $storeDefinitions[0], false)); + } + + // Generate factories for each resource + $factoryDefinition = new ChildDefinition('lock.factory.abstract'); + $factoryDefinition->replaceArgument(0, new Reference('lock.'.$resourceName.'.store')); + $container->setDefinition('lock.'.$resourceName.'.factory', $factoryDefinition); + + // Generate services for lock instances + $lockDefinition = new Definition(Lock::class); + $lockDefinition->setPublic(false); + $lockDefinition->setFactory(array(new Reference('lock.'.$resourceName.'.factory'), 'createLock')); + $lockDefinition->setArguments(array($resourceName)); + $container->setDefinition('lock.'.$resourceName, $lockDefinition); + + // provide alias for default resource + if ('default' === $resourceName) { + $container->setAlias('lock.store', new Alias('lock.'.$resourceName.'.store', false)); + $container->setAlias('lock.factory', new Alias('lock.'.$resourceName.'.factory', false)); + $container->setAlias('lock', new Alias('lock.'.$resourceName, false)); + $container->setAlias(StoreInterface::class, new Alias('lock.store', false)); + $container->setAlias(Factory::class, new Alias('lock.factory', false)); + $container->setAlias(LockInterface::class, new Alias('lock', false)); + } + } + } + private function registerCacheConfiguration(array $config, ContainerBuilder $container) { $version = substr(str_replace('/', '-', base64_encode(hash('sha256', uniqid(mt_rand(), true), true))), 0, 22); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/lock.xml b/src/Symfony/Bundle/FrameworkBundle/Resources/config/lock.xml new file mode 100644 index 000000000000..e4c2231c1c15 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/lock.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index f63f93723029..181dd9334a80 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -29,6 +29,7 @@ + @@ -296,4 +297,19 @@ + + + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index e4e6f0f2a7be..e6e83d40b538 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -16,6 +16,7 @@ use Symfony\Bundle\FullStack; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Processor; +use Symfony\Component\Lock\Store\SemaphoreStore; class ConfigurationTest extends TestCase { @@ -343,6 +344,14 @@ protected static function getBundleDefaultConfig() 'web_link' => array( 'enabled' => !class_exists(FullStack::class), ), + 'lock' => array( + 'enabled' => !class_exists(FullStack::class), + 'resources' => array( + 'default' => array( + class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphore' : 'flock', + ), + ), + ), ); } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/lock.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/lock.xml new file mode 100644 index 000000000000..fc2bf0657a61 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/lock.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/lock_named.xml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/lock_named.xml new file mode 100644 index 000000000000..d36c482de62e --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/xml/lock_named.xml @@ -0,0 +1,22 @@ + + + + + + redis://paas.com + + + + + semaphore + flock + semaphore + flock + %env(REDIS_URL)% + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/lock.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/lock.yml new file mode 100644 index 000000000000..70f578a143a5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/lock.yml @@ -0,0 +1,2 @@ +framework: + lock: ~ diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/lock_named.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/lock_named.yml new file mode 100644 index 000000000000..6d0cb5ca638b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/Fixtures/yml/lock_named.yml @@ -0,0 +1,9 @@ +parameters: + env(REDIS_DSN): redis://paas.com + +framework: + lock: + foo: semaphore + bar: flock + baz: [semaphore, flock] + qux: "%env(REDIS_DSN)%" diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 9b16b9417950..18065e678230 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -54,6 +54,7 @@ "symfony/workflow": "~3.3|~4.0", "symfony/yaml": "~3.2|~4.0", "symfony/property-info": "~3.3|~4.0", + "symfony/lock": "~3.4|~4.0", "symfony/web-link": "~3.3|~4.0", "doctrine/annotations": "~1.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0", diff --git a/src/Symfony/Component/Console/Command/LockableTrait.php b/src/Symfony/Component/Console/Command/LockableTrait.php index b521f3b7708b..308ebf28c045 100644 --- a/src/Symfony/Component/Console/Command/LockableTrait.php +++ b/src/Symfony/Component/Console/Command/LockableTrait.php @@ -46,7 +46,7 @@ private function lock($name = null, $blocking = false) if (SemaphoreStore::isSupported($blocking)) { $store = new SemaphoreStore(); } else { - $store = new FlockStore(sys_get_temp_dir()); + $store = new FlockStore(); } $this->lock = (new Factory($store))->createLock($name ?: $this->getName()); diff --git a/src/Symfony/Component/Console/Tests/Command/LockableTraitTest.php b/src/Symfony/Component/Console/Tests/Command/LockableTraitTest.php index 401ff823a776..a622d1b4895f 100644 --- a/src/Symfony/Component/Console/Tests/Command/LockableTraitTest.php +++ b/src/Symfony/Component/Console/Tests/Command/LockableTraitTest.php @@ -44,7 +44,7 @@ public function testLockReturnsFalseIfAlreadyLockedByAnotherCommand() if (SemaphoreStore::isSupported(false)) { $store = new SemaphoreStore(); } else { - $store = new FlockStore(sys_get_temp_dir()); + $store = new FlockStore(); } $lock = (new Factory($store))->createLock($command->getName()); diff --git a/src/Symfony/Component/Lock/Store/FlockStore.php b/src/Symfony/Component/Lock/Store/FlockStore.php index cd0a276de4a3..5babc7f610bc 100644 --- a/src/Symfony/Component/Lock/Store/FlockStore.php +++ b/src/Symfony/Component/Lock/Store/FlockStore.php @@ -32,12 +32,15 @@ class FlockStore implements StoreInterface private $lockPath; /** - * @param string $lockPath the directory to store the lock + * @param string|null $lockPath the directory to store the lock, defaults to the system's temporary directory * * @throws LockStorageException If the lock directory could not be created or is not writable */ - public function __construct($lockPath) + public function __construct($lockPath = null) { + if (null === $lockPath) { + $lockPath = sys_get_temp_dir(); + } if (!is_dir($lockPath) || !is_writable($lockPath)) { throw new InvalidArgumentException(sprintf('The directory "%s" is not writable.', $lockPath)); } diff --git a/src/Symfony/Component/Lock/Store/MemcachedStore.php b/src/Symfony/Component/Lock/Store/MemcachedStore.php index 8e9db10cd036..4a2ffa3e0204 100644 --- a/src/Symfony/Component/Lock/Store/MemcachedStore.php +++ b/src/Symfony/Component/Lock/Store/MemcachedStore.php @@ -24,6 +24,12 @@ */ class MemcachedStore implements StoreInterface { + private static $defaultClientOptions = array( + 'persistent_id' => null, + 'username' => null, + 'password' => null, + ); + private $memcached; private $initialTtl; /** @var bool */ @@ -52,6 +58,128 @@ public function __construct(\Memcached $memcached, $initialTtl = 300) $this->initialTtl = $initialTtl; } + /** + * Creates a Memcached instance. + * + * By default, the binary protocol, block, and libketama compatible options are enabled. + * + * Example DSN: + * - 'memcached://user:pass@localhost?weight=33' + * - array(array('localhost', 11211, 33)) + * + * @param string $dsn A server or A DSN + * @param array $options An array of options + * + * @return \Memcached + * + * @throws \ErrorEception When invalid options or server are provided + */ + public static function createConnection($server, array $options = array()) + { + if (!static::isSupported()) { + throw new InvalidArgumentException('Memcached extension is required'); + } + set_error_handler(function ($type, $msg, $file, $line) { throw new \ErrorException($msg, 0, $type, $file, $line); }); + try { + $options += static::$defaultClientOptions; + $client = new \Memcached($options['persistent_id']); + $username = $options['username']; + $password = $options['password']; + + // parse any DSN in $server + if (is_string($server)) { + if (0 !== strpos($server, 'memcached://')) { + throw new InvalidArgumentException(sprintf('Invalid Memcached DSN: %s does not start with "memcached://"', $server)); + } + $params = preg_replace_callback('#^memcached://(?:([^@]*+)@)?#', function ($m) use (&$username, &$password) { + if (!empty($m[1])) { + list($username, $password) = explode(':', $m[1], 2) + array(1 => null); + } + + return 'file://'; + }, $server); + if (false === $params = parse_url($params)) { + throw new InvalidArgumentException(sprintf('Invalid Memcached DSN: %s', $server)); + } + if (!isset($params['host']) && !isset($params['path'])) { + throw new InvalidArgumentException(sprintf('Invalid Memcached DSN: %s', $server)); + } + if (isset($params['path']) && preg_match('#/(\d+)$#', $params['path'], $m)) { + $params['weight'] = $m[1]; + $params['path'] = substr($params['path'], 0, -strlen($m[0])); + } + $params += array( + 'host' => isset($params['host']) ? $params['host'] : $params['path'], + 'port' => isset($params['host']) ? 11211 : null, + 'weight' => 0, + ); + if (isset($params['query'])) { + parse_str($params['query'], $query); + $params += $query; + $options = $query + $options; + } + + $server = array($params['host'], $params['port'], $params['weight']); + } + + // set client's options + unset($options['persistent_id'], $options['username'], $options['password'], $options['weight']); + $options = array_change_key_case($options, CASE_UPPER); + $client->setOption(\Memcached::OPT_BINARY_PROTOCOL, true); + $client->setOption(\Memcached::OPT_NO_BLOCK, false); + if (!array_key_exists('LIBKETAMA_COMPATIBLE', $options) && !array_key_exists(\Memcached::OPT_LIBKETAMA_COMPATIBLE, $options)) { + $client->setOption(\Memcached::OPT_LIBKETAMA_COMPATIBLE, true); + } + foreach ($options as $name => $value) { + if (is_int($name)) { + continue; + } + if ('HASH' === $name || 'SERIALIZER' === $name || 'DISTRIBUTION' === $name) { + $value = constant('Memcached::'.$name.'_'.strtoupper($value)); + } + $opt = constant('Memcached::OPT_'.$name); + + unset($options[$name]); + $options[$opt] = $value; + } + $client->setOptions($options); + + // set client's servers, taking care of persistent connections + if (!$client->isPristine()) { + $oldServers = array(); + foreach ($client->getServerList() as $server) { + $oldServers[] = array($server['host'], $server['port']); + } + + $newServers = array(); + if (1 < count($server)) { + $server = array_values($server); + unset($server[2]); + $server[1] = (int) $server[1]; + } + $newServers[] = $server; + + if ($oldServers !== $newServers) { + // before resetting, ensure $servers is valid + $client->addServers(array($server)); + $client->resetServerList(); + } + } + $client->addServers(array($server)); + + if (null !== $username || null !== $password) { + if (!method_exists($client, 'setSaslAuthData')) { + trigger_error('Missing SASL support: the memcached extension must be compiled with --enable-memcached-sasl.'); + } + $client->setSaslAuthData($username, $password); + } + + return $client; + } finally { + restore_error_handler(); + } + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Component/Lock/Store/RedisStore.php b/src/Symfony/Component/Lock/Store/RedisStore.php index 88b15938997c..66a067dfb0f9 100644 --- a/src/Symfony/Component/Lock/Store/RedisStore.php +++ b/src/Symfony/Component/Lock/Store/RedisStore.php @@ -24,6 +24,14 @@ */ class RedisStore implements StoreInterface { + private static $defaultConnectionOptions = array( + 'class' => null, + 'persistent' => 0, + 'persistent_id' => null, + 'timeout' => 30, + 'read_timeout' => 0, + 'retry_interval' => 0, + ); private $redis; private $initialTtl; @@ -45,6 +53,88 @@ public function __construct($redisClient, $initialTtl = 300.0) $this->initialTtl = $initialTtl; } + /** + * Creates a Redis connection using a DSN configuration. + * + * Example DSN: + * - redis://localhost + * - redis://example.com:1234 + * - redis://secret@example.com/13 + * - redis:///var/run/redis.sock + * - redis://secret@/var/run/redis.sock/13 + * + * @param string $dsn + * @param array $options See self::$defaultConnectionOptions + * + * @throws InvalidArgumentException When the DSN is invalid + * + * @return \Redis|\Predis\Client According to the "class" option + */ + public static function createConnection($dsn, array $options = array()) + { + if (0 !== strpos($dsn, 'redis://')) { + throw new InvalidArgumentException(sprintf('Invalid Redis DSN: %s does not start with "redis://"', $dsn)); + } + $params = preg_replace_callback('#^redis://(?:(?:[^:@]*+:)?([^@]*+)@)?#', function ($m) use (&$auth) { + if (isset($m[1])) { + $auth = $m[1]; + } + + return 'file://'; + }, $dsn); + if (false === $params = parse_url($params)) { + throw new InvalidArgumentException(sprintf('Invalid Redis DSN: %s', $dsn)); + } + if (!isset($params['host']) && !isset($params['path'])) { + throw new InvalidArgumentException(sprintf('Invalid Redis DSN: %s', $dsn)); + } + if (isset($params['path']) && preg_match('#/(\d+)$#', $params['path'], $m)) { + $params['dbindex'] = $m[1]; + $params['path'] = substr($params['path'], 0, -strlen($m[0])); + } + $params += array( + 'host' => isset($params['host']) ? $params['host'] : $params['path'], + 'port' => isset($params['host']) ? 6379 : null, + 'dbindex' => 0, + ); + if (isset($params['query'])) { + parse_str($params['query'], $query); + $params += $query; + } + $params += $options + self::$defaultConnectionOptions; + $class = null === $params['class'] ? (extension_loaded('redis') ? \Redis::class : \Predis\Client::class) : $params['class']; + + if (is_a($class, \Redis::class, true)) { + $connect = $params['persistent'] || $params['persistent_id'] ? 'pconnect' : 'connect'; + $redis = new $class(); + @$redis->{$connect}($params['host'], $params['port'], $params['timeout'], $params['persistent_id'], $params['retry_interval']); + + if (@!$redis->isConnected()) { + $e = ($e = error_get_last()) && preg_match('/^Redis::p?connect\(\): (.*)/', $e['message'], $e) ? sprintf(' (%s)', $e[1]) : ''; + throw new InvalidArgumentException(sprintf('Redis connection failed%s: %s', $e, $dsn)); + } + + if ((null !== $auth && !$redis->auth($auth)) + || ($params['dbindex'] && !$redis->select($params['dbindex'])) + || ($params['read_timeout'] && !$redis->setOption(\Redis::OPT_READ_TIMEOUT, $params['read_timeout'])) + ) { + $e = preg_replace('/^ERR /', '', $redis->getLastError()); + throw new InvalidArgumentException(sprintf('Redis connection failed (%s): %s', $e, $dsn)); + } + } elseif (is_a($class, \Predis\Client::class, true)) { + $params['scheme'] = isset($params['host']) ? 'tcp' : 'unix'; + $params['database'] = $params['dbindex'] ?: null; + $params['password'] = $auth; + $redis = new $class((new Factory())->create($params)); + } elseif (class_exists($class, false)) { + throw new InvalidArgumentException(sprintf('"%s" is not a subclass of "Redis" or "Predis\Client"', $class)); + } else { + throw new InvalidArgumentException(sprintf('Class "%s" does not exist', $class)); + } + + return $redis; + } + /** * {@inheritdoc} */ diff --git a/src/Symfony/Component/Lock/Store/StoreFactory.php b/src/Symfony/Component/Lock/Store/StoreFactory.php new file mode 100644 index 000000000000..9a23cf6472de --- /dev/null +++ b/src/Symfony/Component/Lock/Store/StoreFactory.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Lock\Store; + +use Symfony\Component\Lock\Exception\InvalidArgumentException; + +/** + * StoreFactory create stores and connections. + * + * @author Jérémy Derussé + */ +class StoreFactory +{ + public static function createConnection($dsn, array $options = array()) + { + if (!is_string($dsn)) { + throw new InvalidArgumentException(sprintf('The %s() method expects argument #1 to be string, %s given.', __METHOD__, gettype($dsn))); + } + if (0 === strpos($dsn, 'redis://')) { + return RedisStore::createConnection($dsn, $options); + } + if (0 === strpos($dsn, 'memcached://')) { + return MemcachedStore::createConnection($dsn, $options); + } + + throw new InvalidArgumentException(sprintf('Unsupported DSN: %s.', $dsn)); + } + + /** + * @param \Redis|\RedisArray|\RedisCluster|\Predis\Client|\Memcached $connection + * + * @return RedisStore|MemcachedStore + */ + public static function createStore($connection) + { + if ($connection instanceof \Redis || $connection instanceof \RedisArray || $connection instanceof \RedisCluster || $connection instanceof \Predis\Client) { + return new RedisStore($connection); + } + if ($connection instanceof \Memcached) { + return new MemcachedStore($connection); + } + + throw new InvalidArgumentException(sprintf('Unsupported Connection: %s.', get_class($connection))); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php index 53d2ae78dc78..ef3650c3124b 100644 --- a/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php @@ -26,7 +26,7 @@ class FlockStoreTest extends AbstractStoreTest */ protected function getStore() { - return new FlockStore(sys_get_temp_dir()); + return new FlockStore(); } /** diff --git a/src/Symfony/Component/Lock/Tests/Store/MemcachedStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/MemcachedStoreTest.php index eb030fba0f9d..cfe03b25e2c3 100644 --- a/src/Symfony/Component/Lock/Tests/Store/MemcachedStoreTest.php +++ b/src/Symfony/Component/Lock/Tests/Store/MemcachedStoreTest.php @@ -54,4 +54,100 @@ public function testAbortAfterExpiration() { $this->markTestSkipped('Memcached expects a TTL greater than 1 sec. Simulating a slow network is too hard'); } + + public function testDefaultOptions() + { + $this->assertTrue(MemcachedStore::isSupported()); + + $client = MemcachedStore::createConnection('memcached://127.0.0.1'); + + $this->assertTrue($client->getOption(\Memcached::OPT_COMPRESSION)); + $this->assertSame(1, $client->getOption(\Memcached::OPT_BINARY_PROTOCOL)); + $this->assertSame(1, $client->getOption(\Memcached::OPT_LIBKETAMA_COMPATIBLE)); + } + + /** + * @dataProvider provideServersSetting + */ + public function testServersSetting($dsn, $host, $port) + { + $client1 = MemcachedStore::createConnection($dsn); + $client3 = MemcachedStore::createConnection(array($host, $port)); + $expect = array( + 'host' => $host, + 'port' => $port, + ); + + $f = function ($s) { return array('host' => $s['host'], 'port' => $s['port']); }; + $this->assertSame(array($expect), array_map($f, $client1->getServerList())); + $this->assertSame(array($expect), array_map($f, $client3->getServerList())); + } + + public function provideServersSetting() + { + yield array( + 'memcached://127.0.0.1/50', + '127.0.0.1', + 11211, + ); + yield array( + 'memcached://localhost:11222?weight=25', + 'localhost', + 11222, + ); + if (ini_get('memcached.use_sasl')) { + yield array( + 'memcached://user:password@127.0.0.1?weight=50', + '127.0.0.1', + 11211, + ); + } + yield array( + 'memcached:///var/run/memcached.sock?weight=25', + '/var/run/memcached.sock', + 0, + ); + yield array( + 'memcached:///var/local/run/memcached.socket?weight=25', + '/var/local/run/memcached.socket', + 0, + ); + if (ini_get('memcached.use_sasl')) { + yield array( + 'memcached://user:password@/var/local/run/memcached.socket?weight=25', + '/var/local/run/memcached.socket', + 0, + ); + } + } + + /** + * @dataProvider provideDsnWithOptions + */ + public function testDsnWithOptions($dsn, array $options, array $expectedOptions) + { + $client = MemcachedStore::createConnection($dsn, $options); + + foreach ($expectedOptions as $option => $expect) { + $this->assertSame($expect, $client->getOption($option)); + } + } + + public function provideDsnWithOptions() + { + if (!class_exists('\Memcached')) { + self::markTestSkipped('Extension memcached required.'); + } + + yield array( + 'memcached://localhost:11222?retry_timeout=10', + array(\Memcached::OPT_RETRY_TIMEOUT => 8), + array(\Memcached::OPT_RETRY_TIMEOUT => 10), + ); + yield array( + 'memcached://localhost:11222?socket_recv_size=1&socket_send_size=2', + array(\Memcached::OPT_RETRY_TIMEOUT => 8), + array(\Memcached::OPT_SOCKET_RECV_SIZE => 1, \Memcached::OPT_SOCKET_SEND_SIZE => 2, \Memcached::OPT_RETRY_TIMEOUT => 8), + ); + } }