靜態化是提升高流量頁面效能、降低資料庫壓力的經典架構手段。本文實現一個生產級 StaticPageGenerator 類,涵蓋目錄結構自動建立、輸出緩衝捕獲、原子寫入、多站點部署友好的路徑策略,並解決併發寫入、緩存失效、編碼一致性等工程化難題。
<?php
namespace App\Utils;
class StaticPageGenerator {
private string $basePath;
private string $urlPrefix;
private array $options;
public function __construct(array $config = []) {
$this->basePath = rtrim($config['base_path'] ?? sys_get_temp_dir() . '/static_pages', '/');
$this->urlPrefix = rtrim($config['url_prefix'] ?? '/static', '/');
$this->options = array_merge([
'dir_perms' => 0755,
'file_perms' => 0644,
'use_md5_name' => false,
'extension' => '.html',
'encoding' => 'UTF-8',
], $config);
}
/**
* 開始捕獲輸出緩衝
*/
public function start(): void {
if (ob_get_level() > 0) {
throw new \RuntimeException('已存在活躍的輸出緩衝區,請先結束或清理');
}
ob_start();
}
/**
* 結束捕獲並寫入靜態檔案
* @param string $identifier 頁面唯一標識 (如 article_id, page_slug)
* @param array $extraDirs 額外子目錄層級 ['year'=>'2024', 'month'=>'01']
* @return string 生成的相對 URL 路徑
*/
public function end(string $identifier, array $extraDirs = []): string {
$content = ob_get_clean();
if ($content === false) {
throw new \RuntimeException('無輸出緩衝區內容');
}
// 建構儲存路徑
$relPath = $this->buildRelativePath($identifier, $extraDirs);
$absPath = $this->basePath . '/' . $relPath;
// 確保目錄存在 (原子建立避免競態)
$dir = dirname($absPath);
if (!is_dir($dir)) {
if (!mkdir($dir, $this->options['dir_perms'], true) && !is_dir($dir)) {
throw new \RuntimeException("無法建立目錄: {$dir}");
}
}
// 原子寫入:先寫臨時檔再 rename
$tmpFile = $absPath . '.tmp.' . uniqid('', true);
$bytes = file_put_contents($tmpFile, $content, LOCK_EX);
if ($bytes === false) {
@unlink($tmpFile);
throw new \RuntimeException("寫入臨時檔失敗: {$tmpFile}");
}
if (!rename($tmpFile, $absPath)) {
@unlink($tmpFile);
throw new \RuntimeException("原子重命名失敗: {$tmpFile} -> {$absPath}");
}
chmod($absPath, $this->options['file_perms']);
// 返回可訪問 URL
return $this->urlPrefix . '/' . $relPath;
}
/**
* 直接生成靜態內容 (無需輸出緩衝)
*/
public function generate(string $identifier, string $content, array $extraDirs = []): string {
$absPath = $this->basePath . '/' . $this->buildRelativePath($identifier, $extraDirs);
$dir = dirname($absPath);
if (!is_dir($dir)) {
mkdir($dir, $this->options['dir_perms'], true);
}
$tmpFile = $absPath . '.tmp.' . uniqid('', true);
file_put_contents($tmpFile, $content, LOCK_EX);
rename($tmpFile, $absPath);
chmod($absPath, $this->options['file_perms']);
return $this->urlPrefix . '/' . $this->buildRelativePath($identifier, $extraDirs);
}
/**
* 刪除靜態頁
*/
public function delete(string $identifier, array $extraDirs = []): bool {
$absPath = $this->basePath . '/' . $this->buildRelativePath($identifier, $extraDirs);
if (is_file($absPath)) {
return unlink($absPath);
}
return false;
}
/**
* 獲取公開訪問 URL (不生成檔案)
*/
public function getUrl(string $identifier, array $extraDirs = []): string {
return $this->urlPrefix . '/' . $this->buildRelativePath($identifier, $extraDirs);
}
private function buildRelativePath(string $identifier, array $extraDirs): string {
$parts = [];
foreach ($extraDirs as $key => $val) {
if ($val !== '') $parts[] = $val;
}
$filename = $this->options['use_md5_name']
? md5($identifier) . $this->options['extension']
: $identifier . $this->options['extension'];
$parts[] = $filename;
return implode('/', $parts);
}
}
<?php
require_once 'vendor/autoload.php';
$generator = new App\Utils\StaticPageGenerator([
'base_path' => '/var/www/html/static',
'url_prefix' => 'https://cdn.example.com/static',
]);
$generator->start();
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title><?php echo $article['title']; ?></title>
</head>
<body>
<article>
<h1><?php echo $article['title']; ?></h1>
<div class="content"><?php echo $article['content']; ?></div>
</article>
</body>
</html>
<?php
$url = $generator->end("article_{$article['id']}", [
'year' => date('Y'),
'month' => date('m'),
'day' => date('d'),
]);
echo "靜態頁已生成: {$url}";
?>
<?php
// 使用 Twig 為例
$twig = new \Twig\Environment(new \Twig\Loader\FilesystemLoader('/path/to/templates'));
$html = $twig->render('article/detail.html.twig', [
'article' => $article,
'seo' => $seoData,
]);
$generator = new App\Utils\StaticPageGenerator([
'base_path' => '/var/www/html/static',
'url_prefix' => 'https://cdn.example.com/static',
]);
$url = $generator->generate("article_{$article['id']}", $html, [
'year' => date('Y'),
'month' => date('m'),
]);
// 同時更新資料庫靜態頁 URL 字段
DB::table('articles')->where('id', $article['id'])->update(['static_url' => $url]);
?>
<?php
use Symfony\Component\Process\Process;
function batchGenerateArticles(array $ids, int $concurrency = 4): void {
$chunks = array_chunk($ids, ceil(count($ids) / $concurrency));
$processes = [];
foreach ($chunks as $chunk) {
$process = new Process([
'php', 'generate_batch.php', '--ids=' . implode(',', $chunk)
]);
$process->start();
$processes[] = $process;
}
foreach ($processes as $p) {
$p->wait();
if ($p->getExitCode() !== 0) {
error_log("批次生成失敗: " . $p->getErrorOutput());
}
}
}
// generate_batch.php
$ids = explode(',', $argv[1] ?? '');
$generator = new App\Utils\StaticPageGenerator([...]);
foreach ($ids as $id) {
$article = DB::table('articles')->find($id);
if ($article) {
$html = renderArticleTemplate($article);
$generator->generate("article_{$id}", $html, ['year'=>date('Y'), 'month'=>date('m')]);
}
}
?>
file_put_contents($tmp) + rename() 在 POSIX 系統上是原子操作,Nginx/Apache 讀取靜態檔時永遠只會看到完整內容或舊版本,不會出現截斷內容。
year/month/day/ 避免單目錄檔案過多 (ext4 單目錄建議 < 10 萬檔)。use_md5_name 可避免特殊字元、過長標識符導致的檔案系統問題,且分布均勻。url_prefix 直接指向 CDN 域名,生成後即可推送預熱。LOCK_EX 確保同一進程多線程寫入同檔案時互斥。<?php
// 文章更新時觸發
function onArticleUpdated(int $articleId): void {
$generator = new StaticPageGenerator([...]);
// 1. 刪除舊版本 (若路徑含時間可能不同,需掃描或記錄映射關係)
// 建議資料庫存儲 static_path 欄位,直接刪除
$oldPath = DB::table('articles')->where('id', $articleId)->value('static_path');
if ($oldPath) {
$absPath = '/var/www/html/static' . str_replace('https://cdn.example.com/static', '', $oldPath);
@unlink($absPath);
}
// 2. 異步重新生成 (推送到隊列)
Queue::push('static.generate', ['type' => 'article', 'id' => $articleId]);
}
server {
listen 80;
server_name cdn.example.com;
root /var/www/html/static;
location / {
# 靜態檔案直接服務,不經 PHP
try_files $uri $uri/ =404;
# 長期緩存 (配合檔名含 hash 或版本號)
location ~* \.(html|css|js|png|jpg|jpeg|gif|webp|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# HTML 短期緩存,支援快速失效
location ~* \.html$ {
expires 5m;
add_header Cache-Control "public, must-revalidate";
}
}
}
本生成器類解決了靜態化的核心痛點:目錄自建、原子寫入、編碼一致、CDN 整合、併發安全、失效策略。實際專案中建議搭配:
entity_type, entity_id, static_path, generated_at,支援精準失效與審計。靜態化不是銀彈,但對「讀多寫少、內容相對固定」的頁面 (文章詳情、商品頁、分類列表、幫助中心) 是極具性價比的效能杠桿。