檔案快取是中小型專案、無 Redis 環境、靜態內容生成的首選。本文從簡單的頁面快取類演進至生產級 FileCacheAdapter,實現 PSR-16 CacheInterface,支援 TTL、標籤失效、原子寫入、並發鎖、自動 GC。
<?php
class SimplePageCache {
private string $cacheDir;
private int $ttl;
public function __construct(string $cacheDir, int $ttl = 3600) {
$this->cacheDir = rtrim($cacheDir, '/');
$this->ttl = $ttl;
if (!is_dir($this->cacheDir)) mkdir($this->cacheDir, 0755, true);
}
public function start(string $key): bool {
$file = $this->getFilePath($key);
if (file_exists($file) && (time() - filemtime($file) < $this->ttl)) {
readfile($file);
return true; // 快取命中,直接輸出
}
ob_start(); // 開啟輸出緩衝
return false; // 快取未命中,繼續執行業務邏輯
}
public function end(string $key): void {
$content = ob_get_clean();
$this->save($key, $content);
echo $content;
}
private function getFilePath(string $key): string {
return $this->cacheDir . '/' . md5($key) . '.html';
}
private function save(string $key, string $content): void {
$file = $this->getFilePath($key);
$tmp = $file . '.tmp.' . bin2hex(random_bytes(4));
file_put_contents($tmp, $content);
rename($tmp, $file); // 原子替換
}
}
// 使用
$cache = new SimplePageCache(__DIR__.'/cache/pages', 300);
if (!$cache->start('homepage')) {
// 業務邏輯:查詢 DB、渲染模板...
echo '首頁內容
';
$cache->end('homepage');
}
?>
<?php
class FileCache {
private string $dir;
private int $defaultTtl;
private string $ext = '.cache.php'; // 使用 var_export 格式,opcache 友善
public function __construct(string $dir, int $defaultTtl = 3600) {
$this->dir = rtrim($dir, '/');
$this->defaultTtl = $defaultTtl;
if (!is_dir($this->dir)) mkdir($this->dir, 0755, true);
}
public function get(string $key, mixed $default = null): mixed {
$file = $this->path($key);
if (!file_exists($file)) return $default;
if ($this->isExpired($file)) {
$this->delete($key);
return $default;
}
return require $file; // 直接 require,opcache 加速
}
public function set(string $key, mixed $value, int $ttl = null): bool {
$ttl = $ttl ?? $this->defaultTtl;
$expire = time() + $ttl;
$content = "path($key);
$tmp = $file . '.tmp.' . bin2hex(random_bytes(4));
if (file_put_contents($tmp, $content) === false) return false;
if (!@rename($tmp, $file)) { @unlink($tmp); return false; }
return true;
}
public function delete(string $key): bool {
$file = $this->path($key);
return file_exists($file) && @unlink($file);
}
public function clear(): bool {
$files = glob($this->dir . '/*' . $this->ext);
foreach ($files as $f) @unlink($f);
return true;
}
public function has(string $key): bool {
$file = $this->path($key);
return file_exists($file) && !$this->isExpired($file);
}
private function path(string $key): string {
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '', $key);
return $this->dir . '/' . $safe . $this->ext;
}
private function isExpired(string $file): bool {
$line = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)[0] ?? '';
return preg_match('/EXPIRE:\s*(\d+)/', $line, $m) && (int)$m[1] < time();
}
}
?>
composer require psr/simple-cache ^1.0 | ^2.0 | ^3.0
<?php
namespace App\Cache;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;
class FileCacheAdapter implements CacheInterface {
private FileCache $cache; // 使用上述 FileCache
public function __construct(string $directory, int $defaultTtl = 3600) {
$this->cache = new FileCache($directory, $defaultTtl);
}
public function get(string $key, mixed $default = null): mixed {
$this->validateKey($key);
return $this->cache->get($key, $default);
}
public function set(string $key, mixed $value, int $ttl = null): bool {
$this->validateKey($key);
return $this->cache->set($key, $value, $ttl);
}
public function delete(string $key): bool {
$this->validateKey($key);
return $this->cache->delete($key);
}
public function clear(): bool {
return $this->cache->clear();
}
public function has(string $key): bool {
$this->validateKey($key);
return $this->cache->has($key);
}
// PSR-16 批次操作(選擇性實現,預設拋出異常)
public function getMultiple(iterable $keys, mixed $default = null): iterable {
$result = [];
foreach ($keys as $key) {
$this->validateKey($key);
$result[$key] = $this->get($key, $default);
}
return $result;
}
public function setMultiple(iterable $values, int $ttl = null): bool {
foreach ($values as $key => $value) {
$this->validateKey($key);
if (!$this->set($key, $value, $ttl)) return false;
}
return true;
}
public function deleteMultiple(iterable $keys): bool {
foreach ($keys as $key) {
$this->validateKey($key);
if (!$this->delete($key)) return false;
}
return true;
}
private function validateKey(string $key): void {
if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $key)) {
throw new InvalidArgumentException("Invalid cache key: $key");
}
}
}
?>
為快取項打標籤,實現「清除所有 `user:123` 相關快取」:
<?php
class TaggedFileCache extends FileCache {
private string $tagDir;
public function __construct(string $dir, int $defaultTtl = 3600) {
parent::__construct($dir, $defaultTtl);
$this->tagDir = $this->dir . '/_tags';
if (!is_dir($this->tagDir)) mkdir($this->tagDir, 0755, true);
}
public function set(string $key, mixed $value, int $ttl = null, array $tags = []): bool {
if (!parent::set($key, $value, $ttl)) return false;
foreach ($tags as $tag) {
$tagFile = $this->tagDir . '/' . md5($tag) . '.txt';
// 記錄 key 歸屬標籤(追加模式,鎖保護)
$fp = fopen($tagFile, 'c');
flock($fp, LOCK_EX);
fwrite($fp, $key . "\n");
flock($fp, LOCK_UN);
fclose($fp);
}
return true;
}
public function flushByTag(string $tag): int {
$tagFile = $this->tagDir . '/' . md5($tag) . '.txt';
if (!file_exists($tagFile)) return 0;
$keys = array_filter(array_map('trim', file($tagFile)));
$count = 0;
foreach ($keys as $key) {
if ($this->delete($key)) $count++;
}
@unlink($tagFile); // 清除標籤索引
return $count;
}
}
?>
<?php
// 併入 FileCache
public function gc(int $probability = 10): int {
// 機率性觸發:1/$probability 請求執行 GC
if (mt_rand(1, $probability) !== 1) return 0;
$files = glob($this->dir . '/*' . $this->ext);
$deleted = 0;
foreach ($files as $file) {
if ($this->isExpired($file)) {
@unlink($file);
$deleted++;
}
}
return $deleted;
}
// 在 get()/set() 開頭調用 $this->gc();
?>
| 優化點 | 實現 |
|---|---|
| OpCache 加速 | 使用 .cache.php + var_export + require,避免 unserialize 開銷 |
| 原子寫入 | 臨時檔 rename() 替換,防止讀取到半寫入內容 |
| 並發鎖 | 標籤索引寫入使用 flock();高併發讀取無需鎖(檔案系統原子性) |
| 目錄分片 | 大量快取時,按 md5($key)[0..1] 建立子目錄,避免單目錄檔案過多 |
| 記憶體映射 | 極高頻讀取可考慮 mmap 或直接載入 PHP 陣列(需評估記憶體) |
FileCacheAdapter 提供零依賴、PSR-16 相容、生產級的檔案快取方案。配合 OpCache、原子寫入、標籤失效、機率 GC,足以支撐中小型專案的全站快取需求。遷移至 Redis 時,只需替換適配器實現,業務代碼零修改。