1 | <?php |
||
2 | |||
3 | declare(strict_types=1); |
||
4 | |||
5 | namespace Yiisoft\Cache\Redis; |
||
6 | |||
7 | use DateInterval; |
||
8 | use DateTime; |
||
9 | use Predis\Client; |
||
10 | use Predis\ClientInterface; |
||
11 | use Predis\Connection\ConnectionInterface; |
||
12 | use Predis\Connection\StreamConnection; |
||
13 | use Predis\Response\Status; |
||
14 | use Psr\SimpleCache\CacheInterface; |
||
15 | use Traversable; |
||
16 | |||
17 | use function array_fill_keys; |
||
18 | use function array_keys; |
||
19 | use function array_map; |
||
20 | use function count; |
||
21 | use function iterator_to_array; |
||
22 | use function serialize; |
||
23 | use function strpbrk; |
||
24 | use function unserialize; |
||
25 | |||
26 | /** |
||
27 | * RedisCache stores cache data in a Redis. |
||
28 | * |
||
29 | * Please refer to {@see CacheInterface} for common cache operations that are supported by RedisCache. |
||
30 | */ |
||
31 | final class RedisCache implements CacheInterface |
||
32 | { |
||
33 | /** |
||
34 | * @var array<ConnectionInterface>|ConnectionInterface $connections Predis connection instances to use. |
||
35 | */ |
||
36 | private ConnectionInterface|array $connections; |
||
37 | |||
38 | /** |
||
39 | * @param ClientInterface $client Predis client instance to use. |
||
40 | */ |
||
41 | 226 | public function __construct(private ClientInterface $client) |
|
42 | { |
||
43 | 226 | $this->connections = $this->client->getConnection(); |
|
44 | } |
||
45 | |||
46 | /** |
||
47 | * Returns whether Predis cluster is used. |
||
48 | * |
||
49 | * @return bool Whether Predis cluster is used. |
||
50 | */ |
||
51 | 226 | private function isCluster(): bool |
|
52 | { |
||
53 | /** @psalm-suppress MixedAssignment, PossibleRawObjectIteration */ |
||
54 | 226 | foreach ($this->connections as $connection) { |
|
55 | /** @var StreamConnection $connection */ |
||
56 | 108 | $cluster = (new Client($connection->getParameters()))->info('Cluster'); |
|
57 | /** @psalm-suppress MixedArrayAccess */ |
||
58 | 108 | if (isset($cluster['Cluster']['cluster_enabled']) && 1 === (int)$cluster['Cluster']['cluster_enabled']) { |
|
59 | 108 | return true; |
|
60 | } |
||
61 | } |
||
62 | |||
63 | 118 | return false; |
|
64 | } |
||
65 | |||
66 | 134 | public function get(string $key, mixed $default = null): mixed |
|
67 | { |
||
68 | 134 | $this->validateKey($key); |
|
69 | /** @var string|null $value */ |
||
70 | 130 | $value = $this->client->get($key); |
|
71 | 130 | return $value === null ? $default : unserialize($value); |
|
72 | } |
||
73 | |||
74 | 175 | public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool |
|
75 | { |
||
76 | 175 | $ttl = $this->normalizeTtl($ttl); |
|
77 | |||
78 | 175 | if ($this->isExpiredTtl($ttl)) { |
|
79 | 1 | return $this->delete($key); |
|
80 | } |
||
81 | |||
82 | 174 | $this->validateKey($key); |
|
83 | |||
84 | /** @var Status|null $result */ |
||
85 | 172 | $result = $this->isInfinityTtl($ttl) |
|
86 | 148 | ? $this->client->set($key, serialize($value)) |
|
87 | 24 | : $this->client->set($key, serialize($value), 'EX', $ttl); |
|
88 | |||
89 | 172 | return $result !== null; |
|
90 | } |
||
91 | |||
92 | 31 | public function delete(string $key): bool |
|
93 | { |
||
94 | 31 | return !$this->has($key) || $this->client->del($key) === 1; |
|
95 | } |
||
96 | |||
97 | /** |
||
98 | * @inheritDoc |
||
99 | * |
||
100 | * If a cluster is used, all nodes will be cleared. |
||
101 | */ |
||
102 | 226 | public function clear(): bool |
|
103 | { |
||
104 | 226 | if ($this->isCluster()) { |
|
105 | /** @psalm-suppress MixedAssignment, PossibleRawObjectIteration */ |
||
106 | 108 | foreach ($this->connections as $connection) { |
|
107 | /** @var StreamConnection $connection */ |
||
108 | 108 | $client = new Client($connection->getParameters()); |
|
109 | 108 | $client->flushdb(); |
|
110 | } |
||
111 | 108 | return true; |
|
112 | } |
||
113 | |||
114 | 118 | return $this->client->flushdb() !== null; |
|
115 | } |
||
116 | |||
117 | 18 | public function getMultiple(iterable $keys, mixed $default = null): iterable |
|
118 | { |
||
119 | /** @var string[] $keys */ |
||
120 | 18 | $keys = $this->iterableToArray($keys); |
|
121 | 18 | $this->validateKeys($keys); |
|
122 | 12 | $values = array_fill_keys($keys, $default); |
|
123 | |||
124 | 12 | if ($this->isCluster()) { |
|
125 | 5 | foreach ($keys as $key) { |
|
126 | /** @var string|null $value */ |
||
127 | 5 | $value = $this->get($key); |
|
128 | 5 | if (null !== $value) { |
|
129 | /** @psalm-suppress MixedAssignment */ |
||
130 | 4 | $values[$key] = unserialize($value); |
|
131 | } |
||
132 | } |
||
133 | } else { |
||
134 | /** @var null[]|string[] $valuesFromCache */ |
||
135 | 7 | $valuesFromCache = $this->client->mget($keys); |
|
136 | |||
137 | 7 | $i = 0; |
|
138 | /** @psalm-suppress MixedAssignment */ |
||
139 | 7 | foreach ($values as $key => $value) { |
|
140 | 7 | $values[$key] = isset($valuesFromCache[$i]) ? unserialize($valuesFromCache[$i]) : $value; |
|
141 | 7 | $i++; |
|
142 | } |
||
143 | } |
||
144 | |||
145 | 12 | return $values; |
|
146 | } |
||
147 | |||
148 | 18 | public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool |
|
149 | { |
||
150 | 18 | $values = $this->iterableToArray($values); |
|
151 | 18 | $keys = array_map('\strval', array_keys($values)); |
|
152 | 18 | $this->validateKeys($keys); |
|
153 | 16 | $ttl = $this->normalizeTtl($ttl); |
|
154 | 16 | $serializeValues = []; |
|
155 | |||
156 | 16 | if ($this->isExpiredTtl($ttl)) { |
|
157 | 1 | return $this->deleteMultiple($keys); |
|
158 | } |
||
159 | |||
160 | foreach ($values as $key => $value) { |
||
161 | 15 | $serializeValues[$key] = serialize($value); |
|
162 | 15 | } |
|
163 | |||
164 | $results = []; |
||
165 | 15 | if ($this->isCluster()) { |
|
166 | 15 | foreach ($serializeValues as $key => $value) { |
|
167 | 4 | $this->set((string)$key, $value, $this->isInfinityTtl($ttl) ? null : $ttl); |
|
168 | 4 | } |
|
169 | } else { |
||
170 | if ($this->isInfinityTtl($ttl)) { |
||
171 | 11 | $this->client->mset($serializeValues); |
|
172 | 8 | return true; |
|
173 | 8 | } |
|
174 | |||
175 | $this->client->multi(); |
||
176 | 3 | $this->client->mset($serializeValues); |
|
177 | 3 | ||
178 | foreach ($keys as $key) { |
||
179 | 3 | $this->client->expire($key, (int)$ttl); |
|
180 | 3 | } |
|
181 | |||
182 | /** @var array|null $results */ |
||
183 | $results = $this->client->exec(); |
||
184 | 3 | } |
|
185 | |||
186 | return !in_array(null, (array)$results, true); |
||
187 | 7 | } |
|
188 | |||
189 | public function deleteMultiple(iterable $keys): bool |
||
190 | 8 | { |
|
191 | $keys = $this->iterableToArray($keys); |
||
192 | 8 | ||
193 | /** @psalm-suppress MixedAssignment, MixedArgument */ |
||
194 | foreach ($keys as $index => $key) { |
||
195 | 8 | if (!$this->has($key)) { |
|
196 | 8 | unset($keys[$index]); |
|
197 | 3 | } |
|
198 | } |
||
199 | |||
200 | /** @psalm-suppress MixedArgumentTypeCoercion */ |
||
201 | return empty($keys) || $this->client->del($keys) === count($keys); |
||
202 | 4 | } |
|
203 | |||
204 | public function has(string $key): bool |
||
205 | 64 | { |
|
206 | $this->validateKey($key); |
||
207 | 64 | /** @var int $ttl */ |
|
208 | $ttl = $this->client->ttl($key); |
||
209 | 52 | /** "-1" - if the key exists but has no associated expire {@see https://redis.io/commands/ttl}. */ |
|
210 | return $ttl > 0 || $ttl === -1; |
||
211 | 52 | } |
|
212 | |||
213 | /** |
||
214 | * Normalizes cache TTL handling `null` value, strings and {@see DateInterval} objects. |
||
215 | * |
||
216 | * @param DateInterval|int|string|null $ttl The raw TTL. |
||
217 | * |
||
218 | * @return int|null TTL value as UNIX timestamp. |
||
219 | */ |
||
220 | private function normalizeTtl(null|int|string|DateInterval $ttl): ?int |
||
221 | 200 | { |
|
222 | if ($ttl === null) { |
||
223 | 200 | return null; |
|
224 | 160 | } |
|
225 | |||
226 | if ($ttl instanceof DateInterval) { |
||
227 | 40 | return (new DateTime('@0')) |
|
228 | 4 | ->add($ttl) |
|
229 | 4 | ->getTimestamp(); |
|
230 | 4 | } |
|
231 | |||
232 | return (int) $ttl; |
||
233 | 36 | } |
|
234 | |||
235 | /** |
||
236 | * Converts iterable to array. |
||
237 | * |
||
238 | * @psalm-template T |
||
239 | * @psalm-param iterable<T> $iterable |
||
240 | * @psalm-return array<array-key,T> |
||
241 | 30 | */ |
|
242 | private function iterableToArray(iterable $iterable): array |
||
243 | { |
||
244 | 30 | return $iterable instanceof Traversable ? iterator_to_array($iterable) : $iterable; |
|
0 ignored issues
–
show
Bug
Best Practice
introduced
by
![]() |
|||
245 | } |
||
246 | |||
247 | /** |
||
248 | * @throws InvalidArgumentException |
||
249 | */ |
||
250 | 208 | private function validateKey(string $key): void |
|
251 | { |
||
252 | 208 | if ($key === '' || strpbrk($key, '{}()/\@:')) { |
|
253 | 22 | throw new InvalidArgumentException('Invalid key value.'); |
|
254 | } |
||
255 | } |
||
256 | |||
257 | /** |
||
258 | * @param string[] $keys |
||
259 | * |
||
260 | * @throws InvalidArgumentException |
||
261 | */ |
||
262 | 26 | private function validateKeys(array $keys): void |
|
263 | { |
||
264 | 26 | if ([] === $keys) { |
|
265 | 4 | throw new InvalidArgumentException('Invalid key values.'); |
|
266 | } |
||
267 | |||
268 | 22 | foreach ($keys as $key) { |
|
269 | 22 | $this->validateKey($key); |
|
270 | } |
||
271 | } |
||
272 | |||
273 | /** |
||
274 | * @return bool |
||
275 | */ |
||
276 | 186 | private function isExpiredTtl(?int $ttl): bool |
|
277 | { |
||
278 | 186 | return $ttl !== null && $ttl <= 0; |
|
279 | } |
||
280 | |||
281 | /** |
||
282 | * @return bool |
||
283 | */ |
||
284 | 183 | private function isInfinityTtl(?int $ttl): bool |
|
285 | { |
||
286 | 183 | return $ttl === null; |
|
287 | } |
||
288 | } |
||
289 |