將動態 JSP 頁面渲染結果捕獲並寫入靜態 HTML 文件,後續請求直接由 Web 服務器 (Nginx/Apache) 靜態服務,消除 JSP 編譯執行開銷、數據庫查詢壓力,極大提升高並發讀場景性能。
[用戶請求] → [Controller/Servlet] → [數據準備] → [模板引擎渲染] → [寫入靜態文件] → [返回/重定向靜態頁]
↑
[定時任務/事件觸發重新生成]
不推薦生產使用,僅作理解原理。
使用成熟模板引擎 替代 JSP,支持獨立運行、語法豐富、緩存編譯。
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.32</version><!-- 或 2.3.33 -->
</dependency>
<!-- 或 Thymeleaf -->
<dependency>
<groupId>org.thymeleaf</groupId>
<artifactId>thymeleaf</artifactId>
<version>3.1.2.RELEASE</version>
</dependency>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>${article.title?html} - ${siteName}</title>
<meta name="description" content="${article.summary?html}">
<link rel="stylesheet" href="/static/css/article.css">
</head>
<body>
<header><h1>${siteName}</h1></header>
<article>
<h2>${article.title?html}</h2>
<div class="meta">
作者: ${article.author?html} |
發布: ${article.publishTime?string("yyyy-MM-dd HH:mm")} |
分類: ${article.category?html}
</div>
<div class="content">
${article.content} <#-- 富文本內容已含 HTML,信任來源時不轉義 -->
</div>
</article>
<footer>© ${.now?string("yyyy")} ${siteName}</footer>
</body>
</html>
package com.example.model;
import java.time.LocalDateTime;
public class Article {
private Long id;
private String title;
private String summary;
private String content; // HTML 富文本
private String author;
private String category;
private LocalDateTime publishTime;
// Getter/Setter/Builder 省略
}
package com.example.service;
import com.example.model.Article;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateExceptionHandler;
import org.springframework.stereotype.Service;
import java.io.File;
import java.io.FileWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
@Service
public class StaticPageGenerator {
private final Configuration cfg;
private final String outputBaseDir;
public StaticPageGenerator(Configuration cfg, @Value("${static.output.dir:/var/www/html}") String outputBaseDir) {
this.cfg = cfg;
this.outputBaseDir = outputBaseDir;
initConfig();
}
private void initConfig() {
cfg.setClassForTemplateLoading(this.getClass(), "/templates");
cfg.setDefaultEncoding("UTF-8");
cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
cfg.setLogTemplateExceptions(false);
cfg.setWrapUncheckedExceptions(true);
cfg.setApiBuiltinEnabled(true); // 允許 ?api 內置
}
/**
* 生成文章靜態頁
* @param article 文章數據
* @return 生成的文件相對路徑 (如 /article/123.html)
*/
public String generateArticlePage(Article article) {
try {
Template template = cfg.getTemplate("article.ftl");
// 數據模型
Map model = new HashMap<>();
model.put("article", article);
model.put("siteName", "技術博客");
// 輸出文件路徑: /article/{id}.html
String relPath = "/article/" + article.getId() + ".html";
File outFile = new File(outputBaseDir, relPath);
outFile.getParentFile().mkdirs(); // 確保目錄存在
// 渲染寫入
try (Writer writer = new FileWriter(outFile, StandardCharsets.UTF_8)) {
template.process(model, writer);
}
return relPath;
} catch (Exception e) {
throw new RuntimeException("生成靜態頁失敗: " + article.getId(), e);
}
}
/**
* 批量生成 (如全站靜態化)
*/
public void generateAllArticles(Iterable articles) {
for (Article article : articles) {
try {
generateArticlePage(article);
} catch (Exception e) {
log.error("生成失敗: {}", article.getId(), e);
}
}
}
}
@RestController
@RequestMapping("/admin/article")
public class ArticleAdminController {
@Autowired private ArticleService articleService;
@Autowired private StaticPageGenerator staticGenerator;
@PostMapping("/{id}/publish")
public Result publish(@PathVariable Long id) {
Article article = articleService.getById(id);
article.setStatus(ArticleStatus.PUBLISHED);
articleService.update(article);
// 異步生成靜態頁 (不阻塞響應)
CompletableFuture.runAsync(() -> staticGenerator.generateArticlePage(article));
return Result.success();
}
@PostMapping("/{id}/unpublish")
public Result unpublish(@PathVariable Long id) {
Article article = articleService.getById(id);
article.setStatus(ArticleStatus.DRAFT);
articleService.update(article);
// 刪除靜態文件
CompletableFuture.runAsync(() -> deleteStaticFile(article.getId()));
return Result.success();
}
}
若現有項目全是 JSP,短期無法引入模板引擎,可用此法過渡:
public String generateFromJsp(HttpServletRequest req, HttpServletResponse resp, String jspPath, Map model)
throws ServletException, IOException {
// 1. 將模型數據放入 request
model.forEach(req::setAttribute);
// 2. 捕獲 JSP 輸出
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ServletOutputStream servletOut = new ServletOutputStream() {
@Override public void write(int b) { baos.write(b); }
@Override public void write(byte[] b, int off, int len) { baos.write(b, off, len); }
@Override public boolean isReady() { return true; }
@Override public void setWriteListener(WriteListener l) {}
};
// 3. 包裝 response
HttpServletResponse wrappedResp = new HttpServletResponseWrapper(resp) {
@Override public ServletOutputStream getOutputStream() { return servletOut; }
@Override public PrintWriter getWriter() { return new PrintWriter(servletOut, true); }
};
// 4. 請求分派器包含 JSP (執行 JSP 並寫入 baos)
req.getRequestDispatcher(jspPath).include(req, wrappedResp);
// 5. 獲取生成的 HTML
String html = baos.toString(StandardCharsets.UTF_8);
// 6. 寫入文件...
return html;
}
# nginx.conf
server {
listen 80;
server_name blog.example.com;
root /var/www/html; # 靜態文件輸出目錄
# 文章靜態頁直接服務
location /article/ {
try_files $uri $uri/ =404;
expires 1h;
add_header Cache-Control "public";
}
# 動態接口轉發到後端
location /api/ {
proxy_pass https://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 管理後台等需認證頁面
location /admin/ {
proxy_pass https://backend:8080;
# auth_basic ...
}
}
| 策略 | 觸發時機 | 實現方式 | 優/缺點 |
|---|---|---|---|
| 發布/更新時重新生成 | 文章發布、編輯保存 | Controller 調用生成服務 | 強一致、實時 |
| 定時全量重新生成 | 每日凌晨/每小時 | Quartz/XXL-Job 定時任務遍歷數據庫 | 兜底、修復異常文件 |
| 增量生成 (消息驅動) | 數據變更事件 | RabbitMQ/Kafka 發送 ArticleChangedEvent,消費者生成 | 高性能、解耦 |
| CDN 緩存刷新 | 靜態文件更新後 | 調用 CDN API (阿里云/騰訊云/Cloudflare) 刷新 URL | 邊緣節點同步 |
/var/www/html/
├── article/
│ ├── 1001.html
│ ├── 1002.html
│ └── index.html # 列表頁靜態化
├── category/
│ ├── java.html
│ └── python.html
├── tag/
│ ├── spring.html
│ └── jvm.html
├── sitemap.xml
├── robots.txt
└── static/
├── css/
├── js/
└── images/
/article/{id}.html 簡單、唯一、利於 SEOapp.v20240115.css 長期緩存| 方案 | 適用場景 | 性能 | 維護成本 | 推薦度 |
|---|---|---|---|---|
| JSP include 靜態化 | 極簡頁面、無模板引擎經驗 | 低 (仍走 Servlet 容器) | 高 (邏輯耦合在 JSP) | ⭐ |
| FreeMarker/Thymeleaf + JavaBean | 內容發布系統、電商商品頁、部落格、文檔站 | 極高 (純靜態文件 + Nginx) | 低 (模板分離、語法強大) | ⭐⭐⭐⭐⭐ |
| JSP 輸出捕獲 | 存量 JSP 專案過渡期 | 中 (仍需容器渲染) | 中 (復用現有 JSP) | ⭐⭐ (過渡用) |
| 現代 SSG (Next.js/Astro/VitePress) | 新項目、前端分離、組件化 | 極高 (構建時生成、邊緣部署) | 低 (前端生態) | ⭐⭐⭐⭐ (新架構) |