Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
89.23% |
116 / 130 |
|
71.43% |
10 / 14 |
CRAP | |
0.00% |
0 / 1 |
MemcachedHandler | |
89.23% |
116 / 130 |
|
71.43% |
10 / 14 |
47.53 | |
0.00% |
0 / 1 |
prepareConfig | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
3 | |||
setMemcached | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getMemcached | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getExpiration | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
open | |
89.29% |
25 / 28 |
|
0.00% |
0 / 1 |
7.06 | |||
read | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
write | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
8.06 | |||
updateTimestamp | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
close | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
destroy | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
gc | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
lock | |
61.90% |
13 / 21 |
|
0.00% |
0 / 1 |
9.71 | |||
unlock | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 |
1 | <?php declare(strict_types=1); |
2 | /* |
3 | * This file is part of Aplus Framework Session Library. |
4 | * |
5 | * (c) Natan Felles <natanfelles@gmail.com> |
6 | * |
7 | * For the full copyright and license information, please view the LICENSE |
8 | * file that was distributed with this source code. |
9 | */ |
10 | namespace Framework\Session\SaveHandlers; |
11 | |
12 | use Framework\Log\LogLevel; |
13 | use Framework\Session\SaveHandler; |
14 | use Memcached; |
15 | use OutOfBoundsException; |
16 | use SensitiveParameter; |
17 | |
18 | /** |
19 | * Class MemcachedHandler. |
20 | * |
21 | * @package session |
22 | */ |
23 | class MemcachedHandler extends SaveHandler |
24 | { |
25 | protected ?Memcached $memcached; |
26 | |
27 | /** |
28 | * Prepare configurations to be used by the MemcachedHandler. |
29 | * |
30 | * @param array<string,mixed> $config Custom configs |
31 | * |
32 | * The custom configs are: |
33 | * |
34 | * ```php |
35 | * $configs = [ |
36 | * // A custom prefix prepended in the keys |
37 | * 'prefix' => '', |
38 | * // A list of Memcached servers |
39 | * 'servers' => [ |
40 | * [ |
41 | * 'host' => '127.0.0.1', // host always is required |
42 | * 'port' => 11211, // port is optional, default to 11211 |
43 | * 'weight' => 0, // weight is optional, default to 0 |
44 | * ], |
45 | * ], |
46 | * // An associative array of Memcached::OPT_* constants |
47 | * 'options' => [ |
48 | * Memcached::OPT_BINARY_PROTOCOL => true, |
49 | * ], |
50 | * // Maximum attempts to try lock a session id |
51 | * 'lock_attempts' => 60, |
52 | * // Interval between the lock attempts in microseconds |
53 | * 'lock_sleep' => 1_000_000, |
54 | * // TTL to the lock (valid for the current session only) |
55 | * 'lock_ttl' => 600, |
56 | * // The maxlifetime (TTL) used for cache item expiration |
57 | * 'maxlifetime' => null, // Null to use the ini value of session.gc_maxlifetime |
58 | * // Match IP? |
59 | * 'match_ip' => false, |
60 | * // Match User-Agent? |
61 | * 'match_ua' => false, |
62 | * ]; |
63 | * ``` |
64 | */ |
65 | protected function prepareConfig(#[SensitiveParameter] array $config) : void |
66 | { |
67 | $this->config = \array_replace_recursive([ |
68 | 'prefix' => '', |
69 | 'servers' => [ |
70 | [ |
71 | 'host' => '127.0.0.1', |
72 | 'port' => 11211, |
73 | 'weight' => 0, |
74 | ], |
75 | ], |
76 | 'options' => [ |
77 | Memcached::OPT_BINARY_PROTOCOL => true, |
78 | ], |
79 | 'lock_attempts' => 60, |
80 | 'lock_sleep' => 1_000_000, |
81 | 'lock_ttl' => 600, |
82 | 'maxlifetime' => null, |
83 | 'match_ip' => false, |
84 | 'match_ua' => false, |
85 | ], $config); |
86 | foreach ($this->config['servers'] as $index => $server) { |
87 | if ( ! isset($server['host'])) { |
88 | throw new OutOfBoundsException( |
89 | "Memcached host not set on server config '{$index}'" |
90 | ); |
91 | } |
92 | } |
93 | } |
94 | |
95 | public function setMemcached(Memcached $memcached) : static |
96 | { |
97 | $this->memcached = $memcached; |
98 | return $this; |
99 | } |
100 | |
101 | public function getMemcached() : ?Memcached |
102 | { |
103 | return $this->memcached ?? null; |
104 | } |
105 | |
106 | /** |
107 | * Get expiration as a timestamp. |
108 | * |
109 | * Useful for Time To Live greater than a month (`60*60*24*30`). |
110 | * |
111 | * @param int $seconds |
112 | * |
113 | * @see https://www.php.net/manual/en/memcached.expiration.php |
114 | * |
115 | * @return int |
116 | */ |
117 | protected function getExpiration(int $seconds) : int |
118 | { |
119 | return \time() + $seconds; |
120 | } |
121 | |
122 | /** |
123 | * Get a key for Memcached, using the optional |
124 | * prefix, match IP and match User-Agent configs. |
125 | * |
126 | * NOTE: The max key length allowed by Memcached is 250 bytes. |
127 | * |
128 | * @param string $id The session id |
129 | * |
130 | * @return string The final key |
131 | */ |
132 | protected function getKey(string $id) : string |
133 | { |
134 | return $this->config['prefix'] . $id . $this->getKeySuffix(); |
135 | } |
136 | |
137 | public function open($path, $name) : bool |
138 | { |
139 | if (isset($this->memcached)) { |
140 | return true; |
141 | } |
142 | $this->memcached = new Memcached(); |
143 | $pool = []; |
144 | foreach ($this->config['servers'] as $server) { |
145 | $host = $server['host'] . ':' . ($server['port'] ?? 11211); |
146 | if (\in_array($host, $pool, true)) { |
147 | $this->log( |
148 | 'Session (memcached): Server pool already has ' . $host, |
149 | LogLevel::DEBUG |
150 | ); |
151 | continue; |
152 | } |
153 | $result = $this->memcached->addServer( |
154 | $server['host'], |
155 | $server['port'] ?? 11211, |
156 | $server['weight'] ?? 0, |
157 | ); |
158 | if ($result === false) { |
159 | $this->log("Session (memcached): Could not add {$host} to server pool"); |
160 | continue; |
161 | } |
162 | $pool[] = $host; |
163 | } |
164 | $result = $this->memcached->setOptions($this->config['options']); |
165 | if ($result === false) { |
166 | $this->log('Session (memcached): ' . $this->memcached->getLastErrorMessage()); |
167 | } |
168 | if ( ! $this->memcached->getStats()) { |
169 | $this->log('Session (memcached): Could not connect to any server'); |
170 | return false; |
171 | } |
172 | return true; |
173 | } |
174 | |
175 | public function read($id) : string |
176 | { |
177 | if ( ! isset($this->memcached) || ! $this->lock($id)) { |
178 | return ''; |
179 | } |
180 | if ( ! isset($this->sessionId)) { |
181 | $this->sessionId = $id; |
182 | } |
183 | $data = (string) $this->memcached->get($this->getKey($id)); |
184 | $this->setFingerprint($data); |
185 | return $data; |
186 | } |
187 | |
188 | public function write($id, $data) : bool |
189 | { |
190 | if ( ! isset($this->memcached)) { |
191 | return false; |
192 | } |
193 | if ($id !== $this->sessionId) { |
194 | if ( ! $this->unlock() || ! $this->lock($id)) { |
195 | return false; |
196 | } |
197 | $this->setFingerprint(''); |
198 | $this->sessionId = $id; |
199 | } |
200 | if ($this->lockId === false) { |
201 | return false; |
202 | } |
203 | $this->memcached->replace( |
204 | $this->lockId, |
205 | \time(), |
206 | $this->getExpiration($this->config['lock_ttl']) |
207 | ); |
208 | $maxlifetime = $this->getExpiration($this->getMaxlifetime()); |
209 | if ($this->hasSameFingerprint($data)) { |
210 | return $this->memcached->touch($this->getKey($id), $maxlifetime); |
211 | } |
212 | if ($this->memcached->set($this->getKey($id), $data, $maxlifetime)) { |
213 | $this->setFingerprint($data); |
214 | return true; |
215 | } |
216 | return false; |
217 | } |
218 | |
219 | public function updateTimestamp($id, $data) : bool |
220 | { |
221 | return $this->memcached->touch( |
222 | $this->getKey($id), |
223 | $this->getExpiration($this->getMaxlifetime()) |
224 | ); |
225 | } |
226 | |
227 | public function close() : bool |
228 | { |
229 | if ($this->lockId) { |
230 | $this->memcached->delete($this->lockId); |
231 | } |
232 | if ( ! $this->memcached->quit()) { |
233 | return false; |
234 | } |
235 | $this->memcached = null; |
236 | return true; |
237 | } |
238 | |
239 | public function destroy($id) : bool |
240 | { |
241 | if ( ! $this->lockId) { |
242 | return false; |
243 | } |
244 | $destroyed = $this->memcached->delete($this->getKey($id)); |
245 | return ! ($destroyed === false |
246 | && $this->memcached->getResultCode() !== Memcached::RES_NOTFOUND); |
247 | } |
248 | |
249 | public function gc($max_lifetime) : int | false |
250 | { |
251 | return 0; |
252 | } |
253 | |
254 | protected function lock(string $id) : bool |
255 | { |
256 | $expiration = $this->getExpiration($this->config['lock_ttl']); |
257 | if ($this->lockId && $this->memcached->get($this->lockId)) { |
258 | return $this->memcached->replace($this->lockId, \time(), $expiration); |
259 | } |
260 | $lockId = $this->getKey($id) . ':lock'; |
261 | $attempt = 0; |
262 | while ($attempt < $this->config['lock_attempts']) { |
263 | $attempt++; |
264 | if ($this->memcached->get($lockId)) { |
265 | \usleep($this->config['lock_sleep']); |
266 | continue; |
267 | } |
268 | if ( ! $this->memcached->set($lockId, \time(), $expiration)) { |
269 | $this->log('Session (memcached): Error while trying to lock ' . $lockId); |
270 | return false; |
271 | } |
272 | $this->lockId = $lockId; |
273 | break; |
274 | } |
275 | if ($attempt === $this->config['lock_attempts']) { |
276 | $this->log( |
277 | "Session (memcached): Unable to lock {$lockId} after {$attempt} attempts" |
278 | ); |
279 | return false; |
280 | } |
281 | return true; |
282 | } |
283 | |
284 | protected function unlock() : bool |
285 | { |
286 | if ($this->lockId === false) { |
287 | return true; |
288 | } |
289 | if ( ! $this->memcached->delete($this->lockId) && |
290 | $this->memcached->getResultCode() !== Memcached::RES_NOTFOUND |
291 | ) { |
292 | $this->log('Session (memcached): Error while trying to unlock ' . $this->lockId); |
293 | return false; |
294 | } |
295 | $this->lockId = false; |
296 | return true; |
297 | } |
298 | } |