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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: curl, zlib, swoole
extensions: curl, zlib, swoole, redis
coverage: pcov
tools: composer:v2
- name: Validate composer.json
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,6 @@ 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());
Expand All @@ -392,7 +391,7 @@ $middleware = new SessionMiddleware(new SwooleTableSessionHandler(1440, 4096));
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$middleware = new SessionMiddleware(
new RedisSessionHandler(new PhpRedisSessionClient($redis), 1440, 'fastd:session:')
new RedisSessionHandler($redis, 1440, 'fastd:session:')
);

// 将 $middleware 注册进 PSR-15 管道后,即可在后续处理器中读取会话
Expand Down
40 changes: 0 additions & 40 deletions src/Session/Handler/PhpRedisSessionClient.php

This file was deleted.

21 changes: 0 additions & 21 deletions src/Session/Handler/RedisClientInterface.php

This file was deleted.

22 changes: 13 additions & 9 deletions src/Session/Handler/RedisSessionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,22 @@

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

/**
* 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.
* Sessions are JSON strings under a configurable prefix and expire through
* Redis TTL. Redis handles expiration, so gc() is a no-op kept for
* SessionGcInterface compatibility.
*
* The handler intentionally depends on \Redis directly; external callers can
* use SessionHandlerInterface to substitute other storage implementations.
*/
final class RedisSessionHandler implements SessionHandlerInterface, SessionGcInterface
{
public function __construct(
private RedisClientInterface $client,
private Redis $redis,
private int $ttl = 1440,
private string $prefix = 'fastd:session:',
private int $maxDataSize = 8192,
Expand All @@ -26,9 +30,9 @@ public function __construct(

public function read(string $sessionId): array
{
$content = $this->client->get($this->key($sessionId));
$content = $this->redis->get($this->key($sessionId));

if ($content === false) {
if (!is_string($content)) {
return [];
}

Expand All @@ -45,17 +49,17 @@ public function write(string $sessionId, array $data): bool
return false;
}

return $this->client->setex($this->key($sessionId), $this->ttl, $content);
return (bool) $this->redis->setex($this->key($sessionId), $this->ttl, $content);
}

public function destroy(string $sessionId): bool
{
return $this->client->del($this->key($sessionId)) > 0;
return (int) $this->redis->del($this->key($sessionId)) > 0;
}

public function exists(string $sessionId): bool
{
return $this->client->exists($this->key($sessionId)) > 0;
return (bool) $this->redis->exists($this->key($sessionId));
}

/**
Expand Down
88 changes: 42 additions & 46 deletions tests/session/RedisSessionHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,89 +4,85 @@

namespace FastD\Http\Tests;

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

final class FakeRedisClient implements RedisClientInterface
#[AllowMockObjectsWithoutExpectations]
final class RedisSessionHandlerTest extends TestCase
{
/** @var array<string, array{value: string, expires: int}> */
public array $rows = [];

public function get(string $key): string|false
protected function setUp(): void
{
$row = $this->rows[$key] ?? null;
if ($row === null || $row['expires'] <= time()) {
unset($this->rows[$key]);

return false;
if (!extension_loaded('redis')) {
$this->markTestSkipped('ext-redis is required.');
}

return $row['value'];
}

public function setex(string $key, int $ttl, string $value): bool
public function testConstructorRequiresRedisObject(): void
{
$this->rows[$key] = ['value' => $value, 'expires' => time() + $ttl];
$parameter = (new \ReflectionMethod(RedisSessionHandler::class, '__construct'))->getParameters()[0];

return true;
$this->assertInstanceOf(\ReflectionNamedType::class, $parameter->getType());
$this->assertSame(Redis::class, $parameter->getType()->getName());
}

public function del(string $key): int
public function testReadDecodesJson(): void
{
$exists = isset($this->rows[$key]);
unset($this->rows[$key]);
$redis = $this->createMock(Redis::class);
$redis->expects($this->once())
->method('get')
->with('fastd:session:abc')
->willReturn(json_encode(['user' => 'jan']));

return $exists ? 1 : 0;
$this->assertSame(['user' => 'jan'], (new RedisSessionHandler($redis))->read('abc'));
}

public function exists(string $key): int
public function testWriteUsesSetexWithTtlAndJson(): void
{
return $this->get($key) === false ? 0 : 1;
}
}
$redis = $this->createMock(Redis::class);
$redis->expects($this->once())
->method('setex')
->with('fastd:session:abc', 60, json_encode(['user' => 'jan']))
->willReturn(true);

final class RedisSessionHandlerTest extends TestCase
{
public function testWriteReadRoundtrip(): void
{
$client = new FakeRedisClient();
$handler = new RedisSessionHandler($client, 60);
$handler = new RedisSessionHandler($redis, 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);
$this->assertTrue($handler->write('abc', ['user' => 'jan']));
}

public function testDestroyRemovesSession(): void
public function testDestroyAndExistsUsePrefixedKey(): void
{
$handler = new RedisSessionHandler(new FakeRedisClient(), 60);
$handler->write('abc', ['user' => 'jan']);
$redis = $this->createMock(Redis::class);
$redis->expects($this->once())->method('del')->with('fastd:session:abc')->willReturn(1);
$redis->expects($this->once())->method('exists')->with('fastd:session:abc')->willReturn(1);

$handler = new RedisSessionHandler($redis);

$this->assertTrue($handler->destroy('abc'));
$this->assertFalse($handler->exists('abc'));
$this->assertFalse($handler->destroy('abc'));
$this->assertTrue($handler->exists('abc'));
}

public function testInvalidJsonReturnsEmptyArray(): void
{
$client = new FakeRedisClient();
$client->setex('fastd:session:bad', 60, '{invalid');
$redis = $this->createMock(Redis::class);
$redis->method('get')->willReturn('{invalid');

$this->assertSame([], (new RedisSessionHandler($client))->read('bad'));
$this->assertSame([], (new RedisSessionHandler($redis))->read('bad'));
}

public function testOversizedPayloadIsRejected(): void
public function testOversizedPayloadIsRejectedBeforeRedisWrite(): void
{
$handler = new RedisSessionHandler(new FakeRedisClient(), 60, 'fastd:session:', 32);
$redis = $this->createMock(Redis::class);
$redis->expects($this->never())->method('setex');

$handler = new RedisSessionHandler($redis, 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());
$this->assertSame(0, (new RedisSessionHandler($this->createMock(Redis::class)))->gc());
}
}
Loading