* @copyright Copyright (c) 2014, Matthias Mullie. All rights reserved * @license LICENSE MIT */ class Flysystem implements KeyValueStore { /** * @var Filesystem */ protected $filesystem; /** * @var int */ protected $version; public function __construct(Filesystem $filesystem) { $this->filesystem = $filesystem; $this->version = class_exists('League\Flysystem\Local\LocalFilesystemAdapter') ? 2 : 1; } /** * {@inheritdoc} */ public function get($key, &$token = null) { $token = null; // let expired-but-not-yet-deleted files be deleted first if (!$this->exists($key)) { return false; } $data = $this->read($key); if (false === $data) { return false; } $value = unserialize($data[1]); $token = $data[1]; return $value; } /** * {@inheritdoc} */ public function getMulti(array $keys, array &$tokens = null) { $results = array(); $tokens = array(); foreach ($keys as $key) { $token = null; $value = $this->get($key, $token); if (null !== $token) { $results[$key] = $value; $tokens[$key] = $token; } } return $results; } /** * {@inheritdoc} */ public function set($key, $value, $expire = 0) { // we don't really need a lock for this operation, but we need to make // sure it's not locked by another operation, which we could overwrite if (!$this->lock($key)) { return false; } $expire = $this->normalizeTime($expire); if (0 !== $expire && $expire < time()) { $this->unlock($key); // don't waste time storing (and later comparing expiration // timestamp) data that is already expired; just delete it already return !$this->exists($key) || $this->delete($key); } $path = $this->path($key); $data = $this->wrap($value, $expire); try { if (1 === $this->version) { $this->filesystem->put($path, $data); } else { $this->filesystem->write($path, $data); } } catch (FileExistsException $e) { // v1.x $this->unlock($key); return false; } catch (UnableToWriteFile $e) { // v2.x $this->unlock($key); return false; } return $this->unlock($key); } /** * {@inheritdoc} */ public function setMulti(array $items, $expire = 0) { $success = array(); foreach ($items as $key => $value) { $success[$key] = $this->set($key, $value, $expire); } return $success; } /** * {@inheritdoc} */ public function delete($key) { if (!$this->exists($key)) { return false; } if (!$this->lock($key)) { return false; } $path = $this->path($key); try { $this->filesystem->delete($path); return $this->unlock($key); } catch (FileNotFoundException $e) { // v1.x $this->unlock($key); return false; } catch (UnableToDeleteFile $e) { // v2.x $this->unlock($key); return false; } } /** * {@inheritdoc} */ public function deleteMulti(array $keys) { $success = array(); foreach ($keys as $key) { $success[$key] = $this->delete($key); } return $success; } /** * {@inheritdoc} */ public function add($key, $value, $expire = 0) { if (!$this->lock($key)) { return false; } if ($this->exists($key)) { $this->unlock($key); return false; } $path = $this->path($key); $data = $this->wrap($value, $expire); try { $this->filesystem->write($path, $data); return $this->unlock($key); } catch (FileExistsException $e) { // v1.x $this->unlock($key); return false; } catch (UnableToWriteFile $e) { // v2.x $this->unlock($key); return false; } } /** * {@inheritdoc} */ public function replace($key, $value, $expire = 0) { if (!$this->lock($key)) { return false; } if (!$this->exists($key)) { $this->unlock($key); return false; } $path = $this->path($key); $data = $this->wrap($value, $expire); try { if (1 === $this->version) { $this->filesystem->update($path, $data); } else { $this->filesystem->write($path, $data); } return $this->unlock($key); } catch (FileNotFoundException $e) { // v1.x $this->unlock($key); return false; } catch (UnableToWriteFile $e) { // v2.x $this->unlock($key); return false; } } /** * {@inheritdoc} */ public function cas($token, $key, $value, $expire = 0) { if (!$this->lock($key)) { return false; } $current = $this->get($key); if ($token !== serialize($current)) { $this->unlock($key); return false; } $path = $this->path($key); $data = $this->wrap($value, $expire); try { if (1 === $this->version) { $this->filesystem->update($path, $data); } else { $this->filesystem->write($path, $data); } return $this->unlock($key); } catch (FileNotFoundException $e) { // v1.x $this->unlock($key); return false; } catch (UnableToWriteFile $e) { // v2.x $this->unlock($key); return false; } } /** * {@inheritdoc} */ public function increment($key, $offset = 1, $initial = 0, $expire = 0) { if ($offset <= 0 || $initial < 0) { return false; } return $this->doIncrement($key, $offset, $initial, $expire); } /** * {@inheritdoc} */ public function decrement($key, $offset = 1, $initial = 0, $expire = 0) { if ($offset <= 0 || $initial < 0) { return false; } return $this->doIncrement($key, -$offset, $initial, $expire); } /** * {@inheritdoc} */ public function touch($key, $expire) { if (!$this->lock($key)) { return false; } $value = $this->get($key); if (false === $value) { $this->unlock($key); return false; } $path = $this->path($key); $data = $this->wrap($value, $expire); try { if (1 === $this->version) { $this->filesystem->update($path, $data); } else { $this->filesystem->write($path, $data); } return $this->unlock($key); } catch (FileNotFoundException $e) { // v1.x $this->unlock($key); return false; } catch (UnableToWriteFile $e) { // v2.x $this->unlock($key); return false; } } /** * {@inheritdoc} */ public function flush() { $files = $this->filesystem->listContents('.'); foreach ($files as $file) { try { if ('dir' === $file['type']) { if (1 === $this->version) { $this->filesystem->deleteDir($file['path']); } else { $this->filesystem->deleteDirectory($file['path']); } } else { $this->filesystem->delete($file['path']); } } catch (FileNotFoundException $e) { // v1.x // don't care if we failed to unlink something, might have // been deleted by another process in the meantime... } catch (UnableToDeleteFile $e) { // v2.x // don't care if we failed to unlink something, might have // been deleted by another process in the meantime... } } return true; } /** * {@inheritdoc} */ public function getCollection($name) { /* * A better solution could be to simply construct a new object for a * subfolder, but we can't reliably create a new * `League\Flysystem\Filesystem` object for a subfolder from the * `$this->filesystem` object we have. I could `->getAdapter` and fetch * the path from there, but only if we can assume that the adapter is * `League\Flysystem\Adapter\Local` (1.x) or * `League\Flysystem\Local\LocalFilesystemAdapter` (2.x), which it may not be. * But I can just create a new object that changes the path to write at, * by prefixing it with a subfolder! */ if (1 === $this->version) { $this->filesystem->createDir($name); } else { $this->filesystem->createDirectory($name); } return new Collection($this->filesystem, $name); } /** * Shared between increment/decrement: both have mostly the same logic * (decrement just increments a negative value), but need their validation * split up (increment won't accept negative values). * * @param string $key * @param int $offset * @param int $initial * @param int $expire * * @return int|bool */ protected function doIncrement($key, $offset, $initial, $expire) { $current = $this->get($key, $token); if (false === $current) { $success = $this->add($key, $initial, $expire); return $success ? $initial : false; } // NaN, doesn't compute if (!is_numeric($current)) { return false; } $value = $current + $offset; $value = max(0, $value); $success = $this->cas($token, $key, $value, $expire); return $success ? $value : false; } /** * @param string $key * * @return bool */ protected function exists($key) { $data = $this->read($key); if (false === $data) { return false; } $expire = $data[0]; if (0 !== $expire && $expire < time()) { // expired, don't keep it around $path = $this->path($key); $this->filesystem->delete($path); return false; } return true; } /** * Obtain a lock for a given key. * It'll try to get a lock for a couple of times, but ultimately give up if * no lock can be obtained in a reasonable time. * * @param string $key * * @return bool */ protected function lock($key) { $path = md5($key).'.lock'; for ($i = 0; $i < 25; ++$i) { try { $this->filesystem->write($path, ''); return true; } catch (FileExistsException $e) { // v1.x usleep(200); } catch (UnableToWriteFile $e) { // v2.x usleep(200); } } return false; } /** * Release the lock for a given key. * * @param string $key * * @return bool */ protected function unlock($key) { $path = md5($key).'.lock'; try { $this->filesystem->delete($path); } catch (FileNotFoundException $e) { // v1.x return false; } catch (UnableToDeleteFile $e) { // v2.x return false; } return true; } /** * Times can be: * * relative (in seconds) to current time, within 30 days * * absolute unix timestamp * * 0, for infinity. * * The first case (relative time) will be normalized into a fixed absolute * timestamp. * * @param int $time * * @return int */ protected function normalizeTime($time) { // 0 = infinity if (!$time) { return 0; } // relative time in seconds, <30 days if ($time < 30 * 24 * 60 * 60) { $time += time(); } return $time; } /** * Build value, token & expiration time to be stored in cache file. * * @param string $value * @param int $expire * * @return string */ protected function wrap($value, $expire) { $expire = $this->normalizeTime($expire); return $expire."\n".serialize($value); } /** * Fetch stored data from cache file. * * @param string $key * * @return bool|array */ protected function read($key) { $path = $this->path($key); try { $data = $this->filesystem->read($path); } catch (FileNotFoundException $e) { // v1.x // unlikely given previous 'exists' check, but let's play safe... // (outside process may have removed it since) return false; } catch (UnableToReadFile $e) { // v2.x // unlikely given previous 'exists' check, but let's play safe... // (outside process may have removed it since) return false; } if (false === $data) { // in theory, a file could still be deleted between Flysystem's // assertPresent & the time it actually fetched the content // extremely unlikely though return false; } $data = explode("\n", $data, 2); $data[0] = (int) $data[0]; return $data; } /** * @param string $key * * @return string */ protected function path($key) { return md5($key).'.cache'; } }