diff --git a/README.md b/README.md index c51d7ac..9209b22 100755 --- a/README.md +++ b/README.md @@ -379,6 +379,8 @@ $size = $stream->getSize(); use FastD\Http\Session\SessionMiddleware; use FastD\Http\Session\Handler\NativeFileSessionHandler; use FastD\Http\Session\Handler\SwooleTableSessionHandler; +use FastD\Http\Session\Handler\RedisSessionHandler; +use FastD\Http\Session\Handler\PhpRedisSessionClient; // CGI 运行时:文件存储 $middleware = new SessionMiddleware(new NativeFileSessionHandler()); @@ -386,6 +388,13 @@ $middleware = new SessionMiddleware(new NativeFileSessionHandler()); // Swoole 运行时:共享内存存储(TTL 秒) $middleware = new SessionMiddleware(new SwooleTableSessionHandler(1440, 4096)); +// 多实例 / 需要跨节点共享:Redis 存储(TTL 秒) +$redis = new \Redis(); +$redis->connect('127.0.0.1', 6379); +$middleware = new SessionMiddleware( + new RedisSessionHandler(new PhpRedisSessionClient($redis), 1440, 'fastd:session:') +); + // 将 $middleware 注册进 PSR-15 管道后,即可在后续处理器中读取会话 $session = $request->getAttribute('session'); diff --git a/composer.json b/composer.json index e9ff0d0..98de2f5 100755 --- a/composer.json +++ b/composer.json @@ -32,7 +32,8 @@ "phpstan/phpstan": "^2.3" }, "suggest": { - "ext-swoole": "Required to use the SwooleTableSessionHandler in a Swoole >= 6.0 runtime" + "ext-swoole": "Required to use the SwooleTableSessionHandler in a Swoole >= 6.0 runtime", + "ext-redis": "Required to use RedisSessionHandler with PhpRedisSessionClient" }, "autoload": { "psr-4": { diff --git a/src/Session/Handler/PhpRedisSessionClient.php b/src/Session/Handler/PhpRedisSessionClient.php new file mode 100644 index 0000000..4c32140 --- /dev/null +++ b/src/Session/Handler/PhpRedisSessionClient.php @@ -0,0 +1,40 @@ +redis->get($key); + + return is_string($value) || $value === false ? $value : false; + } + + public function setex(string $key, int $ttl, string $value): bool + { + return (bool) $this->redis->setex($key, $ttl, $value); + } + + public function del(string $key): int + { + return (int) $this->redis->del($key); + } + + public function exists(string $key): int + { + return (int) $this->redis->exists($key); + } +} diff --git a/src/Session/Handler/RedisClientInterface.php b/src/Session/Handler/RedisClientInterface.php new file mode 100644 index 0000000..1f38e8c --- /dev/null +++ b/src/Session/Handler/RedisClientInterface.php @@ -0,0 +1,21 @@ +client->get($this->key($sessionId)); + + if ($content === false) { + return []; + } + + $data = json_decode($content, true); + + return is_array($data) ? $data : []; + } + + public function write(string $sessionId, array $data): bool + { + $content = json_encode($data, JSON_UNESCAPED_UNICODE); + + if ($content === false || strlen($content) > $this->maxDataSize) { + return false; + } + + return $this->client->setex($this->key($sessionId), $this->ttl, $content); + } + + public function destroy(string $sessionId): bool + { + return $this->client->del($this->key($sessionId)) > 0; + } + + public function exists(string $sessionId): bool + { + return $this->client->exists($this->key($sessionId)) > 0; + } + + /** + * Redis expires keys by TTL; no explicit sweep is required. + */ + public function gc(int $maxLifetime = 0): int + { + return 0; + } + + private function key(string $sessionId): string + { + return $this->prefix . $sessionId; + } +} diff --git a/tests/session/RedisSessionHandlerTest.php b/tests/session/RedisSessionHandlerTest.php new file mode 100644 index 0000000..13b2784 --- /dev/null +++ b/tests/session/RedisSessionHandlerTest.php @@ -0,0 +1,92 @@ + */ + public array $rows = []; + + public function get(string $key): string|false + { + $row = $this->rows[$key] ?? null; + if ($row === null || $row['expires'] <= time()) { + unset($this->rows[$key]); + + return false; + } + + return $row['value']; + } + + public function setex(string $key, int $ttl, string $value): bool + { + $this->rows[$key] = ['value' => $value, 'expires' => time() + $ttl]; + + return true; + } + + public function del(string $key): int + { + $exists = isset($this->rows[$key]); + unset($this->rows[$key]); + + return $exists ? 1 : 0; + } + + public function exists(string $key): int + { + return $this->get($key) === false ? 0 : 1; + } +} + +final class RedisSessionHandlerTest extends TestCase +{ + public function testWriteReadRoundtrip(): void + { + $client = new FakeRedisClient(); + $handler = new RedisSessionHandler($client, 60); + + $this->assertTrue($handler->write('abc', ['user' => 'jan', 'nested' => ['a' => 1]])); + $this->assertTrue($handler->exists('abc')); + $this->assertSame(['user' => 'jan', 'nested' => ['a' => 1]], $handler->read('abc')); + $this->assertArrayHasKey('fastd:session:abc', $client->rows); + } + + public function testDestroyRemovesSession(): void + { + $handler = new RedisSessionHandler(new FakeRedisClient(), 60); + $handler->write('abc', ['user' => 'jan']); + + $this->assertTrue($handler->destroy('abc')); + $this->assertFalse($handler->exists('abc')); + $this->assertFalse($handler->destroy('abc')); + } + + public function testInvalidJsonReturnsEmptyArray(): void + { + $client = new FakeRedisClient(); + $client->setex('fastd:session:bad', 60, '{invalid'); + + $this->assertSame([], (new RedisSessionHandler($client))->read('bad')); + } + + public function testOversizedPayloadIsRejected(): void + { + $handler = new RedisSessionHandler(new FakeRedisClient(), 60, 'fastd:session:', 32); + + $this->assertFalse($handler->write('abc', ['blob' => str_repeat('x', 64)])); + $this->assertFalse($handler->exists('abc')); + } + + public function testGcIsNoopBecauseRedisExpiresKeys(): void + { + $this->assertSame(0, (new RedisSessionHandler(new FakeRedisClient()))->gc()); + } +}