PHP 內建 Session 機制預設將資料儲存在伺服器臨時目錄的檔案中(session.save_handler = files)。但在某些場景下,開發者需要完全掌控 Session 儲存邏輯:例如分散式部署共享 Session、儲存至資料庫/Redis、自訂加密、或單純為了學習 Session 底層運作。本文將詳細講解如何實作一套自定檔案儲存 Session 類別,包含 Session ID 管理、資料序列化、檔案鎖定、垃圾回收等核心機制。
<?php
/**
* 自定檔案儲存 Session 類別
* 功能:Cookie 傳遞 ID、檔案讀寫、序列化、簡易鎖定、GC
*/
class FileSessionHandler implements SessionHandlerInterface
{
private string $savePath;
private int $lifetime;
private ?string $sessionId = null;
private ?resource $fileHandle = null;
private array $data = [];
public function __construct(string $savePath = '', int $lifetime = 1440)
{
$this->savePath = $savePath ?: sys_get_temp_dir() . '/custom_sessions';
$this->lifetime = $lifetime;
if (!is_dir($this->savePath)) {
mkdir($this->savePath, 0700, true);
}
}
// ---------- SessionHandlerInterface 方法 ----------
public function open(string $path, string $name): bool
{
// 1. 從 Cookie 取得 Session ID
$this->sessionId = $_COOKIE[$name] ?? null;
// 2. 若無 ID 或檔案不存在,產生新 ID
if (!$this->sessionId || !$this->_fileExists($this->sessionId)) {
$this->sessionId = $this->_generateId();
setcookie($name, $this->sessionId, [
'expires' => time() + $this->lifetime,
'path' => '/',
'httponly'=> true,
'secure' => isset($_SERVER['HTTPS']),
'samesite'=> 'Lax',
]);
}
// 3. 開啟檔案(讀寫模式,建立獨佔鎖)
$file = $this->_getFilePath($this->sessionId);
$this->fileHandle = fopen($file, 'c+'); // c+ 不截斷,若不存在則建立
if (!$this->fileHandle) return false;
// 獨佔鎖防止併發寫入衝突
if (!flock($this->fileHandle, LOCK_EX)) {
fclose($this->fileHandle);
return false;
}
// 4. 讀取並反序列化資料
$content = stream_get_contents($this->fileHandle);
$this->data = $content ? @unserialize($content) : [];
if (!is_array($this->data)) $this->data = [];
// 5. 註冊關閉時自動寫入
register_shutdown_function([$this, 'close']);
return true;
}
public function close(): bool
{
if ($this->fileHandle) {
// 寫入前截斷檔案
ftruncate($this->fileHandle, 0);
rewind($this->fileHandle);
fwrite($this->fileHandle, serialize($this->data));
flock($this->fileHandle, LOCK_UN);
fclose($this->fileHandle);
$this->fileHandle = null;
}
return true;
}
public function read(string $id): string|false
{
// 由 open() 已載入 $this->data,此處僅供介面相容
return serialize($this->data);
}
public function write(string $id, string $data): bool
{
// 實際寫入在 close() 統一處理,這裡更新記憶體資料
$this->data = @unserialize($data);
if (!is_array($this->data)) $this->data = [];
return true;
}
public function destroy(string $id): bool
{
$file = $this->_getFilePath($id);
if (file_exists($file)) {
@unlink($file);
}
// 清除 Cookie
if (isset($_COOKIE[session_name()])) {
setcookie(session_name(), '', time() - 3600, '/');
}
$this->data = [];
return true;
}
public function gc(int $maxLifetime): int|false
{
$deleted = 0;
$now = time();
$dir = new DirectoryIterator($this->savePath);
foreach ($dir as $file) {
if ($file->isFile() && ($now - $file->getMTime()) > $maxLifetime) {
@unlink($file->getPathname());
$deleted++;
}
}
return $deleted;
}
// ---------- 輔助方法 ----------
private function _generateId(): string
{
// 使用隨機位元組 + 時間 + 進程 ID,極低衝突機率
return bin2hex(random_bytes(16)) . '_' . time() . '_' . getmypid();
}
private function _getFilePath(string $id): string
{
// 為避免單一目錄檔案過多,可用前 2 字元做子目錄
$prefix = substr($id, 0, 2);
$dir = $this->savePath . '/' . $prefix;
if (!is_dir($dir)) mkdir($dir, 0700, true);
return $dir . '/sess_' . $id;
}
private function _fileExists(string $id): bool
{
return file_exists($this->_getFilePath($id));
}
// ---------- 對外便利方法 ----------
public function __set(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
public function __get(string $key): mixed
{
return $this->data[$key] ?? null;
}
public function __isset(string $key): bool
{
return isset($this->data[$key]);
}
public function __unset(string $key): void
{
unset($this->data[$key]);
}
public function getId(): ?string
{
return $this->sessionId;
}
public function regenerateId(bool $deleteOld = false): bool
{
$oldId = $this->sessionId;
$this->sessionId = $this->_generateId();
// 更新 Cookie
setcookie(session_name(), $this->sessionId, [
'expires' => time() + $this->lifetime,
'path' => '/',
'httponly'=> true,
'secure' => isset($_SERVER['HTTPS']),
'samesite'=> 'Lax',
]);
if ($deleteOld && $oldId) {
@unlink($this->_getFilePath($oldId));
}
return true;
}
}
在專案入口檔案(如 index.php 或 bootstrap.php)最早期呼叫:
<?php
require_once 'FileSessionHandler.php';
$handler = new FileSessionHandler(
savePath: '/var/www/myapp/sessions', // 自訂儲存目錄,需可寫入
lifetime: 7200 // 2 小時
);
// 註冊為 PHP 內建 session 機制的處理器
session_set_save_handler($handler, true); // true = 關閉時自動呼叫 shutdown
session_start();
// 現在可以正常使用 $_SESSION
$_SESSION['user_id'] = 123;
$_SESSION['username'] = 'admin';
$_SESSION['permissions'] = ['read', 'write'];
// 測試跨頁面
echo "Session ID: " . $handler->getId() . "
";
echo "User: " . $_SESSION['username'] . "
";
echo '<a href="page2.php">前往第 2 頁</a>';
<?php
require_once 'FileSessionHandler.php';
$handler = new FileSessionHandler('/var/www/myapp/sessions', 7200);
session_set_save_handler($handler, true);
session_start();
echo "Session ID: " . $handler->getId() . "
";
echo "User ID: " . ($_SESSION['user_id'] ?? 'null') . "
";
echo "Permissions: " . implode(', ', $_SESSION['permissions'] ?? []) . "
";
檔案儲存不適合多伺服器負載平衡。若需分散式共享,只需替換儲存層實作 SessionHandlerInterface:
以 Redis 為例,核心 read/write/destroy/gc 分別對應 GET/SET/DEL/掃描過期鍵,效能遠優於檔案 I/O。
| 項目 | 建議設定 |
|---|---|
| Session ID 長度/熵值 | ≥ 128 bits(本類別用 16 bytes random + 時間 + PID) |
| Cookie 參數 | HttpOnly、Secure(HTTPS)、SameSite=Lax/Strict |
| 儲存目錄權限 | 0700,僅擁有者可讀寫,防止其他用戶竊取 |
| 檔案鎖定 | 使用 flock(LOCK_EX) 防止併發寫入資料損毀 |
| Session 劫持防護 | 驗證 User-Agent、IP 一致性;關鍵操作重新驗證密碼 |
| 定期輪換 ID | 登入後、權限變更後呼叫 regenerateId(true) |
自定檔案儲存 Session 雖非生產環境首選(建議直接用 Redis),但完整實作過程能深入理解 PHP Session 生命週期、序列化、鎖定、GC 等核心機制。本文提供的 FileSessionHandler 類別已實作 SessionHandlerInterface,可直接替換內建處理器,並具備生產級的安全性與擴充性。開發者可在此基礎上快速遷移至資料庫或 Redis 儲存,實現分散式 Session 共享。