├── src ├── Caching │ ├── BulkReader.php │ ├── BulkWriter.php │ ├── Storages │ │ ├── Journal.php │ │ ├── DevNullStorage.php │ │ ├── MemoryStorage.php │ │ ├── SQLiteJournal.php │ │ ├── SQLiteStorage.php │ │ ├── MemcachedStorage.php │ │ └── FileStorage.php │ ├── Storage.php │ ├── OutputHelper.php │ └── Cache.php ├── compatibility.php └── Bridges │ ├── CacheDI │ └── CacheExtension.php │ ├── CacheLatte │ ├── CacheExtension.php │ ├── Nodes │ │ └── CacheNode.php │ └── Runtime.php │ └── Psr │ └── PsrCacheAdapter.php ├── composer.json ├── license.md └── readme.md /src/Caching/BulkReader.php: -------------------------------------------------------------------------------- 1 | value pairs, missing items are omitted 21 | */ 22 | function bulkRead(array $keys): array; 23 | } 24 | 25 | 26 | class_exists(IBulkReader::class); 27 | -------------------------------------------------------------------------------- /src/Caching/BulkWriter.php: -------------------------------------------------------------------------------- 1 | data[$key] ?? null; 26 | } 27 | 28 | 29 | public function lock(string $key): void 30 | { 31 | } 32 | 33 | 34 | public function write(string $key, $data, array $dependencies): void 35 | { 36 | $this->data[$key] = $data; 37 | } 38 | 39 | 40 | public function remove(string $key): void 41 | { 42 | unset($this->data[$key]); 43 | } 44 | 45 | 46 | public function clean(array $conditions): void 47 | { 48 | if (!empty($conditions[Nette\Caching\Cache::All])) { 49 | $this->data = []; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Caching/OutputHelper.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 28 | $this->key = $key; 29 | ob_start(); 30 | } 31 | 32 | 33 | /** 34 | * Stops and saves the cache. 35 | */ 36 | public function end(array $dependencies = []): void 37 | { 38 | if ($this->cache === null) { 39 | throw new Nette\InvalidStateException('Output cache has already been saved.'); 40 | } 41 | 42 | $this->cache->save($this->key, ob_get_flush(), $dependencies + $this->dependencies); 43 | $this->cache = null; 44 | } 45 | 46 | 47 | /** 48 | * Stops and throws away the output. 49 | */ 50 | public function rollback(): void 51 | { 52 | ob_end_flush(); 53 | $this->cache = null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nette/caching", 3 | "description": "⏱ Nette Caching: library with easy-to-use API and many cache backends.", 4 | "keywords": ["nette", "cache", "journal", "sqlite", "memcached"], 5 | "homepage": "https://nette.org", 6 | "license": ["BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"], 7 | "authors": [ 8 | { 9 | "name": "David Grudl", 10 | "homepage": "https://davidgrudl.com" 11 | }, 12 | { 13 | "name": "Nette Community", 14 | "homepage": "https://nette.org/contributors" 15 | } 16 | ], 17 | "require": { 18 | "php": "8.1 - 8.5", 19 | "nette/utils": "^4.0" 20 | }, 21 | "require-dev": { 22 | "nette/tester": "^2.4", 23 | "nette/di": "^3.1 || ^4.0", 24 | "latte/latte": "^3.0.12", 25 | "tracy/tracy": "^2.9", 26 | "psr/simple-cache": "^2.0 || ^3.0", 27 | "phpstan/phpstan-nette": "^2.0@stable" 28 | }, 29 | "conflict": { 30 | "latte/latte": "<3.0.12" 31 | }, 32 | "suggest": { 33 | "ext-pdo_sqlite": "to use SQLiteStorage or SQLiteJournal" 34 | }, 35 | "autoload": { 36 | "classmap": ["src/"], 37 | "psr-4": { 38 | "Nette\\": "src" 39 | } 40 | }, 41 | "minimum-stability": "dev", 42 | "scripts": { 43 | "phpstan": "phpstan analyse", 44 | "tester": "tester tests -s" 45 | }, 46 | "extra": { 47 | "branch-alias": { 48 | "dev-master": "4.0-dev" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Bridges/CacheDI/CacheExtension.php: -------------------------------------------------------------------------------- 1 | tempDir)) { 30 | throw new Nette\InvalidArgumentException("Cache directory must be absolute, '$this->tempDir' given."); 31 | } 32 | FileSystem::createDir($this->tempDir); 33 | if (!is_writable($this->tempDir)) { 34 | throw new Nette\InvalidStateException("Make directory '$this->tempDir' writable."); 35 | } 36 | 37 | $builder = $this->getContainerBuilder(); 38 | 39 | if (extension_loaded('pdo_sqlite')) { 40 | $builder->addDefinition($this->prefix('journal')) 41 | ->setType(Nette\Caching\Storages\Journal::class) 42 | ->setFactory(Nette\Caching\Storages\SQLiteJournal::class, [$this->tempDir . '/journal.s3db']); 43 | } 44 | 45 | $builder->addDefinition($this->prefix('storage')) 46 | ->setType(Nette\Caching\Storage::class) 47 | ->setFactory(Nette\Caching\Storages\FileStorage::class, [$this->tempDir]); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Bridges/CacheLatte/CacheExtension.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 31 | } 32 | 33 | 34 | public function beforeCompile(Latte\Engine $engine): void 35 | { 36 | $this->used = false; 37 | } 38 | 39 | 40 | public function getTags(): array 41 | { 42 | return [ 43 | 'cache' => function (Tag $tag): \Generator { 44 | $this->used = true; 45 | return yield from Nodes\CacheNode::create($tag); 46 | }, 47 | ]; 48 | } 49 | 50 | 51 | public function getPasses(): array 52 | { 53 | return [ 54 | 'cacheInitialization' => function (TemplateNode $node): void { 55 | if ($this->used) { 56 | $node->head->append(new AuxiliaryNode(fn() => '$this->global->cache->initialize($this);')); 57 | } 58 | }, 59 | ]; 60 | } 61 | 62 | 63 | public function getProviders(): array 64 | { 65 | return [ 66 | 'cache' => new Runtime($this->storage), 67 | ]; 68 | } 69 | 70 | 71 | public function getCacheKey(Latte\Engine $engine): array 72 | { 73 | return ['version' => 2]; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Bridges/CacheLatte/Nodes/CacheNode.php: -------------------------------------------------------------------------------- 1 | */ 31 | public static function create(Tag $tag): \Generator 32 | { 33 | $node = $tag->node = new static; 34 | $node->args = $tag->parser->parseArguments(); 35 | [$node->content, $endTag] = yield; 36 | $node->endLine = $endTag?->position; 37 | return $node; 38 | } 39 | 40 | 41 | public function print(PrintContext $context): string 42 | { 43 | return $context->format( 44 | <<<'XX' 45 | if ($this->global->cache->createCache(%dump, %node?)) %line 46 | try { 47 | %node 48 | $this->global->cache->end() %line; 49 | } catch (\Throwable $ʟ_e) { 50 | $this->global->cache->rollback(); 51 | throw $ʟ_e; 52 | } 53 | 54 | 55 | XX, 56 | base64_encode(random_bytes(10)), 57 | $this->args, 58 | $this->position, 59 | $this->content, 60 | $this->endLine, 61 | ); 62 | } 63 | 64 | 65 | public function &getIterator(): \Generator 66 | { 67 | yield $this->args; 68 | yield $this->content; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Bridges/CacheLatte/Runtime.php: -------------------------------------------------------------------------------- 1 | */ 26 | private array $stack = []; 27 | 28 | 29 | public function __construct( 30 | private Nette\Caching\Storage $storage, 31 | ) { 32 | } 33 | 34 | 35 | public function initialize(Latte\Runtime\Template $template): void 36 | { 37 | if ($this->stack) { 38 | $file = (new \ReflectionClass($template))->getFileName(); 39 | if (@is_file($file)) { // @ - may trigger error 40 | end($this->stack)->dependencies[Cache::Files][] = $file; 41 | } 42 | } 43 | } 44 | 45 | 46 | /** 47 | * Starts the output cache. Returns true if buffering was started. 48 | */ 49 | public function createCache(string $key, ?array $args = null): bool 50 | { 51 | if ($args) { 52 | if (array_key_exists('if', $args) && !$args['if']) { 53 | $this->stack[] = new \stdClass; 54 | return true; 55 | } 56 | 57 | $key = array_merge([$key], array_intersect_key($args, range(0, count($args)))); 58 | } 59 | 60 | if ($this->stack) { 61 | end($this->stack)->dependencies[Cache::Items][] = $key; 62 | } 63 | 64 | $cache = new Cache($this->storage, 'Nette.Templating.Cache'); 65 | if ($helper = $cache->capture($key)) { 66 | $this->stack[] = $helper; 67 | 68 | if (isset($args['dependencies'])) { 69 | $args += $args['dependencies'](); 70 | } 71 | 72 | $helper->dependencies[Cache::Tags] = $args['tags'] ?? null; 73 | $helper->dependencies[Cache::Expire] = $args['expiration'] ?? $args['expire'] ?? '+ 7 days'; 74 | } 75 | 76 | return (bool) $helper; 77 | } 78 | 79 | 80 | /** 81 | * Ends the output cache. 82 | */ 83 | public function end(): void 84 | { 85 | $helper = array_pop($this->stack); 86 | if ($helper instanceof OutputHelper) { 87 | $helper->end(); 88 | } 89 | } 90 | 91 | 92 | public function rollback(): void 93 | { 94 | $helper = array_pop($this->stack); 95 | if ($helper instanceof OutputHelper) { 96 | $helper->rollback(); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Bridges/Psr/PsrCacheAdapter.php: -------------------------------------------------------------------------------- 1 | storage->read($key) ?? $default; 28 | } 29 | 30 | 31 | public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool 32 | { 33 | $dependencies = []; 34 | if ($ttl !== null) { 35 | $dependencies[Nette\Caching\Cache::Expire] = self::ttlToSeconds($ttl); 36 | } 37 | 38 | $this->storage->write($key, $value, $dependencies); 39 | 40 | return true; 41 | } 42 | 43 | 44 | public function delete(string $key): bool 45 | { 46 | $this->storage->remove($key); 47 | return true; 48 | } 49 | 50 | 51 | public function clear(): bool 52 | { 53 | $this->storage->clean([Nette\Caching\Cache::All => true]); 54 | return true; 55 | } 56 | 57 | 58 | /** 59 | * @return \Generator 60 | */ 61 | public function getMultiple(iterable $keys, mixed $default = null): \Generator 62 | { 63 | foreach ($keys as $name) { 64 | yield $name => $this->get($name, $default); 65 | } 66 | } 67 | 68 | 69 | /** 70 | * @param iterable $values 71 | */ 72 | public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool 73 | { 74 | $ttl = self::ttlToSeconds($ttl); 75 | 76 | foreach ($values as $key => $value) { 77 | $this->set((string) $key, $value, $ttl); 78 | } 79 | 80 | return true; 81 | } 82 | 83 | 84 | public function deleteMultiple(iterable $keys): bool 85 | { 86 | foreach ($keys as $value) { 87 | $this->delete($value); 88 | } 89 | 90 | return true; 91 | } 92 | 93 | 94 | public function has(string $key): bool 95 | { 96 | return $this->storage->read($key) !== null; 97 | } 98 | 99 | 100 | private static function ttlToSeconds(null|int|DateInterval $ttl = null): ?int 101 | { 102 | if ($ttl instanceof DateInterval) { 103 | return self::dateIntervalToSeconds($ttl); 104 | } 105 | 106 | return $ttl; 107 | } 108 | 109 | 110 | private static function dateIntervalToSeconds(DateInterval $dateInterval): int 111 | { 112 | $now = new \DateTimeImmutable; 113 | $expiresAt = $now->add($dateInterval); 114 | return $expiresAt->getTimestamp() - $now->getTimestamp(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Licenses 2 | ======== 3 | 4 | Good news! You may use Nette Framework under the terms of either 5 | the New BSD License or the GNU General Public License (GPL) version 2 or 3. 6 | 7 | The BSD License is recommended for most projects. It is easy to understand and it 8 | places almost no restrictions on what you can do with the framework. If the GPL 9 | fits better to your project, you can use the framework under this license. 10 | 11 | You don't have to notify anyone which license you are using. You can freely 12 | use Nette Framework in commercial projects as long as the copyright header 13 | remains intact. 14 | 15 | Please be advised that the name "Nette Framework" is a protected trademark and its 16 | usage has some limitations. So please do not use word "Nette" in the name of your 17 | project or top-level domain, and choose a name that stands on its own merits. 18 | If your stuff is good, it will not take long to establish a reputation for yourselves. 19 | 20 | 21 | New BSD License 22 | --------------- 23 | 24 | Copyright (c) 2004, 2014 David Grudl (https://davidgrudl.com) 25 | All rights reserved. 26 | 27 | Redistribution and use in source and binary forms, with or without modification, 28 | are permitted provided that the following conditions are met: 29 | 30 | * Redistributions of source code must retain the above copyright notice, 31 | this list of conditions and the following disclaimer. 32 | 33 | * Redistributions in binary form must reproduce the above copyright notice, 34 | this list of conditions and the following disclaimer in the documentation 35 | and/or other materials provided with the distribution. 36 | 37 | * Neither the name of "Nette Framework" nor the names of its contributors 38 | may be used to endorse or promote products derived from this software 39 | without specific prior written permission. 40 | 41 | This software is provided by the copyright holders and contributors "as is" and 42 | any express or implied warranties, including, but not limited to, the implied 43 | warranties of merchantability and fitness for a particular purpose are 44 | disclaimed. In no event shall the copyright owner or contributors be liable for 45 | any direct, indirect, incidental, special, exemplary, or consequential damages 46 | (including, but not limited to, procurement of substitute goods or services; 47 | loss of use, data, or profits; or business interruption) however caused and on 48 | any theory of liability, whether in contract, strict liability, or tort 49 | (including negligence or otherwise) arising in any way out of the use of this 50 | software, even if advised of the possibility of such damage. 51 | 52 | 53 | GNU General Public License 54 | -------------------------- 55 | 56 | GPL licenses are very very long, so instead of including them here we offer 57 | you URLs with full text: 58 | 59 | - [GPL version 2](http://www.gnu.org/licenses/gpl-2.0.html) 60 | - [GPL version 3](http://www.gnu.org/licenses/gpl-3.0.html) 61 | -------------------------------------------------------------------------------- /src/Caching/Storages/SQLiteJournal.php: -------------------------------------------------------------------------------- 1 | path = $path; 34 | } 35 | 36 | 37 | private function open(): void 38 | { 39 | if ($this->path !== ':memory:' && !is_file($this->path)) { 40 | touch($this->path); // ensures ordinary file permissions 41 | } 42 | 43 | $this->pdo = new \PDO('sqlite:' . $this->path); 44 | $this->pdo->exec(' 45 | PRAGMA foreign_keys = OFF; 46 | PRAGMA journal_mode = WAL; 47 | CREATE TABLE IF NOT EXISTS tags ( 48 | key BLOB NOT NULL, 49 | tag BLOB NOT NULL 50 | ); 51 | CREATE TABLE IF NOT EXISTS priorities ( 52 | key BLOB NOT NULL, 53 | priority INT NOT NULL 54 | ); 55 | CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag); 56 | CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_key_tag ON tags(key, tag); 57 | CREATE UNIQUE INDEX IF NOT EXISTS idx_priorities_key ON priorities(key); 58 | CREATE INDEX IF NOT EXISTS idx_priorities_priority ON priorities(priority); 59 | PRAGMA synchronous = NORMAL; 60 | '); 61 | } 62 | 63 | 64 | public function write(string $key, array $dependencies): void 65 | { 66 | if (!isset($this->pdo)) { 67 | $this->open(); 68 | } 69 | 70 | $this->pdo->exec('BEGIN'); 71 | 72 | if (!empty($dependencies[Cache::Tags])) { 73 | $this->pdo->prepare('DELETE FROM tags WHERE key = ?')->execute([$key]); 74 | 75 | foreach ($dependencies[Cache::Tags] as $tag) { 76 | $arr[] = $key; 77 | $arr[] = $tag; 78 | } 79 | 80 | $this->pdo->prepare('INSERT INTO tags (key, tag) SELECT ?, ?' . str_repeat('UNION SELECT ?, ?', count($arr) / 2 - 1)) 81 | ->execute($arr); 82 | } 83 | 84 | if (!empty($dependencies[Cache::Priority])) { 85 | $this->pdo->prepare('REPLACE INTO priorities (key, priority) VALUES (?, ?)') 86 | ->execute([$key, (int) $dependencies[Cache::Priority]]); 87 | } 88 | 89 | $this->pdo->exec('COMMIT'); 90 | } 91 | 92 | 93 | public function clean(array $conditions): ?array 94 | { 95 | if (!isset($this->pdo)) { 96 | $this->open(); 97 | } 98 | 99 | if (!empty($conditions[Cache::All])) { 100 | $this->pdo->exec(' 101 | BEGIN; 102 | DELETE FROM tags; 103 | DELETE FROM priorities; 104 | COMMIT; 105 | '); 106 | 107 | return null; 108 | } 109 | 110 | $unions = $args = []; 111 | if (!empty($conditions[Cache::Tags])) { 112 | $tags = (array) $conditions[Cache::Tags]; 113 | $unions[] = 'SELECT DISTINCT key FROM tags WHERE tag IN (?' . str_repeat(', ?', count($tags) - 1) . ')'; 114 | $args = $tags; 115 | } 116 | 117 | if (!empty($conditions[Cache::Priority])) { 118 | $unions[] = 'SELECT DISTINCT key FROM priorities WHERE priority <= ?'; 119 | $args[] = (int) $conditions[Cache::Priority]; 120 | } 121 | 122 | if (empty($unions)) { 123 | return []; 124 | } 125 | 126 | $unionSql = implode(' UNION ', $unions); 127 | 128 | $this->pdo->exec('BEGIN IMMEDIATE'); 129 | 130 | $stmt = $this->pdo->prepare($unionSql); 131 | $stmt->execute($args); 132 | $keys = $stmt->fetchAll(\PDO::FETCH_COLUMN, 0); 133 | 134 | if (empty($keys)) { 135 | $this->pdo->exec('COMMIT'); 136 | return []; 137 | } 138 | 139 | $this->pdo->prepare("DELETE FROM tags WHERE key IN ($unionSql)")->execute($args); 140 | $this->pdo->prepare("DELETE FROM priorities WHERE key IN ($unionSql)")->execute($args); 141 | $this->pdo->exec('COMMIT'); 142 | 143 | return $keys; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Caching/Storages/SQLiteStorage.php: -------------------------------------------------------------------------------- 1 | pdo = new \PDO('sqlite:' . $path); 32 | $this->pdo->exec(' 33 | PRAGMA foreign_keys = ON; 34 | CREATE TABLE IF NOT EXISTS cache ( 35 | key BLOB NOT NULL PRIMARY KEY, 36 | data BLOB NOT NULL, 37 | expire INTEGER, 38 | slide INTEGER 39 | ); 40 | CREATE TABLE IF NOT EXISTS tags ( 41 | key BLOB NOT NULL REFERENCES cache ON DELETE CASCADE, 42 | tag BLOB NOT NULL 43 | ); 44 | CREATE INDEX IF NOT EXISTS cache_expire ON cache(expire); 45 | CREATE INDEX IF NOT EXISTS tags_key ON tags(key); 46 | CREATE INDEX IF NOT EXISTS tags_tag ON tags(tag); 47 | PRAGMA synchronous = OFF; 48 | '); 49 | } 50 | 51 | 52 | public function read(string $key): mixed 53 | { 54 | $stmt = $this->pdo->prepare('SELECT data, slide FROM cache WHERE key=? AND (expire IS NULL OR expire >= ?)'); 55 | $stmt->execute([$key, time()]); 56 | if (!$row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 57 | return null; 58 | } 59 | 60 | if ($row['slide'] !== null) { 61 | $this->pdo->prepare('UPDATE cache SET expire = ? + slide WHERE key=?')->execute([time(), $key]); 62 | } 63 | 64 | return unserialize($row['data']); 65 | } 66 | 67 | 68 | public function bulkRead(array $keys): array 69 | { 70 | $stmt = $this->pdo->prepare('SELECT key, data, slide FROM cache WHERE key IN (?' . str_repeat(',?', count($keys) - 1) . ') AND (expire IS NULL OR expire >= ?)'); 71 | $stmt->execute(array_merge($keys, [time()])); 72 | $result = []; 73 | $updateSlide = []; 74 | foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { 75 | if ($row['slide'] !== null) { 76 | $updateSlide[] = $row['key']; 77 | } 78 | 79 | $result[$row['key']] = unserialize($row['data']); 80 | } 81 | 82 | if (!empty($updateSlide)) { 83 | $stmt = $this->pdo->prepare('UPDATE cache SET expire = ? + slide WHERE key IN(?' . str_repeat(',?', count($updateSlide) - 1) . ')'); 84 | $stmt->execute(array_merge([time()], $updateSlide)); 85 | } 86 | 87 | return $result; 88 | } 89 | 90 | 91 | public function lock(string $key): void 92 | { 93 | } 94 | 95 | 96 | public function write(string $key, $data, array $dependencies): void 97 | { 98 | $expire = isset($dependencies[Cache::Expire]) 99 | ? $dependencies[Cache::Expire] + time() 100 | : null; 101 | $slide = isset($dependencies[Cache::Sliding]) 102 | ? $dependencies[Cache::Expire] 103 | : null; 104 | 105 | $this->pdo->exec('BEGIN TRANSACTION'); 106 | $this->pdo->prepare('REPLACE INTO cache (key, data, expire, slide) VALUES (?, ?, ?, ?)') 107 | ->execute([$key, serialize($data), $expire, $slide]); 108 | 109 | if (!empty($dependencies[Cache::Tags])) { 110 | foreach ($dependencies[Cache::Tags] as $tag) { 111 | $arr[] = $key; 112 | $arr[] = $tag; 113 | } 114 | 115 | $this->pdo->prepare('INSERT INTO tags (key, tag) SELECT ?, ?' . str_repeat('UNION SELECT ?, ?', count($arr) / 2 - 1)) 116 | ->execute($arr); 117 | } 118 | 119 | $this->pdo->exec('COMMIT'); 120 | } 121 | 122 | 123 | public function remove(string $key): void 124 | { 125 | $this->pdo->prepare('DELETE FROM cache WHERE key=?') 126 | ->execute([$key]); 127 | } 128 | 129 | 130 | public function clean(array $conditions): void 131 | { 132 | if (!empty($conditions[Cache::All])) { 133 | $this->pdo->prepare('DELETE FROM cache')->execute(); 134 | 135 | } else { 136 | $sql = 'DELETE FROM cache WHERE expire < ?'; 137 | $args = [time()]; 138 | 139 | if (!empty($conditions[Cache::Tags])) { 140 | $tags = $conditions[Cache::Tags]; 141 | $sql .= ' OR key IN (SELECT key FROM tags WHERE tag IN (?' . str_repeat(',?', count($tags) - 1) . '))'; 142 | $args = array_merge($args, $tags); 143 | } 144 | 145 | $this->pdo->prepare($sql)->execute($args); 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Caching/Storages/MemcachedStorage.php: -------------------------------------------------------------------------------- 1 | prefix = $prefix; 53 | $this->journal = $journal; 54 | $this->memcached = new \Memcached; 55 | if ($host) { 56 | $this->addServer($host, $port); 57 | } 58 | } 59 | 60 | 61 | public function addServer(string $host = 'localhost', int $port = 11211): void 62 | { 63 | if (@$this->memcached->addServer($host, $port, 1) === false) { // @ is escalated to exception 64 | $error = error_get_last(); 65 | throw new Nette\InvalidStateException("Memcached::addServer(): $error[message]."); 66 | } 67 | } 68 | 69 | 70 | public function getConnection(): \Memcached 71 | { 72 | return $this->memcached; 73 | } 74 | 75 | 76 | public function read(string $key): mixed 77 | { 78 | $key = urlencode($this->prefix . $key); 79 | $meta = $this->memcached->get($key); 80 | if (!$meta) { 81 | return null; 82 | } 83 | 84 | // meta structure: 85 | // array( 86 | // data => stored data 87 | // delta => relative (sliding) expiration 88 | // callbacks => array of callbacks (function, args) 89 | // ) 90 | 91 | // verify dependencies 92 | if (!empty($meta[self::MetaCallbacks]) && !Cache::checkCallbacks($meta[self::MetaCallbacks])) { 93 | $this->memcached->delete($key, 0); 94 | return null; 95 | } 96 | 97 | if (!empty($meta[self::MetaDelta])) { 98 | $this->memcached->replace($key, $meta, $meta[self::MetaDelta] + time()); 99 | } 100 | 101 | return $meta[self::MetaData]; 102 | } 103 | 104 | 105 | public function bulkRead(array $keys): array 106 | { 107 | $prefixedKeys = array_map(fn($key) => urlencode($this->prefix . $key), $keys); 108 | $keys = array_combine($prefixedKeys, $keys); 109 | $metas = $this->memcached->getMulti($prefixedKeys) ?: []; 110 | $result = []; 111 | $deleteKeys = []; 112 | foreach ($metas as $prefixedKey => $meta) { 113 | if (!empty($meta[self::MetaCallbacks]) && !Cache::checkCallbacks($meta[self::MetaCallbacks])) { 114 | $deleteKeys[] = $prefixedKey; 115 | } else { 116 | $result[$keys[$prefixedKey]] = $meta[self::MetaData]; 117 | } 118 | 119 | if (!empty($meta[self::MetaDelta])) { 120 | $this->memcached->replace($prefixedKey, $meta, $meta[self::MetaDelta] + time()); 121 | } 122 | } 123 | 124 | if (!empty($deleteKeys)) { 125 | $this->memcached->deleteMulti($deleteKeys, 0); 126 | } 127 | 128 | return $result; 129 | } 130 | 131 | 132 | public function lock(string $key): void 133 | { 134 | } 135 | 136 | 137 | public function write(string $key, $data, array $dp): void 138 | { 139 | if (isset($dp[Cache::Items])) { 140 | throw new Nette\NotSupportedException('Dependent items are not supported by MemcachedStorage.'); 141 | } 142 | 143 | $key = urlencode($this->prefix . $key); 144 | $meta = [ 145 | self::MetaData => $data, 146 | ]; 147 | 148 | $expire = 0; 149 | if (isset($dp[Cache::Expire])) { 150 | $expire = (int) $dp[Cache::Expire]; 151 | if (!empty($dp[Cache::Sliding])) { 152 | $meta[self::MetaDelta] = $expire; // sliding time 153 | } 154 | } 155 | 156 | if (isset($dp[Cache::Callbacks])) { 157 | $meta[self::MetaCallbacks] = $dp[Cache::Callbacks]; 158 | } 159 | 160 | if (isset($dp[Cache::Tags]) || isset($dp[Cache::Priority])) { 161 | if (!$this->journal) { 162 | throw new Nette\InvalidStateException('CacheJournal has not been provided.'); 163 | } 164 | 165 | $this->journal->write($key, $dp); 166 | } 167 | 168 | $this->memcached->set($key, $meta, $expire); 169 | } 170 | 171 | 172 | public function bulkWrite(array $items, array $dp): void 173 | { 174 | if (isset($dp[Cache::Items])) { 175 | throw new Nette\NotSupportedException('Dependent items are not supported by MemcachedStorage.'); 176 | } 177 | 178 | $meta = $records = []; 179 | $expire = 0; 180 | if (isset($dp[Cache::Expire])) { 181 | $expire = (int) $dp[Cache::Expire]; 182 | if (!empty($dp[Cache::Sliding])) { 183 | $meta[self::MetaDelta] = $expire; // sliding time 184 | } 185 | } 186 | 187 | if (isset($dp[Cache::Callbacks])) { 188 | $meta[self::MetaCallbacks] = $dp[Cache::Callbacks]; 189 | } 190 | 191 | foreach ($items as $key => $meta[self::MetaData]) { 192 | $key = urlencode($this->prefix . $key); 193 | $records[$key] = $meta; 194 | 195 | if (isset($dp[Cache::Tags]) || isset($dp[Cache::Priority])) { 196 | if (!$this->journal) { 197 | throw new Nette\InvalidStateException('CacheJournal has not been provided.'); 198 | } 199 | 200 | $this->journal->write($key, $dp); 201 | } 202 | } 203 | 204 | $this->memcached->setMulti($records, $expire); 205 | } 206 | 207 | 208 | public function remove(string $key): void 209 | { 210 | $this->memcached->delete(urlencode($this->prefix . $key), 0); 211 | } 212 | 213 | 214 | public function bulkRemove(array $keys): void 215 | { 216 | $this->memcached->deleteMulti(array_map(fn($key) => urlencode($this->prefix . $key), $keys), 0); 217 | } 218 | 219 | 220 | public function clean(array $conditions): void 221 | { 222 | if (!empty($conditions[Cache::All])) { 223 | $this->memcached->flush(); 224 | 225 | } elseif ($this->journal) { 226 | foreach ($this->journal->clean($conditions) as $entry) { 227 | $this->memcached->delete($entry, 0); 228 | } 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/Caching/Storages/FileStorage.php: -------------------------------------------------------------------------------- 1 | timestamp) 43 | MetaCallbacks = 'callbacks'; // array of callbacks (function, args) 44 | 45 | /** additional cache structure */ 46 | private const 47 | File = 'file', 48 | Handle = 'handle'; 49 | 50 | /** probability that the clean() routine is started */ 51 | public static float $gcProbability = 0.001; 52 | 53 | private string $dir; 54 | private ?Journal $journal; 55 | private array $locks; 56 | 57 | 58 | public function __construct(string $dir, ?Journal $journal = null) 59 | { 60 | if (!is_dir($dir) || !Nette\Utils\FileSystem::isAbsolute($dir)) { 61 | throw new Nette\DirectoryNotFoundException("Directory '$dir' not found or is not absolute."); 62 | } 63 | 64 | $this->dir = $dir; 65 | $this->journal = $journal; 66 | 67 | if (mt_rand() / mt_getrandmax() < static::$gcProbability) { 68 | $this->clean([]); 69 | } 70 | } 71 | 72 | 73 | public function read(string $key): mixed 74 | { 75 | $meta = $this->readMetaAndLock($this->getCacheFile($key), LOCK_SH); 76 | return $meta && $this->verify($meta) 77 | ? $this->readData($meta) // calls fclose() 78 | : null; 79 | } 80 | 81 | 82 | /** 83 | * Verifies dependencies. 84 | */ 85 | private function verify(array $meta): bool 86 | { 87 | do { 88 | if (!empty($meta[self::MetaDelta])) { 89 | // meta[file] was added by readMetaAndLock() 90 | if (filemtime($meta[self::File]) + $meta[self::MetaDelta] < time()) { 91 | break; 92 | } 93 | 94 | touch($meta[self::File]); 95 | 96 | } elseif (!empty($meta[self::MetaExpire]) && $meta[self::MetaExpire] < time()) { 97 | break; 98 | } 99 | 100 | if (!empty($meta[self::MetaCallbacks]) && !Cache::checkCallbacks($meta[self::MetaCallbacks])) { 101 | break; 102 | } 103 | 104 | if (!empty($meta[self::MetaItems])) { 105 | foreach ($meta[self::MetaItems] as $depFile => $time) { 106 | $m = $this->readMetaAndLock($depFile, LOCK_SH); 107 | if (($m[self::MetaTime] ?? null) !== $time || ($m && !$this->verify($m))) { 108 | break 2; 109 | } 110 | } 111 | } 112 | 113 | return true; 114 | } while (false); 115 | 116 | $this->delete($meta[self::File], $meta[self::Handle]); // meta[handle] & meta[file] was added by readMetaAndLock() 117 | return false; 118 | } 119 | 120 | 121 | public function lock(string $key): void 122 | { 123 | $cacheFile = $this->getCacheFile($key); 124 | if (!is_dir($dir = dirname($cacheFile))) { 125 | @mkdir($dir); // @ - directory may already exist 126 | } 127 | 128 | $handle = fopen($cacheFile, 'c+b'); 129 | if (!$handle) { 130 | return; 131 | } 132 | 133 | $this->locks[$key] = $handle; 134 | flock($handle, LOCK_EX); 135 | } 136 | 137 | 138 | public function write(string $key, $data, array $dp): void 139 | { 140 | $meta = [ 141 | self::MetaTime => microtime(), 142 | ]; 143 | 144 | if (isset($dp[Cache::Expire])) { 145 | if (empty($dp[Cache::Sliding])) { 146 | $meta[self::MetaExpire] = $dp[Cache::Expire] + time(); // absolute time 147 | } else { 148 | $meta[self::MetaDelta] = (int) $dp[Cache::Expire]; // sliding time 149 | } 150 | } 151 | 152 | if (isset($dp[Cache::Items])) { 153 | foreach ($dp[Cache::Items] as $item) { 154 | $depFile = $this->getCacheFile($item); 155 | $m = $this->readMetaAndLock($depFile, LOCK_SH); 156 | $meta[self::MetaItems][$depFile] = $m[self::MetaTime] ?? null; 157 | unset($m); 158 | } 159 | } 160 | 161 | if (isset($dp[Cache::Callbacks])) { 162 | $meta[self::MetaCallbacks] = $dp[Cache::Callbacks]; 163 | } 164 | 165 | if (!isset($this->locks[$key])) { 166 | $this->lock($key); 167 | if (!isset($this->locks[$key])) { 168 | return; 169 | } 170 | } 171 | 172 | $handle = $this->locks[$key]; 173 | unset($this->locks[$key]); 174 | 175 | $cacheFile = $this->getCacheFile($key); 176 | 177 | if (isset($dp[Cache::Tags]) || isset($dp[Cache::Priority])) { 178 | if (!$this->journal) { 179 | throw new Nette\InvalidStateException('CacheJournal has not been provided.'); 180 | } 181 | 182 | $this->journal->write($cacheFile, $dp); 183 | } 184 | 185 | ftruncate($handle, 0); 186 | 187 | if (!is_string($data)) { 188 | $data = serialize($data); 189 | $meta[self::MetaSerialized] = true; 190 | } 191 | 192 | $head = serialize($meta); 193 | $head = str_pad((string) strlen($head), 6, '0', STR_PAD_LEFT) . $head; 194 | $headLen = strlen($head); 195 | 196 | do { 197 | if (fwrite($handle, str_repeat("\x00", $headLen)) !== $headLen) { 198 | break; 199 | } 200 | 201 | if (fwrite($handle, $data) !== strlen($data)) { 202 | break; 203 | } 204 | 205 | fseek($handle, 0); 206 | if (fwrite($handle, $head) !== $headLen) { 207 | break; 208 | } 209 | 210 | flock($handle, LOCK_UN); 211 | fclose($handle); 212 | return; 213 | } while (false); 214 | 215 | $this->delete($cacheFile, $handle); 216 | } 217 | 218 | 219 | public function remove(string $key): void 220 | { 221 | unset($this->locks[$key]); 222 | $this->delete($this->getCacheFile($key)); 223 | } 224 | 225 | 226 | public function clean(array $conditions): void 227 | { 228 | $all = !empty($conditions[Cache::All]); 229 | $collector = empty($conditions); 230 | $namespaces = $conditions[Cache::Namespaces] ?? null; 231 | 232 | // cleaning using file iterator 233 | if ($all || $collector) { 234 | $now = time(); 235 | foreach (Nette\Utils\Finder::find('_*')->from($this->dir)->childFirst() as $entry) { 236 | $path = (string) $entry; 237 | if ($entry->isDir()) { // collector: remove empty dirs 238 | @rmdir($path); // @ - removing dirs is not necessary 239 | continue; 240 | } 241 | 242 | if ($all) { 243 | $this->delete($path); 244 | 245 | } else { // collector 246 | $meta = $this->readMetaAndLock($path, LOCK_SH); 247 | if (!$meta) { 248 | continue; 249 | } 250 | 251 | if ((!empty($meta[self::MetaDelta]) && filemtime($meta[self::File]) + $meta[self::MetaDelta] < $now) 252 | || (!empty($meta[self::MetaExpire]) && $meta[self::MetaExpire] < $now) 253 | ) { 254 | $this->delete($path, $meta[self::Handle]); 255 | continue; 256 | } 257 | 258 | flock($meta[self::Handle], LOCK_UN); 259 | fclose($meta[self::Handle]); 260 | } 261 | } 262 | 263 | if ($this->journal) { 264 | $this->journal->clean($conditions); 265 | } 266 | 267 | return; 268 | 269 | } elseif ($namespaces) { 270 | foreach ($namespaces as $namespace) { 271 | $dir = $this->dir . '/_' . urlencode($namespace); 272 | if (!is_dir($dir)) { 273 | continue; 274 | } 275 | 276 | foreach (Nette\Utils\Finder::findFiles('_*')->in($dir) as $entry) { 277 | $this->delete((string) $entry); 278 | } 279 | 280 | @rmdir($dir); // may already contain new files 281 | } 282 | } 283 | 284 | // cleaning using journal 285 | if ($this->journal) { 286 | foreach ($this->journal->clean($conditions) as $file) { 287 | $this->delete($file); 288 | } 289 | } 290 | } 291 | 292 | 293 | /** 294 | * Reads cache data from disk. 295 | */ 296 | protected function readMetaAndLock(string $file, int $lock): ?array 297 | { 298 | $handle = @fopen($file, 'r+b'); // @ - file may not exist 299 | if (!$handle) { 300 | return null; 301 | } 302 | 303 | flock($handle, $lock); 304 | 305 | $size = (int) stream_get_contents($handle, self::MetaHeaderLen); 306 | if ($size) { 307 | $meta = stream_get_contents($handle, $size, self::MetaHeaderLen); 308 | $meta = unserialize($meta); 309 | $meta[self::File] = $file; 310 | $meta[self::Handle] = $handle; 311 | return $meta; 312 | } 313 | 314 | flock($handle, LOCK_UN); 315 | fclose($handle); 316 | return null; 317 | } 318 | 319 | 320 | /** 321 | * Reads cache data from disk and closes cache file handle. 322 | */ 323 | protected function readData(array $meta): mixed 324 | { 325 | $data = stream_get_contents($meta[self::Handle]); 326 | flock($meta[self::Handle], LOCK_UN); 327 | fclose($meta[self::Handle]); 328 | 329 | return empty($meta[self::MetaSerialized]) ? $data : unserialize($data); 330 | } 331 | 332 | 333 | /** 334 | * Returns file name. 335 | */ 336 | protected function getCacheFile(string $key): string 337 | { 338 | $file = urlencode($key); 339 | if ($a = strrpos($file, '%00')) { // %00 = urlencode(Nette\Caching\Cache::NamespaceSeparator) 340 | $file = substr_replace($file, '/_', $a, 3); 341 | } 342 | 343 | return $this->dir . '/_' . $file; 344 | } 345 | 346 | 347 | /** 348 | * Deletes and closes file. 349 | * @param resource $handle 350 | */ 351 | private static function delete(string $file, $handle = null): void 352 | { 353 | if (@unlink($file)) { // @ - file may not already exist 354 | if ($handle) { 355 | flock($handle, LOCK_UN); 356 | fclose($handle); 357 | } 358 | 359 | return; 360 | } 361 | 362 | if (!$handle) { 363 | $handle = @fopen($file, 'r+'); // @ - file may not exist 364 | } 365 | 366 | if (!$handle) { 367 | return; 368 | } 369 | 370 | flock($handle, LOCK_EX); 371 | ftruncate($handle, 0); 372 | flock($handle, LOCK_UN); 373 | fclose($handle); 374 | @unlink($file); // @ - file may not already exist 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /src/Caching/Cache.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 77 | $this->namespace = $namespace . self::NamespaceSeparator; 78 | } 79 | 80 | 81 | /** 82 | * Returns cache storage. 83 | */ 84 | final public function getStorage(): Storage 85 | { 86 | return $this->storage; 87 | } 88 | 89 | 90 | /** 91 | * Returns cache namespace. 92 | */ 93 | final public function getNamespace(): string 94 | { 95 | return substr($this->namespace, 0, -1); 96 | } 97 | 98 | 99 | /** 100 | * Returns new nested cache object. 101 | */ 102 | public function derive(string $namespace): static 103 | { 104 | return new static($this->storage, $this->namespace . $namespace); 105 | } 106 | 107 | 108 | /** 109 | * Reads the specified item from the cache or generate it. 110 | */ 111 | public function load(mixed $key, ?callable $generator = null, ?array $dependencies = null): mixed 112 | { 113 | $storageKey = $this->generateKey($key); 114 | $data = $this->storage->read($storageKey); 115 | if ($data === null && $generator) { 116 | $this->storage->lock($storageKey); 117 | try { 118 | $data = $generator(...[&$dependencies]); 119 | } catch (\Throwable $e) { 120 | $this->storage->remove($storageKey); 121 | throw $e; 122 | } 123 | 124 | $this->save($key, $data, $dependencies); 125 | } 126 | 127 | return $data; 128 | } 129 | 130 | 131 | /** 132 | * Reads multiple items from the cache. 133 | */ 134 | public function bulkLoad(array $keys, ?callable $generator = null): array 135 | { 136 | if (count($keys) === 0) { 137 | return []; 138 | } 139 | 140 | foreach ($keys as $key) { 141 | if (!is_scalar($key)) { 142 | throw new Nette\InvalidArgumentException('Only scalar keys are allowed in bulkLoad()'); 143 | } 144 | } 145 | 146 | $result = []; 147 | if (!$this->storage instanceof BulkReader) { 148 | foreach ($keys as $key) { 149 | $result[$key] = $this->load( 150 | $key, 151 | $generator 152 | ? fn(&$dependencies) => $generator(...[$key, &$dependencies]) 153 | : null, 154 | ); 155 | } 156 | 157 | return $result; 158 | } 159 | 160 | $storageKeys = array_map([$this, 'generateKey'], $keys); 161 | $cacheData = $this->storage->bulkRead($storageKeys); 162 | foreach ($keys as $i => $key) { 163 | $storageKey = $storageKeys[$i]; 164 | if (isset($cacheData[$storageKey])) { 165 | $result[$key] = $cacheData[$storageKey]; 166 | } elseif ($generator) { 167 | $result[$key] = $this->load($key, fn(&$dependencies) => $generator(...[$key, &$dependencies])); 168 | } else { 169 | $result[$key] = null; 170 | } 171 | } 172 | 173 | return $result; 174 | } 175 | 176 | 177 | /** 178 | * Writes item into the cache. 179 | * Dependencies are: 180 | * - Cache::Priority => (int) priority 181 | * - Cache::Expire => (timestamp) expiration, infinite if null 182 | * - Cache::Sliding => (bool) use sliding expiration? 183 | * - Cache::Tags => (array) tags 184 | * - Cache::Files => (array|string) file names 185 | * - Cache::Items => (array|string) cache items 186 | * - Cache::Constants => (array|string) cache items 187 | * @return mixed value itself 188 | * @throws Nette\InvalidArgumentException 189 | */ 190 | public function save(mixed $key, mixed $data, ?array $dependencies = null): mixed 191 | { 192 | $key = $this->generateKey($key); 193 | 194 | if ($data instanceof \Closure) { 195 | $this->storage->lock($key); 196 | try { 197 | $data = $data(...[&$dependencies]); 198 | } catch (\Throwable $e) { 199 | $this->storage->remove($key); 200 | throw $e; 201 | } 202 | } 203 | 204 | if ($data === null) { 205 | $this->storage->remove($key); 206 | return null; 207 | } else { 208 | $dependencies = $this->completeDependencies($dependencies); 209 | if (isset($dependencies[self::Expire]) && $dependencies[self::Expire] <= 0) { 210 | $this->storage->remove($key); 211 | } else { 212 | $this->storage->write($key, $data, $dependencies); 213 | } 214 | 215 | return $data; 216 | } 217 | } 218 | 219 | 220 | /** 221 | * Writes multiple items into cache 222 | */ 223 | public function bulkSave(array $items, ?array $dependencies = null): void 224 | { 225 | $write = $remove = []; 226 | 227 | if (!$this->storage instanceof BulkWriter) { 228 | foreach ($items as $key => $data) { 229 | $this->save($key, $data, $dependencies); 230 | } 231 | return; 232 | } 233 | 234 | $dependencies = $this->completeDependencies($dependencies); 235 | if (isset($dependencies[self::Expire]) && $dependencies[self::Expire] <= 0) { 236 | $this->storage->bulkRemove(array_map(fn($key) => $this->generateKey($key), array_keys($items))); 237 | return; 238 | } 239 | 240 | foreach ($items as $key => $data) { 241 | $key = $this->generateKey($key); 242 | if ($data === null) { 243 | $remove[] = $key; 244 | } else { 245 | $write[$key] = $data; 246 | } 247 | } 248 | 249 | if ($remove) { 250 | $this->storage->bulkRemove($remove); 251 | } 252 | 253 | if ($write) { 254 | $this->storage->bulkWrite($write, $dependencies); 255 | } 256 | } 257 | 258 | 259 | private function completeDependencies(?array $dp): array 260 | { 261 | // convert expire into relative amount of seconds 262 | if (isset($dp[self::Expire])) { 263 | $dp[self::Expire] = Nette\Utils\DateTime::from($dp[self::Expire])->format('U') - time(); 264 | } 265 | 266 | // make list from TAGS 267 | if (isset($dp[self::Tags])) { 268 | $dp[self::Tags] = array_values((array) $dp[self::Tags]); 269 | } 270 | 271 | // make list from NAMESPACES 272 | if (isset($dp[self::Namespaces])) { 273 | $dp[self::Namespaces] = array_values((array) $dp[self::Namespaces]); 274 | } 275 | 276 | // convert FILES into CALLBACKS 277 | if (isset($dp[self::Files])) { 278 | foreach (array_unique((array) $dp[self::Files]) as $item) { 279 | $dp[self::Callbacks][] = [[self::class, 'checkFile'], $item, @filemtime($item) ?: null]; // @ - stat may fail 280 | } 281 | 282 | unset($dp[self::Files]); 283 | } 284 | 285 | // add namespaces to items 286 | if (isset($dp[self::Items])) { 287 | $dp[self::Items] = array_unique(array_map([$this, 'generateKey'], (array) $dp[self::Items])); 288 | } 289 | 290 | // convert CONSTS into CALLBACKS 291 | if (isset($dp[self::Constants])) { 292 | foreach (array_unique((array) $dp[self::Constants]) as $item) { 293 | $dp[self::Callbacks][] = [[self::class, 'checkConst'], $item, constant($item)]; 294 | } 295 | 296 | unset($dp[self::Constants]); 297 | } 298 | 299 | if (!is_array($dp)) { 300 | $dp = []; 301 | } 302 | 303 | return $dp; 304 | } 305 | 306 | 307 | /** 308 | * Removes item from the cache. 309 | */ 310 | public function remove(mixed $key): void 311 | { 312 | $this->save($key, null); 313 | } 314 | 315 | 316 | /** 317 | * Removes items from the cache by conditions. 318 | * Conditions are: 319 | * - Cache::Priority => (int) priority 320 | * - Cache::Tags => (array) tags 321 | * - Cache::All => true 322 | */ 323 | public function clean(?array $conditions = null): void 324 | { 325 | $conditions = (array) $conditions; 326 | if (isset($conditions[self::Tags])) { 327 | $conditions[self::Tags] = array_values((array) $conditions[self::Tags]); 328 | } 329 | 330 | $this->storage->clean($conditions); 331 | } 332 | 333 | 334 | /** 335 | * Caches results of function/method calls. 336 | */ 337 | public function call(callable $function): mixed 338 | { 339 | $key = func_get_args(); 340 | if (is_array($function) && is_object($function[0])) { 341 | $key[0][0] = get_class($function[0]); 342 | } 343 | 344 | return $this->load($key, fn() => $function(...array_slice($key, 1))); 345 | } 346 | 347 | 348 | /** 349 | * Caches results of function/method calls. 350 | */ 351 | public function wrap(callable $function, ?array $dependencies = null): \Closure 352 | { 353 | return function () use ($function, $dependencies) { 354 | $key = [$function, $args = func_get_args()]; 355 | if (is_array($function) && is_object($function[0])) { 356 | $key[0][0] = get_class($function[0]); 357 | } 358 | 359 | return $this->load($key, function (&$deps) use ($function, $args, $dependencies) { 360 | $deps = $dependencies; 361 | return $function(...$args); 362 | }); 363 | }; 364 | } 365 | 366 | 367 | /** 368 | * Starts the output cache. 369 | */ 370 | public function capture(mixed $key): ?OutputHelper 371 | { 372 | $data = $this->load($key); 373 | if ($data === null) { 374 | return new OutputHelper($this, $key); 375 | } 376 | 377 | echo $data; 378 | return null; 379 | } 380 | 381 | 382 | #[\Deprecated('use capture()')] 383 | public function start($key): ?OutputHelper 384 | { 385 | trigger_error(__METHOD__ . '() was renamed to capture()', E_USER_DEPRECATED); 386 | return $this->capture($key); 387 | } 388 | 389 | 390 | /** 391 | * Generates internal cache key. 392 | */ 393 | protected function generateKey($key): string 394 | { 395 | return $this->namespace . hash('xxh128', is_scalar($key) ? (string) $key : serialize($key)); 396 | } 397 | 398 | 399 | /********************* dependency checkers ****************d*g**/ 400 | 401 | 402 | /** 403 | * Checks CALLBACKS dependencies. 404 | */ 405 | public static function checkCallbacks(array $callbacks): bool 406 | { 407 | foreach ($callbacks as $callback) { 408 | if (!array_shift($callback)(...$callback)) { 409 | return false; 410 | } 411 | } 412 | 413 | return true; 414 | } 415 | 416 | 417 | /** 418 | * Checks CONSTS dependency. 419 | */ 420 | private static function checkConst(string $const, $value): bool 421 | { 422 | return defined($const) && constant($const) === $value; 423 | } 424 | 425 | 426 | /** 427 | * Checks FILES dependency. 428 | */ 429 | private static function checkFile(string $file, ?int $time): bool 430 | { 431 | return @filemtime($file) == $time; // @ - stat may fail 432 | } 433 | } 434 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Nette Caching 2 | ============= 3 | 4 | [![Downloads this Month](https://img.shields.io/packagist/dm/nette/caching.svg)](https://packagist.org/packages/nette/caching) 5 | [![Tests](https://github.com/nette/caching/workflows/Tests/badge.svg?branch=master)](https://github.com/nette/caching/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/nette/caching/badge.svg?branch=master)](https://coveralls.io/github/nette/caching?branch=master) 7 | [![Latest Stable Version](https://poser.pugx.org/nette/caching/v/stable)](https://github.com/nette/caching/releases) 8 | [![License](https://img.shields.io/badge/license-New%20BSD-blue.svg)](https://github.com/nette/caching/blob/master/license.md) 9 | 10 | 11 | Introduction 12 | ============ 13 | 14 | Cache accelerates your application by storing data - once hardly retrieved - for future use. 15 | 16 | Documentation can be found on the [website](https://doc.nette.org/caching). 17 | 18 | 19 | [Support Me](https://github.com/sponsors/dg) 20 | -------------------------------------------- 21 | 22 | Do you like Nette Caching? Are you looking forward to the new features? 23 | 24 | [![Buy me a coffee](https://files.nette.org/icons/donation-3.svg)](https://github.com/sponsors/dg) 25 | 26 | Thank you! 27 | 28 | 29 | Installation 30 | ------------ 31 | 32 | ``` 33 | composer require nette/caching 34 | ``` 35 | 36 | It requires PHP version 8.1 and supports PHP up to 8.5. 37 | 38 | 39 | Basic Usage 40 | =========== 41 | 42 | The center of work with the cache is the object [Nette\Caching\Cache](https://api.nette.org/3.0/Nette/Caching/Cache.html). We create its instance and pass the so-called storage to the constructor as a parameter. Which is an object representing the place where the data will be physically stored (database, Memcached, files on disk, ...). You will find out all the essentials in [section Storages](#Storages). 43 | 44 | For the following examples, suppose we have an alias `Cache` and a storage in the variable `$storage`. 45 | 46 | ```php 47 | use Nette\Caching\Cache; 48 | 49 | $storage // instance of Nette\Caching\IStorage 50 | ``` 51 | 52 | The cache is actually a *key–value store*, so we read and write data under keys just like associative arrays. Applications consist of a number of independent parts, and if they all used one storage (for idea: one directory on a disk), sooner or later there would be a key collision. The Nette Framework solves the problem by dividing the entire space into namespaces (subdirectories). Each part of the program then uses its own space with a unique name and no collisions can occur. 53 | 54 | The name of the space is specified as the second parameter of the constructor of the Cache class: 55 | 56 | ```php 57 | $cache = new Cache($storage, 'Full Html Pages'); 58 | ``` 59 | 60 | We can now use object `$cache` to read and write from the cache. The method `load()` is used for both. The first argument is the key and the second is the PHP callback, which is called when the key is not found in the cache. The callback generates a value, returns it and caches it: 61 | 62 | ```php 63 | $value = $cache->load($key, function () use ($key) { 64 | $computedValue = ...; // heavy computations 65 | return $computedValue; 66 | }); 67 | ``` 68 | 69 | If the second parameter is not specified `$value = $cache->load($key)`, the `null` is returned if the item is not in the cache. 70 | 71 | The great thing is that any serializable structures can be cached, not only strings. And the same applies for keys. 72 | 73 | The item is cleared from the cache using method `remove()`: 74 | 75 | ```php 76 | $cache->remove($key); 77 | ``` 78 | 79 | You can also cache an item using method `$cache->save($key, $value, array $dependencies = [])`. However, the above method using `load()` is preferred. 80 | 81 | 82 | 83 | 84 | Memoization 85 | =========== 86 | 87 | Memoization means caching the result of a function or method so you can use it next time instead of calculating the same thing again and again. 88 | 89 | Methods and functions can be called memoized using `call(callable $callback, ...$args)`: 90 | 91 | ```php 92 | $result = $cache->call('gethostbyaddr', $ip); 93 | ``` 94 | 95 | The function `gethostbyaddr()` is called only once for each parameter `$ip` and the next time the value from the cache will be returned. 96 | 97 | It is also possible to create a memoized wrapper for a method or function that can be called later: 98 | 99 | ```php 100 | function factorial($num) 101 | { 102 | return ...; 103 | } 104 | 105 | $memoizedFactorial = $cache->wrap('factorial'); 106 | 107 | $result = $memoizedFactorial(5); // counts it 108 | $result = $memoizedFactorial(5); // returns it from cache 109 | ``` 110 | 111 | 112 | Expiration & Invalidation 113 | ========================= 114 | 115 | With caching, it is necessary to address the question that some of the previously saved data will become invalid over time. Nette Framework provides a mechanism, how to limit the validity of data and how to delete them in a controlled way ("to invalidate them", using the framework's terminology). 116 | 117 | The validity of the data is set at the time of saving using the third parameter of the method `save()`, eg: 118 | 119 | ```php 120 | $cache->save($key, $value, [ 121 | Cache::Expire => '20 minutes', 122 | ]); 123 | ``` 124 | 125 | Or using the `$dependencies` parameter passed by reference to the callback in the `load()` method, eg: 126 | 127 | ```php 128 | $value = $cache->load($key, function (&$dependencies) { 129 | $dependencies[Cache::Expire] = '20 minutes'; 130 | return ...; 131 | ]); 132 | ``` 133 | 134 | Or using 3rd parameter in the `load()` method, eg: 135 | 136 | ```php 137 | $value = $cache->load($key, function () { 138 | return ...; 139 | ], [Cache::Expire => '20 minutes']); 140 | ``` 141 | 142 | In the following examples, we will assume the second variant and thus the existence of a variable `$dependencies`. 143 | 144 | 145 | Expiration 146 | ---------- 147 | 148 | The simplest exiration is the time limit. Here's how to cache data valid for 20 minutes: 149 | 150 | ```php 151 | // it also accepts the number of seconds or the UNIX timestamp 152 | $dependencies[Cache::Expire] = '20 minutes'; 153 | ``` 154 | 155 | If we want to extend the validity period with each reading, it can be achieved this way, but beware, this will increase the cache overhead: 156 | 157 | ```php 158 | $dependencies[Cache::Sliding] = true; 159 | ``` 160 | 161 | The handy option is the ability to let the data expire when a particular file is changed or one of several files. This can be used, for example, for caching data resulting from procession these files. Use absolute paths. 162 | 163 | ```php 164 | $dependencies[Cache::Files] = '/path/to/data.yaml'; 165 | // nebo 166 | $dependencies[Cache::Files] = ['/path/to/data1.yaml', '/path/to/data2.yaml']; 167 | ``` 168 | 169 | We can let an item in the cache expired when another item (or one of several others) expires. This can be used when we cache the entire HTML page and fragments of it under other keys. Once the snippet changes, the entire page becomes invalid. If we have fragments stored under keys such as `frag1` and `frag2`, we will use: 170 | 171 | ```php 172 | $dependencies[Cache::Items] = ['frag1', 'frag2']; 173 | ``` 174 | 175 | Expiration can also be controlled using custom functions or static methods, which always decide when reading whether the item is still valid. For example, we can let the item expire whenever the PHP version changes. We will create a function that compares the current version with the parameter, and when saving we will add an array in the form `[function name, ...arguments]` to the dependencies: 176 | 177 | ```php 178 | function checkPhpVersion($ver): bool 179 | { 180 | return $ver === PHP_VERSION_ID; 181 | } 182 | 183 | $dependencies[Cache::Callbacks] = [ 184 | ['checkPhpVersion', PHP_VERSION_ID] // expire when checkPhpVersion(...) === false 185 | ]; 186 | ``` 187 | 188 | Of course, all criteria can be combined. The cache then expires when at least one criterion is not met. 189 | 190 | ```php 191 | $dependencies[Cache::Expire] = '20 minutes'; 192 | $dependencies[Cache::Files] = '/path/to/data.yaml'; 193 | ``` 194 | 195 | 196 | 197 | Invalidation using Tags 198 | ----------------------- 199 | 200 | Tags are a very useful invalidation tool. We can assign a list of tags, which are arbitrary strings, to each item stored in the cache. For example, suppose we have an HTML page with an article and comments, which we want to cache. So we specify tags when saving to cache: 201 | 202 | ```php 203 | $dependencies[Cache::Tags] = ["article/$articleId", "comments/$articleId"]; 204 | ``` 205 | 206 | Now, let's move to the administration. Here we have a form for article editing. Together with saving the article to a database, we call the `clean()` command, which will delete cached items by tag: 207 | 208 | ```php 209 | $cache->clean([ 210 | Cache::Tags => ["article/$articleId"], 211 | ]); 212 | ``` 213 | 214 | Likewise, in the place of adding a new comment (or editing a comment), we will not forget to invalidate the relevant tag: 215 | 216 | ```php 217 | $cache->clean([ 218 | Cache::Tags => ["comments/$articleId"], 219 | ]); 220 | ``` 221 | 222 | What have we achieved? That our HTML cache will be invalidated (deleted) whenever the article or comments change. When editing an article with ID = 10, the tag `article/10` is forced to be invalidated and the HTML page carrying the tag is deleted from the cache. The same happens when you insert a new comment under the relevant article. 223 | 224 | Tags require [Journal](#Journal). 225 | 226 | 227 | Invalidation by Priority 228 | ------------------------ 229 | 230 | We can set the priority for individual items in the cache, and it will be possible to delete them in a controlled way when, for example, the cache exceeds a certain size: 231 | 232 | ```php 233 | $dependencies[Cache::Priority] = 50; 234 | ``` 235 | 236 | Delete all items with a priority equal to or less than 100: 237 | 238 | ```php 239 | $cache->clean([ 240 | Cache::Priority => 100, 241 | ]); 242 | ``` 243 | 244 | Priorities require so-called [Journal](#Journal). 245 | 246 | 247 | Clear Cache 248 | ----------- 249 | 250 | The `Cache::All` parameter clears everything: 251 | 252 | ```php 253 | $cache->clean([ 254 | Cache::All => true, 255 | ]); 256 | ``` 257 | 258 | 259 | Bulk Reading 260 | ============ 261 | 262 | For bulk reading and writing to cache, the `bulkLoad()` method is used, where we pass an array of keys and obtain an array of values: 263 | 264 | ```php 265 | $values = $cache->bulkLoad($keys); 266 | ``` 267 | 268 | Method `bulkLoad()` works similarly to `load()` with the second callback parameter, to which the key of the generated item is passed: 269 | 270 | ```php 271 | $values = $cache->bulkLoad($keys, function ($key, &$dependencies) { 272 | $computedValue = ...; // heavy computations 273 | return $computedValue; 274 | }); 275 | ``` 276 | 277 | 278 | Output Caching 279 | ============== 280 | 281 | The output can be captured and cached very elegantly: 282 | 283 | ```php 284 | if ($capture = $cache->start($key)) { 285 | 286 | echo ... // printing some data 287 | 288 | $capture->end(); // save the output to the cache 289 | } 290 | ``` 291 | 292 | In case that the output is already present in the cache, the `start()` method prints it and returns `null`, so the condition will not be executed. Otherwise, it starts to buffer the output and returns the `$capture` object using which we finally save the data to the cache. 293 | 294 | 295 | Caching in Latte 296 | ================ 297 | 298 | Caching in templates [Latte](https://latte.nette.org) is very easy, just wrap part of the template with tags `{cache}...{/cache}`. The cache is automatically invalidated when the source template changes (including any included templates within the `{cache}` tags). Tags `{cache}` can be nested, and when a nested block is invalidated (for example, by a tag), the parent block is also invalidated. 299 | 300 | In the tag it is possible to specify the keys to which the cache will be bound (here the variable `$id`) and set the expiration and [invalidation tags](#invalidation-using-tags). 301 | 302 | ```html 303 | {cache $id, expire => '20 minutes', tags => [tag1, tag2]} 304 | ... 305 | {/cache} 306 | ``` 307 | 308 | All parameters are optional, so you don't have to specify expiration, tags, or keys. 309 | 310 | The use of the cache can also be conditioned by `if` - the content will then be cached only if the condition is met: 311 | 312 | ```html 313 | {cache $id, if => !$form->isSubmitted()} 314 | {$form} 315 | {/cache} 316 | ``` 317 | 318 | 319 | Storages 320 | ======== 321 | 322 | A storage is an object that represents where data is physically stored. We can use a database, a Memcached server, or the most available storage, which are files on disk. 323 | 324 | Storage | Description 325 | --------|---------------------- 326 | FileStorage | default storage with saving to files on disk 327 | MemcachedStorage | uses the `Memcached` server 328 | MemoryStorage | data are temporarily in memory 329 | SQLiteStorage | data is stored in SQLite database 330 | DevNullStorage | data aren't stored - for testing purposes 331 | 332 | 333 | FileStorage 334 | ----------- 335 | 336 | Writes the cache to files on disk. The storage `Nette\Caching\Storages\FileStorage` is very well optimized for performance and above all ensures full atomicity of operations. What does it mean? That when using the cache, it cannot happen that we read a file that has not yet been completely written by another thread, or that someone would delete it "under your hands". The use of the cache is therefore completely safe. 337 | 338 | This storage also has an important built-in feature that prevents an extreme increase in CPU usage when the cache is cleared or cold (ie not created). This is [cache stampede](https://en.wikipedia.org/wiki/Cache_stampede) prevention. 339 | It happens that at one moment there are several concurrent requests that want the same thing from the cache (eg the result of an expensive SQL query) and because it is not cached, all processes start executing the same SQL query. 340 | The processor load is multiplied and it can even happen that no thread can respond within the time limit, the cache is not created and the application crashes. 341 | Fortunately, the cache in Nette works in such a way that when there are multiple concurrent requests for one item, it is generated only by the first thread, the others wait and then use the generated result. 342 | 343 | Example of creating a FileStorage: 344 | 345 | ```php 346 | // the storage will be the directory '/path/to/temp' on the disk 347 | $storage = new Nette\Caching\Storages\FileStorage('/path/to/temp'); 348 | ``` 349 | 350 | MemcachedStorage 351 | ---------------- 352 | 353 | The server [Memcached](https://memcached.org) is a high-performance distributed storage system whose adapter is `Nette\Caching\Storages\MemcachedStorage`. 354 | 355 | Requires PHP extension `memcached`. 356 | 357 | ```php 358 | $storage = new Nette\Caching\Storages\MemcachedStorage('10.0.0.158'); 359 | ``` 360 | 361 | MemoryStorage 362 | ------------- 363 | 364 | `Nette\Caching\Storages\MemoryStorage` is a storage that stores data in a PHP array and is thus lost when the request is terminated. 365 | 366 | ```php 367 | $storage = new Nette\Caching\Storages\MemoryStorage; 368 | ``` 369 | 370 | 371 | SQLiteStorage 372 | ------------- 373 | 374 | The SQLite database and adapter `Nette\Caching\Storages\SQLiteStorage` offer a way to cache in a single file on disk. The configuration will specify the path to this file. 375 | 376 | Requires PHP extensions `pdo` and `pdo_sqlite`. 377 | 378 | ```php 379 | $storage = new Nette\Caching\Storages\SQLiteStorage('/path/to/cache.sdb'); 380 | ``` 381 | 382 | 383 | DevNullStorage 384 | -------------- 385 | 386 | A special implementation of storage is `Nette\Caching\Storages\DevNullStorage`, which does not actually store data at all. It is therefore suitable for testing if we want to eliminate the effect of the cache. 387 | 388 | ```php 389 | $storage = new Nette\Caching\Storages\DevNullStorage; 390 | ``` 391 | 392 | 393 | Journal 394 | ======= 395 | 396 | Nette stores tags and priorities in a so-called journal. By default, SQLite and file `journal.s3db` are used for this, and **PHP extensions `pdo` and `pdo_sqlite` are required.** 397 | 398 | If you like Nette, **[please make a donation now](https://github.com/sponsors/dg)**. Thank you! 399 | --------------------------------------------------------------------------------