lithium\storage\cache\adapter\File

class

A minimal file-based cache.

The File adapter is a very simple cache, and should only be used for prototyping or for specifically caching files in conjunction with the 'streams' configuration option. For more general caching needs, please consider using a more appropriate cache adapter.

This adapter has no external dependencies. Operations in read/write/delete are atomic for single-keys only. Clearing the cache is supported. Real persistence of cached items is provided. Increment/decrement functionality is provided but only in a non-atomic way.

This adapter can't handle serialization natively. Scope support is available but not natively.

A simple configuration can be accomplished as follows:

Cache::config([
    'default' => [
        'adapter' => 'File',
        'strategies => ['Serializer']
     ]
]);

The path that the cached files will be written to defaults to <app>/resources/tmp/cache, but is user-configurable.

Note that the cache expiration time is stored within the first few bytes of the cached data, and is transparently added and/or removed when values are stored and/or retrieved from the cache.

Source

class File extends \lithium\storage\cache\Adapter {

	/**
	 * The maximum line length of the file header storing meta data.
	 *
	 * @var integer
	 */
	const MAX_HEADER_LENGTH = 500;

	/**
	 * Constructor.
	 *
	 * @see lithium\storage\Cache::config()
	 * @param array $config Configuration for this cache adapter. These settings are queryable
	 *        through `Cache::config('name')`. The available options are as follows:
	 *        - `'scope'` _string_: Scope which will prefix keys; per default not set.
	 *        - `'expiry'` _mixed_: The default expiration time for cache values, if no value
	 *          is otherwise set. Can be either a `strtotime()` compatible tring or TTL in
	 *          seconds. To indicate items should not expire use `Cache::PERSIST`. Defaults
	 *          to `+1 hour`.
	 *        - `'path'` _string_: Path where cached entries live, defaults to
	 *          `Libraries::get(true, 'resources') . '/tmp/cache'`.
	 *        - `'streams'`: When enabled (by default disabled) read operations will return
	 *          stream handles instead of the value itself. This is useful when reading
	 *          BLOBs.
	 * @return void
	 */
	public function __construct(array $config = []) {
		$defaults = [
			'path' => Libraries::get(true, 'resources') . '/tmp/cache',
			'scope' => null,
			'expiry' => '+1 hour',
			'streams' => false
		];
		parent::__construct($config + $defaults);
	}

	/**
	 * Generates safe cache keys.
	 *
	 * Keys should be safe to be used as filename. So we conservatively disallalow
	 * any non alphanumeric characters with the exception of dash und underscore.
	 *
	 * We also limit to max. 255 characters. The limit is actually lowered
	 * to 255 minus the length of an crc32b hash minus separator (246)
	 * minus scope length minus separator (246 - x).
	 *
	 * 255 was chosen as most commonly used filesystems (ext2-4, HFS+,
	 * NTFS, XFS, FAT32, btrfs) limit filename characters to a length of
	 * 255.
	 *
	 * @link https://en.wikipedia.org/wiki/Comparison_of_file_systems#Limits
	 * @param array $keys The original keys.
	 * @return array Keys modified and safe to use with adapter.
	 */
	public function key(array $keys) {
		$length = 246 - ($this->_config['scope'] ? strlen($this->_config['scope']) + 1 : 0);

		return array_map(
			function($key) use ($length) {
				$result = substr(preg_replace('/[^a-z0-9_\-]/iu', '_', $key), 0, $length);
				return $result !== $key ? $result . '_' . hash('crc32b', $key) : $result;
			},
			$keys
		);
	}

