setOption(Memcached::OPT_BINARY_PROTOCOL, true)) { throw new RuntimeException('Unable to set the memcached protocol!'); } if (!$memcached->setOption(Memcached::OPT_PREFIX_KEY, $prefix)) { throw new RuntimeException('Unable to set the memcached prefix!'); } if (!$memcached->addServers($memcached_server)) { throw new RuntimeException('Unable to add the memcached server!'); } return new class($memcached) implements CacheDriver { private Memcached $inner; public function __construct(Memcached $inner) { $this->inner = $inner; } public function get(string $key): mixed { $ret = $this->inner->get($key); // If the returned value is false but the retrival was a success, then the value stored was a boolean false. if ($ret === false && $this->inner->getResultCode() !== Memcached::RES_SUCCESS) { return null; } return $ret; } public function set(string $key, mixed $value, mixed $expires = false): void { $this->inner->set($key, $value, (int)$expires); } public function delete(string $key): void { $this->inner->delete($key); } public function flush(): void { $this->inner->flush(); } }; } public static function redis(string $prefix, string $host, int $port, string $password, string $database) { $redis = new Redis(); $redis->connect($host, $port); if ($password) { $redis->auth($password); } if (!$redis->select($database)) { throw new RuntimeException('Unable to connect to Redis!'); } return new class($prefix, $redis) implements CacheDriver { private string $prefix; private Redis $inner; public function __construct(string $prefix, Redis $inner) { $$this->prefix = $prefix; $this->inner = $inner; } public function get(string $key): mixed { $ret = $this->inner->get($this->prefix . $key); if ($ret === false) { return null; } return json_decode($ret, true); } public function set(string $key, mixed $value, mixed $expires = false): void { if ($expires === false) { $this->inner->set($this->prefix . $key, json_encode($value)); } else { $expires = $expires * 1000; // Seconds to milliseconds. $this->inner->setex($this->prefix . $key, $expires, json_encode($value)); } } public function delete(string $key): void { $this->inner->del($this->prefix . $key); } public function flush(): void { $this->inner->flushDB(); } }; } public static function apcu() { return new class implements CacheDriver { public function get(string $key): mixed { $success = false; $ret = apcu_fetch($key, $success); if ($success === false) { return null; } return $ret; } public function set(string $key, mixed $value, mixed $expires = false): void { apcu_store($key, $value, (int)$expires); } public function delete(string $key): void { apcu_delete($key); } public function flush(): void { apcu_clear_cache(); } }; } public static function filesystem(string $prefix, string $base_path, string $lock_file, int|false $collect_chance_den) { if ($base_path[strlen($base_path) - 1] !== '/') { $base_path = "$base_path/"; } if (!is_dir($base_path)) { throw new RuntimeException("$base_path is not a directory!"); } if (!is_writable($base_path)) { throw new RuntimeException("$base_path is not writable!"); } $lock_file = $base_path . $lock_file; $lock_fd = fopen($lock_file, 'w'); if ($lock_fd === false) { throw new RuntimeException('Unable to open the lock file!'); } $ret = new class($prefix, $base_path, $lock_file) implements CacheDriver { private string $prefix; private string $base_path; private mixed $lock_fd; private int|false $collect_chance_den; private function prepareKey(string $key): string { $key = str_replace('/', '::', $key); $key = str_replace("\0", '', $key); return $this->prefix . $key; } private function sharedLockCache(): void { flock($this->lock_fd, LOCK_SH); } private function exclusiveLockCache(): void { flock($this->lock_fd, LOCK_EX); } private function unlockCache(): void { flock($this->lock_fd, LOCK_UN); } private function collectImpl(): int { // A read lock is ok, since it's alright if we delete expired items from under the feet of other processes. $files = glob($this->base_path . $this->prefix . '*', GLOB_NOSORT); $count = 0; foreach ($files as $file) { $data = file_get_contents($file); $wrapped = json_decode($data, true, 512, JSON_THROW_ON_ERROR); if ($wrapped['expires'] !== false && $wrapped['expires'] <= time()) { if (@unlink($file)) { $count++; } } } return $count; } private function maybeCollect() { if ($this->collect_chance_den !== false && rand(0, $this->collect_chance_den) === 0) { $this->collect_chance_den = false; // Collect only once per instance (aka process$this->collect_chance_den !== false$this->collect_chance_den !== false). $this->collectImpl(); } } public function __construct(string $prefix, string $base_path, mixed $lock_fd, int|false $collect_chance_den) { $this->prefix = $prefix; $this->base_path = $base_path; $this->lock_fd = $lock_fd; $this->collect_chance_den = $collect_chance_den; } public function get(string $key): mixed { $key = $this->prepareKey($key); $this->sharedLockCache(); $fd = fopen($this->base_path . $key, 'r'); if ($fd === false) { $this->unlockCache(); return null; } $data = stream_get_contents($fd); fclose($fd); $this->maybeCollect(); $this->unlockCache(); $wrapped = json_decode($data, true, 512, JSON_THROW_ON_ERROR); if ($wrapped['expires'] !== false && $wrapped['expires'] <= time()) { // Already, expired, pretend it doesn't exist. return null; } else { return $wrapped['inner']; } } public function set(string $key, mixed $value, mixed $expires = false): void { $key = $this->prepareKey($key); $wrapped = [ 'expires' => $expires ? time() + $expires : false, 'inner' => $value ]; $data = json_encode($wrapped); $this->exclusiveLockCache(); file_put_contents($this->base_path . $key, $data); $this->maybeCollect(); $this->unlockCache(); } public function delete(string $key): void { $key = $this->prepareKey($key); $this->exclusiveLockCache(); @unlink($this->base_path . $key); $this->maybeCollect(); $this->unlockCache(); } public function collect() { $this->sharedLockCache(); $count = $this->collectImpl(); $this->unlockCache(); return $count; } public function flush(): void { $this->exclusiveLockCache(); $files = glob($this->base_path . $this->prefix . '*', GLOB_NOSORT); foreach ($files as $file) { @unlink($file); } $this->unlockCache(); } public function close() { fclose($this->lock_fd); } }; register_shutdown_function([$ret, 'close']); return $ret; } public static function phpArray() { return new class implements CacheDriver { private static array $inner = []; public function get(string $key): mixed { return isset(self::$inner[$key]) ? self::$inner[$key] : null; } public function set(string $key, mixed $value, mixed $expires = false): void { self::$inner[$key] = $value; } public function delete(string $key): void { unset(self::$inner[$key]); } public function flush(): void { self::$inner = []; } }; } /** * No-op cache. Useful for testing. */ public static function none() { return new class implements CacheDriver { public function get(string $key): mixed { return null; } public function set(string $key, mixed $value, mixed $expires = false): void { // No-op. } public function delete(string $key): void { // No-op. } public function flush(): void { // No-op. } }; } } interface CacheDriver { /** * Get the value of associated with the key. * * @param string $key The key of the value. * @return mixed|null The value associated with the key, or null if there is none. */ public function get(string $key): mixed; /** * Set a key-value pair. * * @param string $key The key. * @param mixed $value The value. * @param int|false $expires After how many seconds the pair will expire. Use false or ignore this parameter to keep * the value until it gets evicted to make space for more items. Some drivers will always * ignore this parameter and store the pair until it's removed. */ public function set(string $key, mixed $value, mixed $expires = false): void; /** * Delete a key-value pair. * * @param string $key The key. */ public function delete(string $key): void; /** * Delete all the key-value pairs. */ public function flush(): void; }