<?php
namespace Lia\Addon;
/**
*
* @todo integrate with liaison server to read() from cache & write() to cache before/after request. OR integrate with startup() & shutdown() hooks (which don't currently exist)
* @todo integrate with liaison server to run deletion of stale cache files
* @todo write tests for liaison integration
*/
class Cache extends \Lia\Addon {
public string $fqn = 'lia:server.cache';
/**
* The dir to write cache files to
*/
public $dir;
public bool $enabled = true;
/**
* Number of minutes between stale cache checks
*/
public int $stale_check_frequency = 60*24;
/**
* The main file to write to & read from
*/
public $main = 'main.php';
/**
* Key=>values that get written to disk in a single file (call $cache->write())
*/
public array $props = [];
public function __construct($package=null, $name=null){
parent::__construct($package,$name);
// $lia = $this->lia;
// $this->dir = &$this->props['dir'];
// $this->main = &$this->props['main'];
// $this->props['enabled'] = $this->props['enabled'] ?? $this->enabled;
// $this->enabled = &$this->props['enabled'];
}
public function init_lia(){
$lia = $this->lia;
$lia->methods['cache'] = [$this, 'set'];
$lia->methods['cache_get'] = [$this, 'get'];
$lia->methods['cache_write'] = [$this, 'write'];
$lia->methods['cache_read'] = [$this, 'read'];
$lia->methods['cache_file'] = [$this, 'cache_file'];
$lia->methods['cache_get_file'] = [$this, 'get_cache_file_content'];
}
/**
* @return absolute path to the main key=>value file
*/
public function main_file_path(){
return $this->dir.'/'.$this->main;
}
/**
* reads main key=>value pairs from disk into memory
*/
public function read(){
$this->props = include($this->main_file_path());
}
/**
* Write main key=>value pairs to disk from memory
*/
public function write(){
file_put_contents($this->main_file_path(), '<?php return '.var_export($this->props,true).';');
}
// public function onPackageReady(){
// parent::onPackageReady();
// if ($this->lia->hasApi('lia:config.default')){
// $this->lia->api('lia:config.default', 'lia:cache.dir', $this->package->dir('cache'));
// // delete stuff every 5 days
// $this->lia->api('lia:config.default', 'lia:cache.clearAfterXMinutes', 60*24*5);
// $this->lia->api('lia:config.default', 'lia:cache.enable', true);
// }
// }
//
// public function onEmitResponseSent(){
// if ($this->isStaleCacheCheckRequired()){
// $this->deleteStaleCacheFiles();
// }
// }
/**
* Check if: cache is enabled, cached file exists, cached file is NOT expired
* @param $key the key identifying the file
* @return boolean true or false (valid or not valid)
*/
public function is_cache_file_valid($key){
if (!$this->enabled)return false;
$file = $this->cache_file_path($key);
$meta = $this->cache_meta_file_path($key);
if (!file_exists($meta)
||!file_exists($file))return false;
$conf = json_decode(file_get_contents($meta),true);
if (time()>$conf['expiry'])return false;
return true;
}
/**
* Get content of a cached file, if cache is valid
* @param $key the key identifying the file
* @return string content of file or false if file not cached
*
* @see is_cache_file_valid()
*/
public function get_cache_file_content($key){
$file = $this->get_cache_file_path($key);
if ($file==false)return false;
return file_get_contents($file);
}
/**
* Get path to cached file if cache is valid
* @param $key the key identifying the file
* @return string|boolean path to cached file (if cache valid) or false
*
* @see is_cache_file_valid()
* @todo auto-delete stale cache files (depending on a config)
*/
public function get_cache_file_path($key){
if ($this->is_cache_file_valid($key))return $this->cache_file_path($key);
return false;
}
/**
* Get the path for a cached file
*
* @param $key the key identifying the file
* @return absolute file path for the key or false if dir is not set.
*
* @note Does not check if cache is valid
* @see is_cache_file_valid()
* @see get_cache_file_path() for a safe version that checks cache state
*/
public function cache_file_path($key){
if ($this->dir==null){
// throw new \Exception("Set server.cache.dir in order to use cache. You must do this before creating the server instance ... for some reason ...");
return false;
}
return $this->dir.'/'.$key.'.file';
}
/**
* get the path to a meta file, which contains expiration time of cached files
* @note Does not check if cache is enabled, existence of file or meta file, or expiration of file
* @param $key the key identifying the file/meta file
* @return absolute file path (to the meta file) for the key or false if dir is not set.
*/
public function cache_meta_file_path($key){
if ($this->dir==null)return false;
return $this->dir.'/'.$key.'.meta';
}
/**
* Store $value to file by identified by $key
* @param $key the key identifying the file
* @param $value the value to store
* @param $maxAge number of seconds before the value expires
* @return path to the file or false if cache is disabled
* @note This implementation is likely inefficient (not benchmarked)
* @note Creates the cache directory if it does not exist. This feature may be removed in the future
*
* @usage(Delete a file) `cache_file('some.key', $any_value, -1);`: the -1 expires the cache.
*/
public function cache_file($key, $value, $maxAge=60*60*24*5){
$file = $this->cache_file_path($key);
if ($file==false || !$this->enabled)return;
$meta_file = $this->cache_meta_file_path($key);
$dir = dirname($file);
$this->create_dir($dir);
file_put_contents($file,$value);
$expiry = time() + $maxAge;
$meta = [
'expiry'=>$expiry,
];
file_put_contents($meta_file,json_encode($meta));
return $file;
}
/**
* Delete stale cache files IF a stale cache check is required
* @see is_stale_cache_check_required()
* @return boolean true if it runs, false otherwise
*/
public function delete_stale_files(){
if ($this->is_stale_cache_check_required()){
$this->run_delete_stale_files();
return true;
}
return false;
}
/**
* Delete stale cache files (unconditional)
* @see delete_stale_files() for conditional deletion (bc you don't want to do this every request)
* @todo implement faster method for extracting expiry from meta files, such as using substring with fixed lengths or using a different format altogether
*/
public function run_delete_stale_files(){
$dir = $this->dir;
// using opendir() over scandir() to save RAM, in case cache dir gets a HUGE list of files
// just safer this way
$dh = opendir($dir);
$openLen = strlen('{"expiry":');
$endLen = -1 * strlen('}');
$now = time();
while (($f = readdir($dh)) !== false){
if ($f=='.'||$f=='..')continue;
$tail = substr($f,-5);
if ($tail == '.meta')continue;
$meta_file = substr($f,0,-5).'.meta'; // remove '.file' & put '.meta'
$expiry = json_decode(file_get_contents($dir.'/'.$meta_file),true)['expiry']??0;
if ($expiry>$now)continue;
unlink($dir.'/'.$meta_file);
unlink($dir.'/'.$f);
}
closedir($dh);
}
/**
* Deletes ALL cache files, whether expired or not
*/
public function delete_all_cache_files(){
$dir = $this->dir;
// using opendir() over scandir() to save RAM, in case cache dir gets a HUGE list of files
// just safer this way
if (!is_dir($dir))return;
$dh = opendir($dir);
while ($dh!==false && ($f = readdir($dh)) !== false){
if ($f=='.'||$f=='..')continue;
unlink($dir.'/'.$f);
}
closedir($dh);
}
/**
* check if stale cache check is required
* @see $stale_check_frequency
*/
public function is_stale_cache_check_required(){
$frequency = $this->stale_check_frequency;
$seconds = $frequency * 60;
$last_check = $this->get('cache.last_stale_check', 0);
if ($last_check + $seconds > time())return false;
return true;
}
// protected function isStaleCacheCheckRequired(){
// // get cache-clearing frequency (lia config)
// $frequencyInMinutes = $this->lia->api('lia:config.get', 'lia:cache.clearAfterXMinutes');
// $seconds = $frequencyInMinutes*60;
// // check when last cache-clear was performed
// $path = $this->lia->getCachedFilePath('lia.Cache.LastCleanupCheck');
// if ($path==null||!is_file($path)){
// $lastClear = 0;
// } else if (is_file($path)){
// $content = file_get_contents($path);
// $content = trim($content);
// $lastClear = (int)$content;
// } else {
// throw new \Lia\Exception("Could not load cache frequency file.");
// }
// // if enough time has elapsed, clear the cache
// if ($lastClear + $seconds > time())return false;
// return true;
// }
protected function deleteStaleCacheFiles(){
$this->lia->cacheFile('lia.Cache.LastCleanupCheck', time(), 60*60*24*180);
// $dir = $this->lia->api('lia:config.get', 'lia:cache.dir');
$dir = $this->dir;
if (!is_dir($dir))throw new \Lia\Exception("The cache directory '{$dir}' does not exist, thus cannot be checked for stale files.");
$dh = opendir($dir);
$openLen = strlen('{"expiry":');
$endLen = -1 * strlen('}');
$now = time();
while (($f = readdir($dh)) !== false){
if (substr($f,0,5)!='meta-')continue;
$content = trim(file_get_contents($dir.'/'.$f));
$expiresAt = substr($content, $openLen, $endLen);
if ($expiresAt>$now)continue;
$actualFile = $dir.'/file-'.substr($f, 5);
if (is_file($actualFile))unlink($actualFile);
//delete the meta file
unlink($dir.'/'.$f);
}
closedir($dh);
}
/**
* Creates directory if it does not exist.
* @param $dir string directory path
*
* @error possible recursive while loop if there is no directory in the path given ... maybe with windows paths like is_dir('C:') ... Or if is_dir('/') in linux/mac returns false for some reason
*/
public function create_dir($dir){
if (is_dir($dir))return;
$parent = $dir;
// get the permissions of the parent directory
do {
$parent = dirname($parent);
} while (!is_dir($parent));
$perms = fileperms($parent);
mkdir($dir, $perms, true);
}
public function set($key, $value){
$this->props[$key] = $value;
}
/**
* @return false if key not found, or the cached value */
public function get($key){
return $this->props[$key] ?? false;
}
public function __set($key,$value){
$this->props[$key] = $value;
}
public function __get($key){
return $this->props[$key];
}
}