為什麼要生成靜態頁?
| 優勢 | 說明 |
| 效能 | 無資料庫查詢、無模板渲染、直接由 Web Server (Nginx/Apache) 靜態檔案服務,極致快速 |
| SEO | 搜尋引擎爬蟲偏好靜態 URL,利於收錄與排名 |
| 安全 | 隱藏後端技術棧、資料庫結構、參數邏輯,降低注入/探測風險 |
| 穩定 | 資料庫掛掉不影響已生成頁面;靜態檔案伺服器極簡、極穩 |
兩大主流方案比較
| 方案 | 原理 | 優點 | 缺點 | 適用場景 |
| 模板引擎 | FreeMarker/Velocity/Thymeleaf 渲染模板產出 HTML | 資料與視圖分離、邏輯可控、支援複雜條件 | 需維護模板、語法學習成本、大頁面模板編寫繁瑣 | 結構固定、需精細控制輸出的頁面 |
| URL 抓取 | HTTP 請求動態頁,將回應直接存檔 | 零侵入、複用現有 Controller/View、無需寫模板 | 需處理 Session/Cookie、動態參數、並發生成鎖 | 現有系統改造、頁面邏輯複雜、快速上線 |
核心工具類:HTMLGenerator
package com.example.util;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.regex.*;
public class HTMLGenerator {
private static final Pattern URL_PATTERN = Pattern.compile("^(https?://)[\\w\\-\\./:]+");
/**
* 抓取 URL 完整 HTML 內容
* @param url 目標完整 URL (含協定、域名、路徑、查詢字串)
* @return HTML 字串,失敗返回 null
*/
public static String fetch(String url) {
if (StringUtils.isBlank(url) || !URL_PATTERN.matcher(url).find()) {
return null;
}
StringBuilder sb = new StringBuilder();
try {
URL target = new URL(url);
URLConnection conn = target.openConnection();
// 模擬瀏覽器,避免 403
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; HTMLGenerator/1.0)");
conn.setConnectTimeout(10000);
conn.setReadTimeout(30000);
try (BufferedReader in = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = in.readLine()) != null) {
sb.append(line);
}
}
} catch (MalformedURLException e) {
log.error("URL 格式錯誤: {}", url, e);
} catch (IOException e) {
log.error("抓取失敗: {}", url, e);
}
return sb.length() > 0 ? sb.toString() : null;
}
/**
* 依請求動態生成靜態檔案
* @param request 當前請求 (用於取得 contextPath、realPath)
* @param url 目標 URL,null 表示當前請求 URL
* @param toWebRoot true=存至 WebRoot 同層級,false=存至 /files/history/日期/
* @param encoding 輸出編碼
* @throws IOException
*/
public void genHtml(HttpServletRequest request, String url, boolean toWebRoot, String encoding) throws IOException {
if (url == null) url = request.getRequestURL().toString();
String contextPath = request.getContextPath();
String realRoot = request.getServletContext().getRealPath("/");
if (!realRoot.endsWith(File.separator)) realRoot += File.separator;
// 目標相對路徑:去除 contextPath,副檔名統一 .html
String relPath = StringUtils.substringAfter(url, contextPath);
relPath = relPath.replaceAll("\\.(do|jsp|action|html?)$", ".html");
String saveDir, fileName;
if (toWebRoot) {
saveDir = "";
fileName = relPath;
} else {
// 歷史版本目錄:files/history/yyyy/MM/dd/
saveDir = "files" + File.separator + "history" + File.separator
+ new SimpleDateFormat("yyyy/MM/dd/").format(new Date());
// 檔名加入時間戳後 6 碼避免衝突
String seq = String.valueOf(System.currentTimeMillis()).substring(6);
fileName = StringUtils.substringBeforeLast(relPath, ".html") + "-" + seq + ".html";
}
File outFile = new File(realRoot + saveDir + fileName);
FileUtils.writeStringToFile(outFile, fetch(url), encoding, false);
}
}
Controller 整合範例
@Controller
public class CmsController {
@Autowired HTMLGenerator htmlGenerator;
@GetMapping("/article/{id}")
public String view(@PathVariable Long id, HttpServletRequest request, Model model) {
Article article = articleService.get(id);
model.addAttribute("article", article);
// 異步生成靜態頁,不阻塞回應
CompletableFuture.runAsync(() -> {
try {
String url = request.getScheme() + "://" + request.getServerName()
+ ":" + request.getServerPort() + request.getRequestURI();
htmlGenerator.genHtml(request, url, true, "UTF-8");
} catch (Exception e) {
log.error("靜態頁生成失敗", e);
}
});
return "article/view"; // 正常渲染動態頁
}
}
進階議題與解決方案
1. 權限頁面/個人化內容
- 靜態頁不適用於「每人不同」的頁面 (購物車、用戶中心)。
- 方案:僅對公開、緩存友善頁面 (文章、列表、商品詳情) 生成靜態;其餘走動態。
2. 並發生成衝突
// 使用分散式鎖 (Redis SETNX) 或本地鎖
String lockKey = "htmlgen:lock:" + url.hashCode();
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (locked) {
try { htmlGenerator.genHtml(...); }
finally { redisTemplate.delete(lockKey); }
}
3. 靜態資源 (CSS/JS/圖片) 路徑失效
- 生成頁面中的相對路徑在深層目錄下會斷裂。
- 方案:頁面統一使用 絕對路徑 (
/static/...) 或 <base href="...">。
4. 更新/失效機制
5. 大規模站點架構建議
- 生成服務獨立部署,透過消息佇列 (Kafka/RocketMQ) 接收「頁面失效」事件。
- 靜態檔案存儲於對象存儲 (S3/OSS/MinIO) + CDN 分發,Web Server 僅做回源。
- 引入 增量生成:僅重新生成受影響頁面,配合依賴圖 (文章→分類列表→首頁) 自動級聯。
總結
URLConnection 抓取法以「零侵入、復用現有邏輯」見長,適合存量系統快速靜態化。配合異步生成、分散式鎖、CDN 回源,可支撐百萬級靜態頁管理。核心原則:公開頁靜態化、私有頁動態化、路徑絕對化、更新事件驅動。