nur-sery/nur_src/b/io/FileCachedValue.php

300 lines
8.1 KiB
PHP

<?php
namespace nur\b\io;
use ArrayAccess;
use Countable;
use Exception;
use Generator;
use nur\A;
use nur\b\coll\TBaseArray;
use nur\b\IllegalAccessException;
use nur\b\params\Parametrable;
use nur\b\params\Tparametrable;
use nur\data\types\Tmd;
use nur\file;
use nur\os;
use nur\sery\php\time\DateTime;
use nur\sery\php\time\Delay;
/**
* Class FileCachedValue: un fichier utilisé pour mettre en cache certaines
* données. les données sont (re)calculée si la durée de vie du cache est
* dépassée
*/
abstract class FileCachedValue extends Parametrable implements ArrayAccess, Countable {
use Tparametrable, TBaseArray, Tmd;
/** @var string chemin vers le fichier cache par défaut */
const FILE = null;
/** @var int durée de vie par défaut du cache */
const DURATION = "1D"; // jusqu'au lendemain
/** @var bool faut-il mettre en cache la valeur nulle? */
const CACHE_NULL = false;
/** @var array schéma des données supplémentaires */
const SCHEMA = null;
function __construct(?array $params=null) {
self::set_parametrable_params_defaults($params, [
"file" => static::FILE,
"duration" => static::DURATION,
"cache_null" => static::CACHE_NULL,
]);
[$params, $data] = $this->splitParametrableParams($params);
if ($data) A::merge($params["data"], $data);
parent::__construct($params);
}
const PARAMETRABLE_PARAMS_SCHEMA = [
"file" => ["?string", null, "chemin vers le fichier"],
"duration" => [Delay::class, null, "durée de vie du cache"],
"override_duration" => ["bool", false, "faut-il ignorer la duration inscrite dans le fichier et prendre la valeur locale?"],
"cache_null" => ["bool", false, "faut-il mettre en cache la valeur null?"],
"data" => ["array", null, "données supplémentaires"]
];
protected $ppFile;
function getFile(): string {
$cacheFile = $this->ppFile;
if ($cacheFile === null) {
$rand = bin2hex(random_bytes(8));
$this->ppFile = $cacheFile = sys_get_temp_dir()."/$rand.cache";
}
return $cacheFile;
}
/** @var Delay */
protected $ppDuration;
/** @var bool */
protected $ppOverrideDuration;
protected $ppCacheNull;
function pp_setData($data): void {
$md = $this->md();
if ($md !== null) $md->ensureSchema($data);
$this->data = $data;
}
/** calculer la donnée */
protected abstract function compute();
/** sérialiser la donnée avant de l'enregistrer */
protected function serialize($data): string {
return serialize($data);
}
/** enregistrer la donnée dans le fichier spécifié */
protected function save($data, string $file): void {
$outf = file::open($file, "wb");
try {
$contents = $this->serialize($data);
$tstart = new DateTime();
$duration = Delay::with($this->ppDuration, $tstart);
fwrite($outf, serialize([$tstart, $duration])."\n");
fwrite($outf, $contents);
} finally {
fclose($outf);
}
}
/** désérialiser la données depuis le contenu spécifié */
protected function unserialize(string $contents) {
return unserialize($contents);
}
/** charger la donnée depuis le fichier spécifié */
protected function load(string $file) {
$inf = file::open($file, "rb");
try {
fgets($inf); // sauter TSDATA
$contents = stream_get_contents($inf);
return $this->unserialize($contents);
} finally {
fclose($inf);
}
}
protected $fp;
protected function _open(): void {
$file = $this->getFile();
IOException::ensure_not_false(os::mkdirof($file));
$this->fp = IOException::ensure_not_false(fopen($file, "c+b"));
}
protected function _lock(int $operation): void {
if (!flock($this->fp, $operation)) {
switch ($operation) {
case LOCK_SH: $msg = "sh_lock"; break;
case LOCK_EX: $msg = "ex_lock"; break;
}
throw IllegalAccessException::unexpected_state($msg);
}
}
/**
* vérifier si le fichier est valide. s'il est invalide, il faut le recréer.
*
* on assume que le fichier existe, vu qu'il a été ouvert en c+b
*/
protected function isValidFile(): bool {
# on considère que le fichier est invalide s'il est de taille nulle
return filesize($this->getFile()) > 0;
}
protected function loadInfos(): array {
[$tstart, $duration] = unserialize(fgets($this->fp));
if (is_int($tstart)) {
$tstart = new DateTime($tstart);
$duration = new Delay($duration, $tstart);
}
if ($this->ppOverrideDuration) {
$duration = Delay::with($this->ppDuration, $tstart);
}
return [$tstart, $duration];
}
protected function shouldUpdate(bool $noCache=false) {
/** @var Delay $duration */
$this->_open();
$cleanup = true;
try {
$this->_lock(LOCK_SH);
if ($this->isValidFile()) {
[$tstart, $duration] = $this->loadInfos();
$expired = $duration->isElapsed();
} else {
$expired = false;
$noCache = true;
}
if ($noCache || $expired) {
$this->_lock(LOCK_EX);
$cleanup = false;
return true;
}
} finally {
if ($cleanup) $this->cleanup();
}
return false;
}
/**
* indiquer si la valeur calculée doit être mise en cache. par défaut, la
* valeur nulle n'est pas mise en cache.
*/
protected function shouldCache($value): bool {
return $this->ppCacheNull || $value !== null;
}
protected function cleanup(): void {
if ($this->fp !== null) {
fclose($this->fp);
$this->fp = null;
}
}
/**
* obtenir la donnée.
*
* si la donnée a déjà été mise en cache au préalable, et que la durée de
* validité du cache n'est pas dépassée, retourner la valeur mise en cache.
* sinon, (re)calculer la donnée
*
* @param bool $noCache ne pas utiliser le cache (forcer le recalcul)
*/
function get(bool $noCache=false) {
try {
if ($this->shouldUpdate($noCache)) {
try {
$data = $this->compute();
if ($data instanceof Generator) $data = iterator_to_array($data);
if ($this->shouldCache($data)) {
$this->save($data, $this->ppFile);
} else {
# ne pas garder le fichier s'il ne faut pas mettre en cache
unlink($this->ppFile);
}
} catch (Exception $e) {
// si une erreur se produit, ne pas garder le fichier
unlink($this->ppFile);
throw $e;
}
} else {
$data = $this->load($this->ppFile);
}
return $data;
} finally {
$this->cleanup();
}
}
function getInfos(): array {
$this->_open();
try {
$this->_lock(LOCK_SH);
if (!$this->isValidFile()) return ["valid" => false];
$size = filesize($this->ppFile);
/**
* @var DateTime $tstart
* @var Delay $duration
*/
[$tstart, $duration] = $this->loadInfos();
return [
"valid" => true,
"size" => $size,
"tstart" => $tstart,
"duration" => strval($duration),
"date_start" => $tstart->format(),
"date_end" => $duration->getDest()->format(),
];
} finally {
$this->cleanup();
}
}
const UPDATE_SUB = -1, UPDATE_SET = 0, UPDATE_ADD = 1;
/** mettre à jour la durée de validité du fichier */
function updateDuration($nduration, int $action=1): void {
$this->_open();
try {
$this->_lock(LOCK_SH);
if (!$this->isValidFile()) return;
$this->_lock(LOCK_EX);
$fp = $this->fp;
/**
* @var DateTime $tstart
* @var Delay $duration
*/
[$tstart, $duration] = $this->loadInfos();
$contents = stream_get_contents($fp);
if ($action < 0) $duration->subDuration($nduration);
elseif ($action > 0) $duration->addDuration($nduration);
fseek($fp, 0);
ftruncate($fp, 0);
fwrite($fp, serialize([$tstart, $duration])."\n");
fwrite($fp, $contents);
} finally {
$this->cleanup();
}
}
/** supprimer le fichier s'il a expiré */
function deleteExpired(): bool {
try {
if ($this->shouldUpdate()) {
unlink($this->ppFile);
return true;
}
} finally {
$this->cleanup();
}
return false;
}
}