Skip to content

Commit 7da5319

Browse files
committed
feature: RedisHandler
closes #272
1 parent eb78c9b commit 7da5319

File tree

3 files changed

+373
-0
lines changed

3 files changed

+373
-0
lines changed

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,43 @@ else {
4242
}
4343
```
4444

45+
## Redis session storage
46+
47+
This package now includes `Gt\Session\RedisHandler` for shared session storage.
48+
It works with Redis-compatible backends such as Redis and Valkey, and is intended
49+
for deployments where application nodes are disposable and session state needs to
50+
survive traffic moving between servers.
51+
52+
`RedisHandler` expects `save_path` to be a DSN rather than a filesystem path.
53+
It uses the `phpredis` extension at runtime.
54+
55+
Example production config:
56+
57+
```ini
58+
[session]
59+
handler=Gt\Session\RedisHandler
60+
save_path=rediss://default:secret@example-redis.internal:25061/0?prefix=GT:&ttl=1440
61+
name=GT
62+
use_cookies=true
63+
```
64+
65+
Supported DSN forms:
66+
67+
- `redis://host:6379`
68+
- `redis://:password@host:6379/0`
69+
- `redis://username:password@host:6379/0`
70+
- `rediss://username:password@host:6379/0`
71+
72+
Useful query parameters:
73+
74+
- `prefix`: key prefix for stored sessions, defaults to `<session-name>:`
75+
- `ttl`: session lifetime in seconds, defaults to `session.gc_maxlifetime`
76+
- `timeout`: connection timeout in seconds
77+
- `read_timeout`: socket read timeout in seconds
78+
- `persistent=1`: enable persistent connections
79+
- `persistent_id`: optional persistent connection pool id
80+
- `verify_peer=0` / `verify_peer_name=0`: optional TLS verification flags
81+
4582
# Proudly sponsored by
4683

