bnomei/kirby3-php-cachedriver

View on GitHub
classes/PHPCache.php

Summary

Maintainability
D
1 day
Test Coverage
<?php

declare(strict_types=1);

namespace Bnomei;

use Exception;
use Kirby\Cache\FileCache;
use Kirby\Cache\Value;
use Kirby\Cms\Dir;
use Kirby\Toolkit\A;
use Kirby\Filesystem\F;
use Kirby\Toolkit\Str;

final class PHPCache extends FileCache
{
    private $shutdownCallbacks = [];

    /** @var array $database */
    private $database;

    /** @var int $isDirty */
    private $isDirty;

    public const DB_FILENAME = 'phpcache-mono';
    public const FILE_SALT = 'cHas@PH9uy1!';

    public function __construct(array $options = [])
    {
        $this->setOptions($options);

        parent::__construct($this->options);

        if ($this->option('check_opcache')) {
            $this->check_opcache();
        }

        $this->isDirty = 0;
        if ($this->option('debug')) {
            $this->flush();
        }
        $this->load();
        $this->garbagecollect();
    }

    public function __destruct()
    {
        foreach ($this->shutdownCallbacks as $callback) {
            if (!is_string($callback) && is_callable($callback)) {
                $callback();
            }
        }
        $this->writeMono();
    }

    public function check_opcache(): bool
    {
        if ($this->option('opcache.enable') !== true) {
            throw new Exception("PHP Cache Driver expects opcache to be enabled");
            return false;
        }

        // ignore in CLI because thats the cache is flushed for each CLI call anyway
        /*
        if (php_sapi_name() === "cli" && $this->option('opcache.enable_cli') !== true) {
            // throw new Exception("PHP Cache Driver expects opcache to be enabled");
            return false;
        }
        */

        if ($this->option('opcache.validate_timestamps') !== true) {
            throw new Exception("PHP Cache Driver expects 'opcache.validate_timestamps=1'");
            return false;
        }

        if ($this->option('opcache.revalidate_freq') !== 0) {
            throw new Exception("PHP Cache Driver expects 'opcache.revalidate_freq=0'");
            return false;
        }

        return true;
    }

    public function register_shutdown_function($callback)
    {
        $this->shutdownCallbacks[] = $callback;
    }

    public function garbagecollect(): bool
    {
        $count = 0;
        foreach (array_keys($this->database) as $key) {
            $expires = null;
            $data = $this->database[$key];
            if ($data) {
                $expires = Value::fromArray($data)->expires();
            }
            if (! $data || ($expires && $expires < time())) {
                $this->remove($key, true);
                $count++;
            }
        }
        return $count > 0;
    }

    /**
     * @param string|null $key
     * @return array
     */
    public function option(?string $key = null)
    {
        if ($key) {
            return A::get($this->options, $key);
        }
        return $this->options;
    }

    private function load()
    {
        $this->database = [];

        $monoFile = $this->file(static::DB_FILENAME);

        // load mono
        $this->database = F::exists($monoFile) ? include $monoFile : [];
        // read partials...
        $hadPartials = false;
        foreach (Dir::files($this->root()) as $file) {
            if (F::filename($file) !== F::filename($monoFile) &&
                F::extension($file) === $this->option('extension')) {
                $data = include $this->root() . '/' . $file;
                foreach ($data as $key => $value) {
                    $this->database[$key] = $value;
                }
                // ...remove the partial
                unlink($this->root() . '/' . $file);
                $hadPartials = true;
            }
        }
        if ($hadPartials) {
            $this->writeMono();
        }
    }

    public function toArray(): array
    {
        return $this->database;
    }

    /**
     * @inheritDoc
     */
    public function set(string $key, $value, int $minutes = 0): bool
    {
        // SHOULD SET EVEN IN DEBUG
        /*
        if ($this->option('debug')) {
            return true;
        }
        */

        return $this->removeAndSet($key, $value, $minutes);
    }

    private function removeAndSet(string $key, $value, int $minutes = 0): bool
    {
        $this->remove($key);

        $key = $this->key($key);
        $value = new Value($value, $minutes);
        $data = null;

        // serialize
        if ($this->option('serialize') === 'json') {
            $data = json_decode($value->toJson(), true);
            // if encoding failed try to write raw values
            if ($data === null && json_last_error() !== JSON_ERROR_NONE) {
                $data = $this->serialize($value->toArray());
            }
        } else {
            $data = $this->serialize($value->toArray());
        }

        $this->database[$key] = $data;
        $this->isDirty += 1;

        if ($this->isDirty > $this->option('mono_dump')) {
            $this->writeMono();
        } else {
            $this->write($key, $data);
        }

        return true;
    }

    private static function isCallable($value): bool
    {
        // do not call global helpers just methods or closures
        return !is_string($value) && is_callable($value);
    }

    public function serialize($value)
    {
        if (! $value) {
            return null;
        }
        $value = self::isCallable($value) ? $value() : $value;


        if (is_a($value, 'Kirby\Cms\Field')) {
            $value = $value->value();
        }
        // Kirby\Cms\Content
        // Kirby\Toolkit\Obj
        elseif (is_object($value) && method_exists($value, 'toArray')) {
            $value = $value->toArray();
        }

        if (is_array($value)) {
            $items = [];
            foreach ($value as $key => $item) {
                $items[$key] = $this->serialize($item);
            }
            return $items;
        }

        return $value;
    }

