diff --git a/docs/README.md b/docs/README.md index 8cf70137..0869fe4d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -295,6 +295,33 @@ framework: provider: snc_redis.cache ``` +### RedisArray Support ### + +The bundle supports RedisArray for phpredis clients, allowing data distribution across multiple Redis instances using the same client class as single connections. + +Example configuration: + +```yaml +snc_redis: + clients: + default: + type: phpredis + alias: default + dsns: + - redis://localhost:6379 + - redis://localhost:6380 + options: + connection_timeout: 5 + retry_interval: 100 + parameters: + database: 0 + password: mypassword + redis_array: true +``` + +This configuration creates a RedisArray client for multiple Redis instances. + + ### Complete configuration example ### ``` yaml diff --git a/src/DependencyInjection/Configuration/Configuration.php b/src/DependencyInjection/Configuration/Configuration.php index b1cd9dfb..9ed3c2ae 100644 --- a/src/DependencyInjection/Configuration/Configuration.php +++ b/src/DependencyInjection/Configuration/Configuration.php @@ -121,6 +121,7 @@ private function addClientsSection(ArrayNodeDefinition $rootNode): void ->arrayNode('options') ->addDefaultsIfNotSet() ->children() + ->booleanNode('redis_array')->defaultFalse()->end() ->arrayNode('commands') ->useAttributeAsKey('name') ->scalarPrototype()->end() diff --git a/src/DependencyInjection/SncRedisExtension.php b/src/DependencyInjection/SncRedisExtension.php index afca09e5..2b3cd4c6 100644 --- a/src/DependencyInjection/SncRedisExtension.php +++ b/src/DependencyInjection/SncRedisExtension.php @@ -93,7 +93,8 @@ public function getXsdValidationBasePath(): string /** @param array{dsns: array, type: string} $client */ private function loadClient(array $client, ContainerBuilder $container): void { - $dsnResolver = static function ($dsn) use ($container) { + $dsnResolver = /** @return RedisDsn|RedisEnvDsn */ + static function ($dsn) use ($container) { $usedEnvs = null; $container->resolveEnvPlaceholders($dsn, null, $usedEnvs); @@ -227,12 +228,13 @@ private function loadPredisConnectionParameters(string $clientAlias, array $opti /** @param mixed[] $options A client configuration */ private function loadPhpredisClient(array $options, ContainerBuilder $container): void { - $connectionCount = count($options['dsns']); - $hasClusterOption = $options['options']['cluster'] !== null; - $hasSentinelOption = isset($options['options']['replication']); + $connectionCount = count($options['dsns']); + $hasClusterOption = $options['options']['cluster'] !== null; + $hasSentinelOption = isset($options['options']['replication']); + $hasRedisArrayOption = $options['options']['redis_array'] ?? false; - if ($connectionCount > 1 && !$hasClusterOption && !$hasSentinelOption) { - throw new LogicException('Use options "cluster" or "sentinel" to enable support for multi DSN instances.'); + if ($connectionCount > 1 && !$hasClusterOption && !$hasSentinelOption && !$hasRedisArrayOption) { + throw new LogicException('Use options "cluster", "replication", or "redis_array" to enable support for multi DSN instances.'); } if ($hasClusterOption && $hasSentinelOption) { diff --git a/src/Factory/PhpredisClientFactory.php b/src/Factory/PhpredisClientFactory.php index a245479e..bb42d820 100644 --- a/src/Factory/PhpredisClientFactory.php +++ b/src/Factory/PhpredisClientFactory.php @@ -10,6 +10,7 @@ use ProxyManager\Factory\AccessInterceptorValueHolderFactory; use ProxyManager\Proxy\AccessInterceptorInterface; use Redis; +use RedisArray; use RedisCluster; use RedisException; use RedisSentinel; @@ -75,7 +76,7 @@ public function __construct(callable $interceptor, ?Configuration $proxyConfigur * @param list> $dsns Multiple DSN string * @param mixed[] $options Options provided in bundle client config * - * @return Redis|RedisCluster|Relay + * @return Redis|RedisArray|RedisCluster|Relay * * @throws InvalidConfigurationException * @throws LogicException @@ -96,6 +97,18 @@ public function create(string $class, array $dsns, array $options, string $alias $parsedDsns = array_map(static fn (string $dsn) => new RedisDsn($dsn), $dsns); + if ($options['redis_array'] ?? false) { + if ($isSentinel || $isCluster) { + throw new LogicException('The redis_array option is only supported for Redis or Relay classes.'); + } + + if ($options['cluster'] || $options['replication']) { + throw new LogicException('The redis_array option cannot be combined with cluster or replication options.'); + } + + return $this->createRedisArrayClient($parsedDsns, $class, $alias, $options, $loggingEnabled); + } + if ($isRedis || $isRelay) { if (count($parsedDsns) > 1) { throw new LogicException('Cannot have more than 1 dsn with \Redis and \RedisArray is not supported yet.'); @@ -111,6 +124,66 @@ public function create(string $class, array $dsns, array $options, string $alias return $this->createClusterClient($parsedDsns, $class, $alias, $options, $loggingEnabled); } + /** + * @param RedisDsn[] $dsns + * @param mixed[] $options + * + * @return Redis|RedisArray|RedisCluster|Relay + */ + private function createRedisArrayClient(array $dsns, string $class, string $alias, array $options, bool $loggingEnabled) + { + if (count($dsns) < 2) { + throw new LogicException('The redis_array option requires at least two DSNs.'); + } + + $hosts = array_values(array_map( + static fn (RedisDsn $dsn) => ($dsn->getTls() ? 'tls://' : '') . $dsn->getHost() . ':' . $dsn->getPort(), + $dsns, + )); + + $redisArrayOptions = [ + 'connect_timeout' => (float) ($options['connection_timeout'] ?? 5), + 'retry_interval' => (int) ($options['retry_interval'] ?? 100), + ]; + + $username = $options['parameters']['username'] ?? null; + $password = $options['parameters']['password'] ?? null; + if ($username !== null && $password !== null) { + $redisArrayOptions['auth'] = [$username, $password]; + } elseif ($password !== null) { + $redisArrayOptions['auth'] = $password; + } + + try { + /** @var array $hostsList */ + $hostsList = array_map('strval', $hosts); + /** @psalm-suppress InvalidArgument */ + $client = new RedisArray($hostsList, $redisArrayOptions); + } catch (RedisException $e) { + throw new RedisException(sprintf('Failed to create RedisArray with hosts %s: %s', implode(', ', $hosts), $e->getMessage()), 0, $e); + } + + $connectedHosts = $client->getHosts(); + if (empty($connectedHosts)) { + throw new LogicException(sprintf('RedisArray failed to connect to any hosts: %s', implode(', ', $hosts))); + } + + $db = $options['parameters']['database'] ?? null; + if ($db !== null && $db !== '') { + $client->select($db); + } + + if (isset($options['prefix'])) { + $client->setOption(Redis::OPT_PREFIX, $options['prefix']); + } + + if (isset($options['serialization'])) { + $client->setOption(Redis::OPT_SERIALIZER, $this->loadSerializationType($options['serialization'])); + } + + return $loggingEnabled ? $this->createLoggingProxy($client, $alias) : $client; + } + /** * @param class-string $class * @param list $dsns @@ -356,7 +429,7 @@ private function loadSlaveFailoverType(string $type): int * * @return T * - * @template T of Redis|Relay|RedisCluster + * @template T of Redis|RedisArray|RedisCluster|Relay */ private function createLoggingProxy(object $client, string $alias): object { diff --git a/src/Resources/config/schema/redis-1.0.xsd b/src/Resources/config/schema/redis-1.0.xsd index 746b7cc2..4fa9a7d2 100644 --- a/src/Resources/config/schema/redis-1.0.xsd +++ b/src/Resources/config/schema/redis-1.0.xsd @@ -55,6 +55,7 @@ + diff --git a/tests/DependencyInjection/SncRedisExtensionEnvTest.php b/tests/DependencyInjection/SncRedisExtensionEnvTest.php index 604a88df..6c2fe37e 100644 --- a/tests/DependencyInjection/SncRedisExtensionEnvTest.php +++ b/tests/DependencyInjection/SncRedisExtensionEnvTest.php @@ -4,7 +4,6 @@ namespace Snc\RedisBundle\Tests\DependencyInjection; -use LogicException; use PHPUnit\Framework\TestCase; use Predis\Client; use Redis; @@ -59,6 +58,7 @@ public function testPredisDefaultParameterConfig(): void 'logging' => false, ], 'commands' => ['foo' => 'Foo\Bar\Baz'], + 'redis_array' => false, 'scan' => null, 'read_write_timeout' => null, 'iterable_multibulk' => false, @@ -96,6 +96,7 @@ public function testPhpredisDefaultParameterConfig(string $config, string $class $this->assertSame( [ + 'redis_array' => false, 'connection_async' => false, 'connection_persistent' => false, 'connection_timeout' => 5, @@ -141,7 +142,9 @@ public function testPhpredisFullConfig(): void 'sentinel_username' => null, 'sentinel_password' => null, 'logging' => false, + ], + 'redis_array' => false, 'connection_async' => false, 'read_write_timeout' => null, 'iterable_multibulk' => false, @@ -187,6 +190,7 @@ public function testPhpredisWithAclConfig(): void 'serialization' => 'php', 'service' => null, 'throw_errors' => true, + 'redis_array' => false, ], $clientDefinition->getArgument(2), ); @@ -222,6 +226,7 @@ public function testPhpRedisClusterOption(): void $this->assertSame( [ 'cluster' => true, + 'redis_array' => false, 'connection_async' => false, 'connection_persistent' => false, 'connection_timeout' => 5, @@ -252,6 +257,7 @@ public function testPhpRedisSentinelOption(): void [ 'replication' => 'sentinel', 'service' => 'mymaster', + 'redis_array' => false, 'connection_async' => false, 'connection_persistent' => false, 'connection_timeout' => 5, @@ -285,6 +291,7 @@ public function testPhpRedisClusterOptionMultipleDsn(): void 'read_write_timeout' => 1.5, 'connection_timeout' => 1.5, 'connection_persistent' => true, + 'redis_array' => false, 'connection_async' => false, 'scan' => null, 'iterable_multibulk' => false, @@ -297,14 +304,6 @@ public function testPhpRedisClusterOptionMultipleDsn(): void ); } - public function testPhpRedisArrayIsNotSupported(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Use options "cluster" or "sentinel" to enable support for multi DSN instances.'); - - $this->getConfiguredContainer('env_phpredis_array_not_supported'); - } - private function getConfiguredContainer(string $file): ContainerBuilder { $container = new ContainerBuilder(); diff --git a/tests/Factory/PhpredisClientFactoryTest.php b/tests/Factory/PhpredisClientFactoryTest.php index 79daf442..c990c260 100644 --- a/tests/Factory/PhpredisClientFactoryTest.php +++ b/tests/Factory/PhpredisClientFactoryTest.php @@ -4,18 +4,23 @@ namespace Snc\RedisBundle\Tests\Factory; +use LogicException; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Redis; +use RedisArray; use RedisCluster; +use RedisSentinel; use Relay\Relay; use SEEC\PhpUnit\Helper\ConsecutiveParams; +use Snc\RedisBundle\DependencyInjection\Configuration\RedisDsn; use Snc\RedisBundle\Factory\PhpredisClientFactory; use Snc\RedisBundle\Logger\RedisCallInterceptor; use Snc\RedisBundle\Logger\RedisLogger; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use function array_map; use function class_exists; use function fsockopen; use function phpversion; @@ -470,4 +475,93 @@ public function testCreateWithConnectionPersistentFalse(): void $this->assertInstanceOf(Redis::class, $client); $this->assertNull($client->getPersistentID()); } + + public function testCreateRedisArrayClient(): void + { + $dsns = [ + 'redis://localhost:6379', + 'redis://localhost:6380', + ]; + $options = [ + 'redis_array' => true, + 'connection_timeout' => 5, + 'retry_interval' => 100, + 'parameters' => ['database' => 0, 'password' => 'mypassword'], + ]; + $factory = new PhpredisClientFactory(static function (): void { + }, null); + $client = $factory->create( + Redis::class, + array_map('strval', $dsns), + $options, + 'default', + false, + ); + + $this->assertInstanceOf(RedisArray::class, $client); + $this->assertEquals(['localhost:6379', 'localhost:6380'], $client->getHosts()); + } + + public function testThrowsExceptionForMultipleDsnsWithoutValidOption(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot have more than 1 dsn with \Redis and \RedisArray is not supported yet.'); + + $dsns = [ + new RedisDsn('redis://localhost:6379'), + new RedisDsn('redis://localhost:6380'), + ]; + $options = []; + $factory = new PhpredisClientFactory(static function (): void { + }, null); + $factory->create( + Redis::class, + array_map('strval', $dsns), + $options, + 'default', + false, + ); + } + + public function testThrowsExceptionForRedisArrayWithClusterOrReplication(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The redis_array option cannot be combined with cluster or replication options.'); + + $dsns = [ + 'redis://localhost:6379', + 'redis://localhost:6380', + ]; + $options = ['redis_array' => true, 'cluster' => true]; + $factory = new PhpredisClientFactory(static function (): void { + }, null); + $factory->create( + Redis::class, + array_map('strval', $dsns), + $options, + 'default', + false, + ); + } + + public function testThrowsExceptionForRedisArrayWithSentinel(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The redis_array option is only supported for Redis or Relay classes.'); + + $dsns = [ + new RedisDsn('redis://localhost:6379'), + new RedisDsn('redis://localhost:6380'), + ]; + $options = ['redis_array' => true]; + $factory = new PhpredisClientFactory(static function (): void { + }, null); + $factory->create( + RedisSentinel::class, + array_map('strval', $dsns), + $options, + 'default', + false, + ); + } } diff --git a/tests/Functional/App/config.yaml b/tests/Functional/App/config.yaml index 43cab850..f018b02b 100644 --- a/tests/Functional/App/config.yaml +++ b/tests/Functional/App/config.yaml @@ -19,8 +19,12 @@ snc_redis: default: type: phpredis alias: default - dsn: redis://sncredis@localhost + dsns: + - redis://sncredis@localhost/1?role=master + - redis://sncredis@localhost/2 logging: '%kernel.debug%' + options: + redis_array: true cache: type: predis alias: cache