ThinkPHP 內建的檔案快取驅動(`File`)採用單檔案儲存 JSON 序列化資料,結構簡單、零依賴,適合中小型專案。本文解析其核心原始碼,指出潛在問題(競態條件、序列化效能、目錄權限),並提供現代化重構版本(支援 PSR-16、標籤清除、原子寫入)。
class Cache {
const CACHE_DIR = '/Runtime/Cache/';
const EXT = '.php'; // 實際為 .php 檔,內容為
private $dir = '';
public function __construct(string $dir = '') { $this->dir = $dir; }
public function set(string $key, mixed $data, int $expire = 0): bool {
$filename = $this->getFilename($key);
// 簡易去重:比對 MD5
if (file_exists($filename)) {
$old = $this->get($key);
if (md5($old) === md5(json_encode($data))) return true;
}
$this->ensureDir(dirname($filename));
$content = "";
return file_put_contents($filename, $content, LOCK_EX) !== false;
}
public function get(string $key): mixed {
$filename = $this->getFilename($key);
if (!file_exists($filename)) return false;
$data = include $filename; // 直接 include 回傳陣列
// 這裡缺少過期時間檢查!原版依賴檔案 mtime 或獨立快取元資料
return $data;
}
public function del(string $key): bool {
$filename = $this->getFilename($key);
return file_exists($filename) ? unlink($filename) : false;
}
private function getFilename(string $key): string {
return __DIR__ . self::CACHE_DIR . $this->dir . md5($key) . self::EXT;
}
private function ensureDir(string $path): void {
if (!is_dir($path)) mkdir($path, 0755, true);
}
}
| 問題 | 影響 | 說明 |
|---|---|---|
| 缺少過期時間檢查 | 資料永不過期 | `get()` 未檢查 `$expire`,需額外機制(如獨立 meta 檔或檔名編碼時間) |
| `var_export` 序列化 | 效能差、不支援閉包/資源 | 大型陣列輸出極慢,建議改用 `igbinary` 或 `msgpack` |
| 無原子寫入保證 | 高併發下可能讀到半寫入檔案 | 建議寫入暫存檔再 `rename()` |
| 目錄權限 0777 | 安全風險 | 共享主機環境極危險,建議 0755 + 正確擁有者 |
| 不支援標籤/群組清除 | 無法批次失效相關快取 | 需額外索引機制 |
<?php
declare(strict_types=1);
namespace App\Cache;
use Psr\SimpleCache\CacheInterface;
use RuntimeException;
class FileCache implements CacheInterface
{
private string $directory;
private int $defaultTtl;
private string $extension = '.cache.php';
private array $index = []; // key => [expire, tags]
public function __construct(string $directory, int $defaultTtl = 3600)
{
$this->directory = rtrim($directory, '/') . '/';
$this->defaultTtl = $defaultTtl;
if (!is_dir($this->directory)) {
mkdir($this->directory, 0755, true);
}
$this->loadIndex();
}
public function get(string $key, mixed $default = null): mixed
{
$this->validateKey($key);
$meta = $this->index[$key] ?? null;
if (!$meta) return $default;
if ($meta['expire'] > 0 && $meta['expire'] < time()) {
$this->delete($key);
return $default;
}
$file = $this->filePath($key);
if (!file_exists($file)) return $default;
$data = @include $file;
return $data === false ? $default : $data;
}
public function set(string $key, mixed $value, int $ttl = null): bool
{
$this->validateKey($key);
$ttl = $ttl ?? $this->defaultTtl;
$expire = $ttl > 0 ? time() + $ttl : 0;
$file = $this->filePath($key);
$tmpFile = $file . '.tmp.' . bin2hex(random_bytes(4));
$content = "";
if (file_put_contents($tmpFile, $content, LOCK_EX) === false) {
@unlink($tmpFile);
return false;
}
if (!@rename($tmpFile, $file)) {
@unlink($tmpFile);
return false;
}
@chmod($file, 0644);
$this->index[$key] = ['expire' => $expire, 'tags' => []];
$this->saveIndex();
return true;
}
public function delete(string $key): bool
{
$this->validateKey($key);
$file = $this->filePath($key);
$deleted = file_exists($file) && unlink($file);
if (isset($this->index[$key])) {
unset($this->index[$key]);
$this->saveIndex();
}
return $deleted;
}
public function clear(): bool
{
$files = glob($this->directory . '*' . $this->extension);
foreach ($files as $f) @unlink($f);
$this->index = [];
$this->saveIndex();
return true;
}
public function getMultiple(iterable $keys, mixed $default = null): iterable
{
$result = [];
foreach ($keys as $key) $result[$key] = $this->get($key, $default);
return $result;
}
public function setMultiple(iterable $values, int $ttl = null): bool
{
foreach ($values as $key => $value) {
if (!$this->set($key, $value, $ttl)) return false;
}
return true;
}
public function deleteMultiple(iterable $keys): bool
{
foreach ($keys as $key) $this->delete($key);
return true;
}
public function has(string $key): bool
{
return $this->get($key) !== null;
}
// --- 擴充功能:標籤支援 ---
public function setWithTags(string $key, mixed $value, array $tags, int $ttl = null): bool
{
if (!$this->set($key, $value, $ttl)) return false;
$this->index[$key]['tags'] = $tags;
foreach ($tags as $tag) {
$tagFile = $this->directory . '.tags.' . md5($tag) . '.json';
$tagKeys = file_exists($tagFile) ? json_decode(file_get_contents($tagFile), true) : [];
$tagKeys[$key] = true;
file_put_contents($tagFile, json_encode(array_keys($tagKeys)), LOCK_EX);
}
$this->saveIndex();
return true;
}
public function deleteByTag(string $tag): int
{
$tagFile = $this->directory . '.tags.' . md5($tag) . '.json';
if (!file_exists($tagFile)) return 0;
$keys = json_decode(file_get_contents($tagFile), true);
$count = 0;
foreach ($keys as $key => _) {
if ($this->delete($key)) $count++;
}
@unlink($tagFile);
return $count;
}
// --- 內部方法 ---
private function filePath(string $key): string
{
return $this->directory . md5($key) . $this->extension;
}
private function validateKey(string $key): void
{
if (strpos($key, '..') !== false || strpos($key, '/') !== false) {
throw new RuntimeException('Invalid cache key');
}
}
private function loadIndex(): void
{
$indexFile = $this->directory . '.index.json';
if (file_exists($indexFile)) {
$this->index = json_decode(file_get_contents($indexFile), true) ?? [];
}
}
private function saveIndex(): void
{
$indexFile = $this->directory . '.index.json';
file_put_contents($indexFile, json_encode($this->index), LOCK_EX);
}
}
$cache = new FileCache(__DIR__ . '/runtime/cache', 3600);
// 基本操作
$cache->set('user:1', ['name' => 'John', 'role' => 'admin'], 1800);
$user = $cache->get('user:1');
// 標籤批次清除
$cache->setWithTags('article:100', $content, ['article', 'category:tech'], 3600);
$cache->setWithTags('article:101', $content, ['article', 'category:tech'], 3600);
$cache->deleteByTag('category:tech'); // 一次清除該分類所有文章快取
// PSR-16 相容:可直接注入支援 PSR-16 的服務容器
檔案快取雖不如 Redis 功能豐富,但零依賴、部署簡單、適合中小流量或無法安裝擴展的環境。採用 PSR-16 介面並加強原子性、標籤支援後,足以應付大多數實務場景。