    protected function file(string $key): string
    {
        $file = $this->root . '/' . strval(crc32(($key . static::FILE_SALT)));

        if (isset($this->options['extension'])) {
            return $file . '.' . $this->options['extension'];
        } else {
            return $file;
        }
    }

    private function write($key, $data): bool
    {
        $file = $this->file($key);
        $success = file_put_contents(
                $file,
                '<?php' . PHP_EOL .' return ' . var_export([$key => $data], true) . ';',
                LOCK_EX
            ) !== false;
        opcache_invalidate(__FILE__);
        opcache_invalidate($file);

        return $success;
    }

    public function writeMono(): bool
    {
        if ($this->isDirty > 0) {
            $this->isDirty = 0;
            $file = $this->file(static::DB_FILENAME);
            $success = file_put_contents(
                    $file,
                    '<?php' . PHP_EOL .' return ' . var_export($this->database, true) . ';',
                    LOCK_EX
                ) !== false;
            opcache_invalidate(__FILE__);
            opcache_invalidate($file);

            return $success;
        }
        return true;
    }

    /**
     * @inheritDoc
     */
    public function retrieve(string $key): ?Value
    {
        $key = $this->key($key);
        $value = A::get($this->database, $key);

        return $value ? Value::fromArray($value) : null;
    }

    public function get(string $key, $default = null)
    {
        if ($this->option('debug')) {
            return $default;
        }

        $value = $this->retrieve($key);

        return $value ? $value->value() : $default;
    }

    /**
     * @inheritDoc
     */
    public function remove(string $key, bool $hasPrefix = false): bool
    {
        if (! $hasPrefix) {
            $key = $this->key($key);
        }

        if (array_key_exists($key, $this->database)) {
            unset($this->database[$key]);
            $this->isDirty += 1;
        }

        $file = $this->file($key);
        if (F::exists($file)) {
            return unlink($file);
        }

        return true;
    }

    /**
     * @inheritDoc
     */
    public function flush(): bool
    {
        $this->database = [];
        $this->isDirty = 0;
        return Dir::remove($this->root()) && Dir::make($this->root());
    }

    private static $singleton;
    public static function singleton(array $options = []): self
    {
        if (self::$singleton) {
            return self::$singleton;
        }
        self::$singleton = new self($options);
        return self::$singleton;
    }


    private function setOptions(array $options)
    {
        $root = null;
        $cache = kirby()->cache('bnomei.php-cachedriver');
        if (is_a($cache, FileCache::class)) {
            $root = A::get($cache->options(), 'root');
            if ($prefix =  A::get($cache->options(), 'prefix')) {
                $root .= '/' . $prefix;
            }
        } else {
            $root = kirby()->roots()->cache();
        }

        $this->options = array_merge([
            'root' => $root,
            'debug' => \option('debug'),
            'mono_dump' => intval(\option('bnomei.php-cachedriver.mono_dump')),
            'check_opcache' => \option('bnomei.php-cachedriver.check_opcache'),
            'serialize' => \option('bnomei.php-cachedriver.serialize'),
        ], $options);

        // overwrite *.cache in all constructors
        $this->options['extension'] = 'php';

        // opcache
        if ($this->options['check_opcache'] && $oc = opcache_get_configuration()) {
            if ($directives = A::get($oc, 'directives')) {
                $this->options['opcache.enable'] = A::get($directives, 'opcache.enable');
                $this->options['opcache.enable_cli'] = A::get($directives, 'opcache.enable_cli');
                $this->options['opcache.validate_timestamps'] = A::get($directives, 'opcache.validate_timestamps');
                $this->options['opcache.revalidate_freq'] = A::get($directives, 'opcache.revalidate_freq');
            }
        }
    }

    public function benchmark(int $count = 10)
    {
        $prefix = "elephant-benchmark-";
        $php = $this;
        $file = kirby()->cache('bnomei.php-cachedriver'); // neat, right? ;-)

        foreach (['php' => $php, 'file' => $file] as $label => $driver) {
            $time = microtime(true);
            for ($i = 0; $i < $count; $i++) {
                $key = $prefix . $i;
                if (! $driver->get($key)) {
                    $driver->set($key, Str::random(1000));
                }
            }
            if ($label === 'php') {
                $driver->writeMono();
            }
            for ($i = $count * 0.6; $i < $count * 0.8; $i++) {
                $key = $prefix . $i;
                $driver->remove($key);
            }
            if ($label === 'php') {
                $driver->writeMono();
            }
            for ($i = $count * 0.8; $i < $count; $i++) {
                $key = $prefix . $i;
                $driver->set($key, Str::random(1000));
            }
            if ($label === 'php') {
                $driver->writeMono();
            }
            for ($i = 0; $i < $count; $i++) {
                $key = $prefix . $i;
                if (! $driver->get($key)) {
                    // just read all again
                }
            }
            echo $label . ' : ' . (microtime(true) - $time) . PHP_EOL;
        }

        // cleanup
        for ($i = 0; $i < $count; $i++) {
            $key = $prefix . $i;
            $driver->remove($key);
        }
    }
}