	/**
	 * Write values to the cache. All items to be cached will receive an
	 * expiration time of `$expiry`.
	 *
	 * @param array $keys Key/value pairs with keys to uniquely identify the to-be-cached item.
	 * @param string|integer $expiry A `strtotime()` compatible cache time or TTL in seconds.
	 *                       To persist an item use `\lithium\storage\Cache::PERSIST`.
	 * @return boolean `true` on successful write, `false` otherwise.
	 */
	public function write(array $keys, $expiry = null) {
		$expiry = $expiry || $expiry === Cache::PERSIST ? $expiry : $this->_config['expiry'];

		if (!$expiry || $expiry === Cache::PERSIST) {
			$expires = 0;
		} elseif (is_int($expiry)) {
			$expires = $expiry + time();
		} else {
			$expires = strtotime($expiry);
		}
		if ($this->_config['scope']) {
			$keys = $this->_addScopePrefix($this->_config['scope'], $keys, '_');
		}
		foreach ($keys as $key => $value) {
			if (!$this->_write($key, $value, $expires)) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Read values from the cache. Will attempt to return an array of data
	 * containing key/value pairs of the requested data.
	 *
	 * Invalidates and cleans up expired items on-the-fly when found.
	 *
	 * @param array $keys Keys to uniquely identify the cached items.
	 * @return array Cached values keyed by cache keys on successful read,
	 *               keys which could not be read will not be included in
	 *               the results array.
	 */
	public function read(array $keys) {
		if ($this->_config['scope']) {
			$keys = $this->_addScopePrefix($this->_config['scope'], $keys, '_');
		}
		$results = [];

		foreach ($keys as $key) {
			if (!$item = $this->_read($key, $this->_config['streams'])) {
				continue;
			}
			if ($item['expiry'] < time() && $item['expiry'] != 0) {
				$this->_delete($key);
				continue;
			}
			$results[$key] = $item['value'];
		}
		if ($this->_config['scope']) {
			$results = $this->_removeScopePrefix($this->_config['scope'], $results, '_');
		}
		return $results;
	}

	/**
	 * Will attempt to remove specified keys from the user space cache.
	 *
	 * @param array $keys Keys to uniquely identify the cached items.
	 * @return boolean `true` on successful delete, `false` otherwise.
	 */
	public function delete(array $keys) {
		if ($this->_config['scope']) {
			$keys = $this->_addScopePrefix($this->_config['scope'], $keys, '_');
		}
		foreach ($keys as $key) {
			if (!$this->_delete($key)) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Performs a decrement operation on a specified numeric cache item.
	 *
	 * @param string $key Key of numeric cache item to decrement.
	 * @param integer $offset Offset to decrement - defaults to `1`.
	 * @return integer|boolean The item's new value on successful decrement, else `false`.
	 */
	public function decrement($key, $offset = 1) {
		if ($this->_config['scope']) {
			$key = "{$this->_config['scope']}_{$key}";
		}
		if (!$result = $this->_read($key)) {
			return false;
		}
		if (!$this->_write($key, $result['value'] -= $offset, $result['expiry'])) {
			return false;
		}
		return $result['value'];
	}

	/**
	 * Performs an increment operation on a specified numeric cache item.
	 *
	 * @param string $key Key of numeric cache item to increment
	 * @param integer $offset Offset to increment - defaults to `1`.
	 * @return integer|boolean The item's new value on successful increment, else `false`.
	 */
	public function increment($key, $offset = 1) {
		if ($this->_config['scope']) {
			$key = "{$this->_config['scope']}_{$key}";
		}
		if (!$result = $this->_read($key)) {
			return false;
		}
		if (!$this->_write($key, $result['value'] += $offset, $result['expiry'])) {
			return false;
		}
		return $result['value'];
	}

	/**
	 * Clears entire cache by flushing it. Please note
	 * that a scope - in case one is set - is *not* honored.
	 *
	 * The operation will continue to remove keys even if removing
	 * one single key fails, clearing thoroughly as possible.
	 *
	 * @return boolean `true` on successful clearing, `false` if failed partially or entirely.
	 */
	public function clear() {
		$result = true;
		foreach (new DirectoryIterator($this->_config['path']) as $file) {
			if (!$file->isFile()) {
				continue;
			}
			$result = $this->_delete($file->getBasename()) && $result;
		}
		return $result;
	}

	/**
	 * Cleans entire cache running garbage collection on it. Please
	 * note that a scope - in case one is set - is *not* honored.
	 *
	 * The operation will continue to remove keys even if removing
	 * one single key fails, cleaning thoroughly as possible.
	 *
	 * @return boolean `true` on successful cleaning, `false` if failed partially or entirely.
	 */
	public function clean() {
		$result = true;
		foreach (new DirectoryIterator($this->_config['path']) as $file) {
			if (!$file->isFile()) {
				continue;
			}
			if (!$item = $this->_read($key = $file->getBasename())) {
				continue;
			}
			if ($item['expiry'] > time()) {
				continue;
			}
			$result = $this->_delete($key) && $result;
		}
		return $result;
	}

	/**
	 * Compiles value to format and writes file.
	 *
	 * @see lithium\storage\cache\adapter\File::write()
	 * @param string $key Key to uniquely identify the cached item.
	 * @param mixed $value Value or resource with value to store under given key.
	 * @param integer $expires UNIX timestamp after which the item is invalid.
	 * @return boolean `true` on success, `false` otherwise.
	 */
	protected function _write($key, $value, $expires) {
		$path = "{$this->_config['path']}/{$key}";

		if (!$stream = fopen($path, 'wb')) {
			return false;
		}
		fwrite($stream, "{:expiry:{$expires}}\n");

		if (is_resource($value)) {
			stream_copy_to_stream($value, $stream);
		} else {
			fwrite($stream, $value);
		}
		return fclose($stream);
	}

	/**
	 * Reads from file, parses its format and returns its expiry and value.
	 *
	 * @see lithium\storage\cache\adapter\File::read()
	 * @param string $key Key to uniquely identify the cached item.
	 * @param boolean $streams When `true` will return stream handle instead of value.
	 * @return array|boolean Array with `expiry` and `value` or `false` otherwise.
	 */
	protected function _read($key, $streams = false) {
		$path = "{$this->_config['path']}/{$key}";

		if (!is_file($path) || !is_readable($path)) {
			return false;
		}
		if (!$stream = fopen($path, 'rb')) {
			return false;
		}
		$header = stream_get_line($stream, static::MAX_HEADER_LENGTH, "\n");

		if (!preg_match('/^\{\:expiry\:(\d+)\}/', $header, $matches)) {
			return false;
		}
		if ($streams) {
			$value = fopen('php://temp', 'wb');
			stream_copy_to_stream($stream, $value);
			rewind($value);
		} else {
			$value = stream_get_contents($stream);
		}
		fclose($stream);

		return ['expiry' => $matches[1], 'value' => $value];

	}

	/**
	 * Deletes a file using the corresponding cached item key.
	 *
	 * @see lithium\storage\cache\adapter\File::delete()
	 * @param string $key Key to uniquely identify the cached item.
	 * @return boolean `true` on success, `false` otherwise.
	 */
	protected function _delete($key) {
		$path = "{$this->_config['path']}/{$key}";
		return is_readable($path) && is_file($path) && unlink($path);
	}
}