4784
[JetBrains Open Source sponsorship program](https://www.jetbrains.com/community/opensource/)

src/RedisHandler.php

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
namespace Gt\Session;
3+
4+
use Redis;
5+
use RuntimeException;
6+
7+
class RedisHandler extends Handler {
8+
private const DEFAULT_PORT = 6379;
9+
private const DEFAULT_PREFIX_SEPARATOR = ":";
10+
private ?object $client = null;
11+
private string $prefix = "";
12+
private int $ttl = 0;
13+
14+
public function open(string $savePath, string $name):bool {
15+
$config = $this->parseSavePath($savePath, $name);
16+
$client = $this->createClient();
17+
18+
$connected = $client->connect(
19+
$config["host"],
20+
$config["port"],
21+
$config["timeout"],
22+
$config["persistentId"],
23+
0,
24+
$config["readTimeout"],
25+
$config["context"],
26+
);
27+
28+
if(!$connected) {
29+
return false;
30+
}
31+
32+
if($config["auth"] !== null && !$client->auth($config["auth"])) {
33+
return false;
34+
}
35+
36+
if($config["database"] > 0 && !$client->select($config["database"])) {
37+
return false;
38+
}
39+
40+
$this->client = $client;
41+
$this->prefix = $config["prefix"];
42+
$this->ttl = $config["ttl"];
43+
return true;
44+
}
45+
46+
public function close():bool {
47+
if(is_null($this->client)) {
48+
return true;
49+
}
50+
51+
return $this->client->close();
52+
}
53+
54+
public function read(string $sessionId):string {
55+
$value = $this->requireClient()->get($this->getKey($sessionId));
56+
return $value === false ? "" : $value;
57+
}
58+
59+
public function write(string $sessionId, string $sessionData):bool {
60+
$client = $this->requireClient();
61+
$key = $this->getKey($sessionId);
62+
63+
if($this->ttl > 0) {
64+
return $client->setEx($key, $this->ttl, $sessionData);
65+
}
66+
67+
return $client->set($key, $sessionData);
68+
}
69+
70+
public function destroy(string $id = ""):bool {
71+
return $this->requireClient()->del($this->getKey($id)) >= 0;
72+
}
73+
74+
public function gc(int $maxLifeTime):int|false {
75+
return 0;
76+
}
77+
78+
/**
79+
* @return array{
80+
* host:string,
81+
* port:int,
82+
* timeout:float,
83+
* readTimeout:float,
84+
* persistentId:?string,
85+
* prefix:string,
86+
* ttl:int,
87+
* database:int,
88+
* auth:array{string,string}|string|null,
89+
* context:?array<string,mixed>
90+
* }
91+
*/
92+
private function parseSavePath(string $savePath, string $name):array {
93+
$parts = parse_url($savePath);
94+
if($parts === false || !isset($parts["host"])) {
95+
throw new RuntimeException("Invalid Redis save_path DSN.");
96+
}
97+
98+
parse_str($parts["query"] ?? "", $query);
99+
100+
$scheme = strtolower($parts["scheme"] ?? "redis");
101+
$host = $parts["host"];
102+
$context = null;
103+
104+
if(in_array($scheme, ["rediss", "tls"], true)) {
105+
$host = "tls://$host";
106+
$context = [
107+
"stream" => [
108+
"verify_peer" => filter_var(
109+
$query["verify_peer"] ?? true,
110+
FILTER_VALIDATE_BOOL
111+
),
112+
"verify_peer_name" => filter_var(
113+
$query["verify_peer_name"] ?? true,
114+
FILTER_VALIDATE_BOOL
115+
),
116+
],
117+
];
118+
}
119+
120+
$auth = null;
121+
if(isset($parts["user"]) && $parts["user"] !== "" && isset($parts["pass"])) {
122+
$auth = [rawurldecode($parts["user"]), rawurldecode($parts["pass"])];
123+
}
124+
elseif(isset($parts["pass"])) {
125+
$auth = rawurldecode($parts["pass"]);
126+
}
127+
128+
return [
129+
"host" => $host,
130+
"port" => (int)($parts["port"] ?? self::DEFAULT_PORT),
131+
"timeout" => (float)($query["timeout"] ?? 0),
132+
"readTimeout" => (float)($query["read_timeout"] ?? 0),
133+
"persistentId" => isset($query["persistent"])
134+
&& filter_var($query["persistent"], FILTER_VALIDATE_BOOL)
135+
? ($query["persistent_id"] ?? "phpgt-session")
136+
: null,
137+
"prefix" => (string)($query["prefix"] ?? ($name . self::DEFAULT_PREFIX_SEPARATOR)),
138+
"ttl" => (int)($query["ttl"] ?? ini_get("session.gc_maxlifetime")),
139+
"database" => isset($parts["path"])
140+
? (int)trim($parts["path"], "/")
141+
: 0,
142+
"auth" => $auth,
143+
"context" => $context,
144+
];
145+
}
146+
147+
private function getKey(string $sessionId):string {
148+
return $this->prefix . $sessionId;
149+
}
150+
151+
protected function createClient():object {
152+
if(!class_exists(Redis::class)) {
153+
throw new RuntimeException(
154+
"The phpredis extension is required to use Gt\\Session\\RedisHandler."
155+
);
156+
}
157+
158+
return new Redis();
159+
}
160+
161+
private function requireClient():object {
162+
if(is_null($this->client)) {
163+
throw new RuntimeException("RedisHandler::open() must be called before use.");
164+
}
165+
166+
return $this->client;
167+
}
168+
}

test/phpunit/RedisHandlerTest.php

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
namespace Gt\Session\Test;
3+
4+
use Gt\Session\RedisHandler;
5+
use PHPUnit\Framework\TestCase;
6+
7+
class RedisHandlerTest extends TestCase {
8+
public function testOpenParsesStandardDsn():void {
9+
$client = new TestRedisClient();
10+
$sut = new class($client) extends RedisHandler {
11+
public function __construct(private readonly TestRedisClient $client) {}
12+
13+
protected function createClient():object {
14+
return $this->client;
15+
}
16+
};
17+
18+
$sut->open(
19+
"redis://default:secret@example.internal:25061/2?prefix=prod:session:&ttl=1800&timeout=1.5&read_timeout=2.5&persistent=1&persistent_id=pool-a",
20+
"GT",
21+
);
22+
23+
self::assertSame("example.internal", $client->connectParameters["host"]);
24+
self::assertSame(25061, $client->connectParameters["port"]);
25+
self::assertSame(1.5, $client->connectParameters["timeout"]);
26+
self::assertSame(2.5, $client->connectParameters["readTimeout"]);
27+
self::assertSame("pool-a", $client->connectParameters["persistentId"]);
28+
self::assertSame([["default", "secret"]], $client->authCalls);
29+
self::assertSame([2], $client->selectCalls);
30+
31+
$sut->write("abc123", "payload");
32+
self::assertSame("payload", $sut->read("abc123"));
33+
self::assertSame(
34+
[
35+
"key" => "prod:session:abc123",
36+
"ttl" => 1800,
37+
"value" => "payload",
38+
],
39+
$client->setExCalls[0],
40+
);
41+
}
42+
43+
public function testOpenParsesTlsDsn():void {
44+
$client = new TestRedisClient();
45+
$sut = new class($client) extends RedisHandler {
46+
public function __construct(private readonly TestRedisClient $client) {}
47+
48+
protected function createClient():object {
49+
return $this->client;
50+
}
51+
};
52+
53+
$sut->open(
54+
"rediss://:secret@example.internal?verify_peer=0&verify_peer_name=0",
55+
"GT",
56+
);
57+
$sut->write("abc123", "payload");
58+
59+
self::assertSame("tls://example.internal", $client->connectParameters["host"]);
60+
self::assertSame(
61+
[
62+
"stream" => [
63+
"verify_peer" => false,
64+
"verify_peer_name" => false,
65+
],
66+
],
67+
$client->connectParameters["context"],
68+
);
69+
self::assertSame(["secret"], $client->authCalls);
70+
self::assertSame("payload", $client->data["GT:abc123"]);
71+
}
72+
73+
public function testDestroyAndClose():void {
74+
$client = new TestRedisClient();
75+
$sut = new class($client) extends RedisHandler {
76+
public function __construct(private readonly TestRedisClient $client) {}
77+
78+
protected function createClient():object {
79+
return $this->client;
80+
}
81+
};
82+
$sut->open("redis://cache.internal", "GT");
83+
$sut->write("abc123", "payload");
84+
85+
self::assertTrue($sut->destroy("abc123"));
86+
self::assertSame("", $sut->read("abc123"));
87+
self::assertTrue($sut->close());
88+
self::assertTrue($client->closed);
89+
}
90+
}
91+
92+
class TestRedisClient {
93+
/** @var array<string,mixed> */
94+
public array $connectParameters = [];
95+
/** @var array<int,array{key:string,ttl:int,value:string}> */
96+
public array $setExCalls = [];
97+
/** @var array<int,string> */
98+
public array $setCalls = [];
99+
/** @var array<int,array|string> */
100+
public array $authCalls = [];
101+
/** @var array<int,int> */
102+
public array $selectCalls = [];
103+
/** @var array<string,string> */
104+
public array $data = [];
105+
public int $deleted = 0;
106+
public bool $closed = false;
107+
108+
public function connect(
109+
string $host,
110+
int $port,
111+
float $timeout = 0,
112+
?string $persistentId = null,
113+
int $retryInterval = 0,
114+
float $readTimeout = 0,
115+
?array $context = null,
116+
):bool {
117+
$this->connectParameters = [
118+
"host" => $host,
119+
"port" => $port,
120+
"timeout" => $timeout,
121+
"persistentId" => $persistentId,
122+
"retryInterval" => $retryInterval,
123+
"readTimeout" => $readTimeout,
124+
"context" => $context,
125+
];
126+
return true;
127+
}
128+
129+
public function auth(array|string $credentials):bool {
130+
$this->authCalls []= $credentials;
131+
return true;
132+
}
133+
134+
public function select(int $database):bool {
135+
$this->selectCalls []= $database;
136+
return true;
137+
}
138+
139+
public function get(string $key):string|false {
140+
return $this->data[$key] ?? false;
141+
}
142+
143+
public function set(string $key, string $value):bool {
144+
$this->setCalls []= $key;
145+
$this->data[$key] = $value;
146+
return true;
147+
}
148+
149+
public function setEx(string $key, int $ttl, string $value):bool {
150+
$this->setExCalls []= [
151+
"key" => $key,
152+
"ttl" => $ttl,
153+
"value" => $value,
154+
];
155+
$this->data[$key] = $value;
156+
return true;
157+
}
158+
159+
public function del(string $key):int {
160+
unset($this->data[$key]);
161+
return ++$this->deleted;
162+
}
163+
164+
public function close():bool {
165+
$this->closed = true;
166+
return true;
167+
}
168+
}

0 commit comments

Comments
 (0)