上一篇介紹了基礎的 CreateHtml 類。生產環境需解決:多種目錄組織策略 (按年/月/日/分類/ID)、多模板渲染、文件命名規範、併發安全、緩存失效與更新。本文呈現一個功能完整的 HtmlGenerator 類。
<?php
/**
* 專業級靜態頁面生成器
* 特性:
* - 多種目錄策略:year, month, day, category, id, flat
* - 模板引擎無關 (支持原生 PHP、Blade、Twig、Smarty 等)
* - 併發安全寫入 (文件鎖 + 原子重命名)
* - 緩存元數據記錄 (便於失效/更新)
* - 靈活的文件命名規則
*/
class HtmlGenerator {
// 配置屬性
private $rootDir = 'html'; // 靜態文件根目錄
private $dirStrategy = 'day'; // 目錄策略
private $nameStrategy = 'id'; // 命名策略
private $templateEngine = null; // 模板引擎實例
private $templatePath = ''; // 模板目錄
private $metadataFile = '.metadata.json'; // 元數據文件
// 運行時狀態
private $currentData = [];
private $currentTemplate = '';
private $generatedPath = '';
/** 目錄策略常量 */
const DIR_YEAR = 'year'; // html/2024/
const DIR_MONTH = 'month'; // html/2024/05/
const DIR_DAY = 'day'; // html/2024/05/20/
const DIR_CATEGORY = 'category'; // html/news/
const DIR_ID = 'id'; // html/12345/ (按ID分目錄)
const DIR_FLAT = 'flat'; // html/ (平鋪)
/** 命名策略常量 */
const NAME_ID = 'id'; // 12345.html
const NAME_TIME_ID = 'time_id'; // 20240520_12345.html
const NAME_SLUG = 'slug'; // article-title.html
const NAME_MD5 = 'md5'; // a1b2c3d4.html
const NAME_CUSTOM = 'custom'; // 自定義回調
public function __construct(array $config = []) {
$this->rootDir = $config['root_dir'] ?? 'html';
$this->dirStrategy = $config['dir_strategy'] ?? self::DIR_DAY;
$this->nameStrategy = $config['name_strategy'] ?? self::NAME_ID;
$this->templatePath = $config['template_path'] ?? '';
$this->templateEngine = $config['template_engine'] ?? null;
$this->metadataFile = $config['metadata_file'] ?? '.metadata.json';
}
/** 設置模板引擎 (需實現 render(string $template, array $data): string 接口) */
public function setTemplateEngine($engine) {
$this->templateEngine = $engine;
return $this;
}
/** 設置數據並生成 */
public function generate(array $data, string $template = '', string $customName = ''): string {
$this->currentData = $data;
$this->currentTemplate = $template;
// 1. 渲染 HTML
$html = $this->render($template, $data);
// 2. 確定保存路徑
$dir = $this->buildDirectory($data);
$fileName = $this->buildFileName($data, $customName);
$fullPath = $dir . DIRECTORY_SEPARATOR . $fileName;
// 3. 原子寫入
$this->atomicWrite($fullPath, $html);
// 4. 記錄元數據
$this->recordMetadata($fullPath, $data);
$this->generatedPath = $fullPath;
return $fullPath;
}
/** 渲染模板 */
private function render(string $template, array $data): string {
if ($this->templateEngine) {
// 支持任意實現 render() 的引擎
return $this->templateEngine->render($template, $data);
}
// 原生 PHP 模板 (extract + include)
if ($template && is_file($this->templatePath . DIRECTORY_SEPARATOR . $template)) {
extract($data, EXTR_SKIP); // 變量解壓到當前作用域
ob_start();
include $this->templatePath . DIRECTORY_SEPARATOR . $template;
return ob_get_clean();
}
// 無模板:直接序列化數據 (調試用)
return '<pre>' . htmlspecialchars(var_export($data, true)) . '</pre>';
}
/** 構建目錄路徑 */
private function buildDirectory(array $data): string {
$base = rtrim($this->rootDir, '/\\');
$subDir = '';
switch ($this->dirStrategy) {
case self::DIR_YEAR:
$subDir = date('Y');
break;
case self::DIR_MONTH:
$subDir = date('Y/m');
break;
case self::DIR_DAY:
$subDir = date('Y/m/d');
break;
case self::DIR_CATEGORY:
$cat = $data['category'] ?? $data['cat_id'] ?? 'uncategorized';
$subDir = strtolower(preg_replace('/[^a-z0-9_-]/i', '_', $cat));
break;
case self::DIR_ID:
$id = $data['id'] ?? $data['article_id'] ?? 'unknown';
$subDir = (string)$id;
break;
case self::DIR_FLAT:
default:
$subDir = '';
}
$fullDir = $base . ($subDir ? DIRECTORY_SEPARATOR . $subDir : '');
// 遞歸創建 (權限 0755)
if (!is_dir($fullDir)) {
if (!mkdir($fullDir, 0755, true)) {
throw new RuntimeException("無法創建目錄: $fullDir");
}
}
return $fullDir;
}
/** 構建文件名 */
private function buildFileName(array $data, string $customName = ''): string {
$ext = '.html';
switch ($this->nameStrategy) {
case self::NAME_ID:
$name = $data['id'] ?? $data['article_id'] ?? 'index';
break;
case self::NAME_TIME_ID:
$time = isset($data['created_at']) ? $data['created_at'] : time();
$id = $data['id'] ?? $data['article_id'] ?? 'index';
$name = date('YmdHis', $time) . '_' . $id;
break;
case self::NAME_SLUG:
$title = $data['title'] ?? $data['slug'] ?? 'untitled';
$name = $this->slugify($title);
break;
case self::NAME_MD5:
$content = json_encode($data, JSON_UNESCAPED_UNICODE);
$name = substr(md5($content), 0, 12);
break;
case self::NAME_CUSTOM:
$name = $customName ?: 'custom';
break;
default:
$name = 'index';
}
// 文件名安全過濾
$name = preg_replace('/[^a-zA-Z0-9_-]/', '', $name);
return $name . $ext;
}
/** 字符串轉 slug */
private function slugify(string $text): string {
// 簡易版,生產建議用 cocur/slugify 库
$text = mb_strtolower($text, 'UTF-8');
$text = preg_replace('/[^a-z0-9\u4e00-\u9fa5]+/u', '-', $text);
$text = trim($text, '-');
return substr($text, 0, 100); // 限制長度
}
/** 原子寫入:寫入臨時文件 -> 重命名 (避免讀取到半寫入文件) */
private function atomicWrite(string $fullPath, string $content): void {
$dir = dirname($fullPath);
$tmpName = $dir . DIRECTORY_SEPARATOR . '.' . basename($fullPath) . '.tmp.' . uniqid();
$fp = fopen($tmpName, 'w');
if (!$fp) {
throw new RuntimeException("無法創建臨時文件: $tmpName");
}
// 獨佔鎖
if (flock($fp, LOCK_EX)) {
fwrite($fp, $content);
flock($fp, LOCK_UN);
}
fclose($fp);
// 原子重命名 (同文件系統下保證原子性)
if (!rename($tmpName, $fullPath)) {
@unlink($tmpName);
throw new RuntimeException("文件重命名失敗: $tmpName -> $fullPath");
}
// 設置文件權限
@chmod($fullPath, 0644);
}
/** 記錄元數據 (用於緩存失效查找) */
private function recordMetadata(string $path, array $data): void {
$metaFile = dirname($path) . DIRECTORY_SEPARATOR . $this->metadataFile;
$meta = [];
if (is_file($metaFile)) {
$json = file_get_contents($metaFile);
$meta = json_decode($json, true) ?? [];
}
$key = basename($path);
$meta[$key] = [
'id' => $data['id'] ?? $data['article_id'] ?? null,
'category' => $data['category'] ?? $data['cat_id'] ?? null,
'updated_at' => $data['updated_at'] ?? time(),
'generated_at' => time(),
'template' => $this->currentTemplate,
];
// 限制元數據條數,防止文件過大
if (count($meta) > 10000) {
// 簡單策略:保留最新 5000
krsort($meta);
$meta = array_slice($meta, 0, 5000, true);
}
file_put_contents($metaFile, json_encode($meta, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
/** 根據 ID 刪除靜態文件 (更新/刪除文章時調用) */
public function deleteById($id, $category = null): int {
$deleted = 0;
// 掃描元數據文件查找 (實際項目建議用數據庫記錄路徑)
// 這裡演示掃描根目錄下所有元數據文件
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->rootDir, FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->getBasename() === $this->metadataFile) {
$meta = json_decode(file_get_contents($file->getPathname()), true) ?? [];
foreach ($meta as $fileName => $info) {
if (($info['id'] ?? null) == $id &&
($category === null || ($info['category'] ?? null) == $category)) {
$targetPath = $file->getPath() . DIRECTORY_SEPARATOR . $fileName;
if (is_file($targetPath) && unlink($targetPath)) {
$deleted++;
unset($meta[$fileName]);
}
}
}
// 更新元數據文件
file_put_contents($file->getPathname(), json_encode($meta), LOCK_EX);
}
}
return $deleted;
}
/** 獲取最後生成的路徑 */
public function getGeneratedPath(): string {
return $this->generatedPath;
}
}
?>
<?php
// composer require illuminate/view
use Illuminate\View\Factory;
use Illuminate\Filesystem\Filesystem;
// 初始化 Blade (Laravel 組件獨立使用)
$blade = new Factory(new Filesystem(), __DIR__ . '/templates/cache');
$blade->addLocation(__DIR__ . '/templates');
// 配置生成器
$generator = new HtmlGenerator([
'root_dir' => 'public/static',
'dir_strategy' => HtmlGenerator::DIR_CATEGORY,
'name_strategy' => HtmlGenerator::NAME_SLUG,
'template_path' => __DIR__ . '/templates',
'template_engine' => $blade,
]);
// 文章數據 (來自數據庫)
$article = [
'id' => 1001,
'title' => 'PHP 靜態化最佳實踐',
'slug' => 'php-static-best-practice',
'category' => 'tech',
'content' => '<p>內容...</p>',
'author' => '張三',
'created_at' => strtotime('2024-05-20 10:00:00'),
'updated_at' => time(),
];
// 生成靜態頁
try {
$path = $generator->generate($article, 'article.blade.php');
echo "✅ 生成成功: $path";
} catch (Exception $e) {
echo "❌ 生成失敗: " . $e->getMessage();
}
?>
<!-- templates/article.blade.php -->
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<title>{{ $title }} - 技術博客</title>
<meta name="description" content="{{ Str::limit(strip_tags($content), 160) }}">
</head>
<body>
<article>
<header>
<h1>{{ $title }}</h1>
<div class="meta">
作者: {{ $author }} |
分類: {{ $category }} |
時間: {{ date("Y-m-d H:i", $created_at) }}
</div>
</header>
<div class="content">
{!! $content !!}
</div>
</article>
</body>
</html>
<?php
// 文章更新後
function updateArticle($id, $data) {
global $db, $generator;
$db->update('articles', $data, ['id' => $id]);
// 刪除舊靜態文件
$generator->deleteById($id);
// 異步重新生成 (推入隊列)
Queue::push('regenerate_static', ['id' => $id]);
}
?>
<?php
// crontab: 0 3 * * * /usr/bin/php /var/www/rebuild_static.php
// 每天凌晨 3 點重建過去 24 小時更新的文章
$updatedArticles = $db->query("SELECT * FROM articles WHERE updated_at > ?", time() - 86400);
foreach ($updatedArticles as $article) {
$generator->generate($article, 'article.blade.php');
}
?>
pcntl_fork 或 Swoole/Workerman 多進程並行。ob_start('ob_gzhandler') 或事後用 gzip 壓縮存儲,Nginx 直接發送 .gz。