Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,13 +379,22 @@ $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());

// 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');

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
40 changes: 40 additions & 0 deletions src/Session/Handler/PhpRedisSessionClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace FastD\Http\Session\Handler;

/**
* phpredis adapter for RedisSessionHandler.
*
* Usage:
* new PhpRedisSessionClient(new \Redis())
*/
final class PhpRedisSessionClient implements RedisClientInterface
{
public function __construct(private mixed $redis)
{
}

public function get(string $key): string|false
{
$value = $this->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);
}
}
21 changes: 21 additions & 0 deletions src/Session/Handler/RedisClientInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace FastD\Http\Session\Handler;

/**
* Minimal Redis operations required by {@see RedisSessionHandler}.
*
* Implementations can wrap phpredis, Predis or a test double.
*/
interface RedisClientInterface
{
public function get(string $key): string|false;

public function setex(string $key, int $ttl, string $value): bool;

public function del(string $key): int;

public function exists(string $key): int;
}
73 changes: 73 additions & 0 deletions src/Session/Handler/RedisSessionHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

declare(strict_types=1);

namespace FastD\Http\Session\Handler;

use FastD\Http\Session\SessionGcInterface;
use FastD\Http\Session\SessionHandlerInterface;

/**
* Redis-backed session handler.
*
* Sessions are stored as JSON strings under a configurable key prefix and
* expire through Redis TTL. Redis performs expiry itself, so gc() is a no-op
* kept for SessionGcInterface compatibility.
*/
final class RedisSessionHandler implements SessionHandlerInterface, SessionGcInterface
{
public function __construct(
private RedisClientInterface $client,
private int $ttl = 1440,
private string $prefix = 'fastd:session:',
private int $maxDataSize = 8192,
) {
}

public function read(string $sessionId): array
{
$content = $this->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;
}
}
92 changes: 92 additions & 0 deletions tests/session/RedisSessionHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace FastD\Http\Tests;

use FastD\Http\Session\Handler\RedisClientInterface;
use FastD\Http\Session\Handler\RedisSessionHandler;
use PHPUnit\Framework\TestCase;

final class FakeRedisClient implements RedisClientInterface
{
/** @var array<string, array{value: string, expires: int}> */
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());
}
}
Loading