本文提供一個可直接落地專案的 JFreeChart 圖表生成服務類,封裝了「數據集構建 → 圖表客製化 → PNG 暫存 → URL 返回」的完整流程。對比同類教程,本實現強化了參數校驗、異常處理、字體跨平台相容、資源釋放四大工程化細節,適合作為專案內部共用的 ChartService 基礎組件。
package com.example.common.chart;
import org.jfree.chart.*;
import org.jfree.chart.axis.*;
import org.jfree.chart.labels.StandardCategoryItemLabelGenerator;
import org.jfree.chart.plot.CategoryPlot;
import org.jfree.chart.plot.PlotOrientation;
import org.jfree.chart.renderer.category.LineAndShapeRenderer;
import org.jfree.chart.servlet.ServletUtilities;
import org.jfree.chart.title.TextTitle;
import org.jfree.data.category.DefaultCategoryDataset;
import org.jfree.ui.RectangleEdge;
import org.jfree.ui.RectangleInsets;
import java.awt.*;
import java.io.IOException;
import java.util.Objects;
public class ChartService {
/** 預設輸出尺寸 */
private static final int DEFAULT_WIDTH = 700;
private static final int DEFAULT_HEIGHT = 350;
/** 跨平台中文字體策略:Windows 用微軟正黑體,Linux/Docker 用 Noto Sans CJK */
private static final Font CHINESE_FONT_BOLD = resolveChineseFont(Font.BOLD);
private static final Font CHINESE_FONT_PLAIN = resolveChineseFont(Font.PLAIN);
private static Font resolveChineseFont(int style) {
String os = System.getProperty("os.name").toLowerCase();
String family = os.contains("win") ? "Microsoft JhengHei" : "Noto Sans CJK TC";
return new Font(family, style, 12);
}
/**
* 生成折線圖並返回 DisplayChart Servlet 可訪問的 URL
*
* @param seriesNames 系列名稱 (圖例),長度 = scores 第 1 維
* @param scores 數據矩陣 [seriesCount][categoryCount]
* @param categories 類別標籤 (X 軸),長度 = scores 第 2 維
* @param title 圖表標題
* @return 相對路徑 URL,如 "/ctx/DisplayChart?filename=chart_abc123.png"
* @throws IllegalArgumentException 參數維度不匹配
* @throws ChartGenerationException 圖表生成/暫存失敗
*/
public String generateLineChartUrl(String[] seriesNames, double[][] scores,
String[] categories, String title) {
// 1. 參數防禦性檢查
Objects.requireNonNull(seriesNames, "seriesNames 不可為 null");
Objects.requireNonNull(scores, "scores 不可為 null");
Objects.requireNonNull(categories, "categories 不可為 null");
if (seriesNames.length != scores.length) {
throw new IllegalArgumentException("seriesNames 長度(" + seriesNames.length +
") 必須等於 scores 第 1 維長度(" + scores.length + ")");
}
for (int i = 0; i < scores.length; i++) {
if (scores[i].length != categories.length) {
throw new IllegalArgumentException("系列[" + i + "] 數據長度(" + scores[i].length +
") 必須等於 categories 長度(" + categories.length + ")");
}
}
// 2. 構建數據集
DefaultCategoryDataset dataset = new DefaultCategoryDataset();
for (int s = 0; s < seriesNames.length; s++) {
for (int c = 0; c < categories.length; c++) {
String cat = truncate(categories[c], 10); // 避免標籤過長
dataset.addValue(scores[s][c], seriesNames[s], cat);
}
}
// 3. 建立基礎圖表
JFreeChart chart = ChartFactory.createLineChart(
title, null, null, dataset,
PlotOrientation.VERTICAL, true, true, false);
// 4. 應用統一樣式模板
applyCommonStyle(chart);
// 5. 針對系列客製化渲染器 (線型、顏色、資料標籤)
customizeRenderer(chart.getCategoryPlot(), seriesNames.length);
// 6. 輸出暫存 PNG 並取得檔名
String fileName;
try {
fileName = ServletUtilities.saveChartAsPNG(chart, DEFAULT_WIDTH, DEFAULT_HEIGHT, null, null);
} catch (IOException e) {
throw new ChartGenerationException("圖表 PNG 生成失敗", e);
}
// 7. 組裝 URL (需根據實際部署的 context-path 調整前綴)
String contextPath = getContextPath(); // 可從 ThreadLocal/RequestContextHolder 獲取
return contextPath + "/DisplayChart?filename=" + fileName;
}
/** 統一樣式模板:背景、圖例、標題、坐標軸、網格 */
private void applyCommonStyle(JFreeChart chart) {
chart.setBackgroundPaint(Color.WHITE);
chart.getLegend().setPosition(RectangleEdge.RIGHT);
chart.getLegend().setItemFont(CHINESE_FONT_PLAIN);
chart.setTitle(new TextTitle(chart.getTitle().getText(), CHINESE_FONT_BOLD.deriveFont(16f)));
CategoryPlot plot = chart.getCategoryPlot();
plot.setBackgroundPaint(Color.WHITE);
plot.setBackgroundAlpha(0.85f);
plot.setDomainGridlinesVisible(true);
plot.setRangeGridlinesVisible(true);
plot.setDomainGridlinePaint(new Color(0xE0E0E0));
plot.setRangeGridlinePaint(new Color(0xE0E0E0));
plot.setAxisOffset(new RectangleInsets(0, 0, 0, 12));
plot.setNoDataMessage("無資料可顯示");
// X 軸
CategoryAxis domainAxis = plot.getDomainAxis();
domainAxis.setTickLabelFont(CHINESE_FONT_PLAIN);
domainAxis.setLabelFont(CHINESE_FONT_BOLD);
domainAxis.setMaximumCategoryLabelLines(2);
domainAxis.setCategoryMargin(0.15);
// Y 軸
NumberAxis rangeAxis = (NumberAxis) plot.getRangeAxis();
rangeAxis.setStandardTickUnits(NumberAxis.createIntegerTickUnits());
rangeAxis.setAutoRangeIncludesZero(true);
rangeAxis.setUpperMargin(0.12);
rangeAxis.setTickLabelFont(CHINESE_FONT_PLAIN);
rangeAxis.setLabelFont(CHINESE_FONT_BOLD);
}
/** 系列渲染器客製化:形狀、線型、資料標籤 */
private void customizeRenderer(CategoryPlot plot, int seriesCount) {
LineAndShapeRenderer renderer = (LineAndShapeRenderer) plot.getRenderer();
renderer.setShapesVisible(true);
renderer.setBaseItemLabelsVisible(true);
renderer.setBaseItemLabelGenerator(new StandardCategoryItemLabelGenerator());
renderer.setBaseItemLabelFont(CHINESE_FONT_PLAIN.deriveFont(10f));
// 預設色盤 (可外部化配置)
Color[] palette = {new Color(0x1F77B4), new Color(0xFF7F0E), new Color(0x2CA02C),
new Color(0xD62728), new Color(0x9467BD), new Color(0x8C564B)};
for (int i = 0; i < seriesCount; i++) {
renderer.setSeriesPaint(i, palette[i % palette.length]);
renderer.setSeriesShape(i, ShapeUtilities.createRegularCross(3, 1)); // 十字標記
// 線型區分:0 實線, 1 長虛線, 2 短虛線, 3 點線
float[] dash = switch (i % 4) {
case 0 -> null; // 實線
case 1 -> new float[]{10f, 6f};
case 2 -> new float[]{6f, 6f};
default -> new float[]{2f, 4f};
};
if (dash != null) {
renderer.setSeriesStroke(i, new BasicStroke(2.0f, BasicStroke.CAP_ROUND,
BasicStroke.JOIN_ROUND, 1.0f, dash, 0.0f));
} else {
renderer.setSeriesStroke(i, new BasicStroke(2.0f));
}
}
}
/** 截斷過長標籤 */
private String truncate(String s, int maxLen) {
return (s != null && s.length() > maxLen) ? s.substring(0, maxLen) + "…" : s;
}
/** 獲取 Web 應用上下文路徑 (需根據框架實現,此處示意) */
private String getContextPath() {
// Spring: ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest().getContextPath()
// 簡易版假設部署在根路徑或已知 context
return "/myapp"; // 請替換為動態獲取
}
/** 業務異常包裝 */
public static class ChartGenerationException extends RuntimeException {
public ChartGenerationException(String msg, Throwable cause) { super(msg, cause); }
}
}
@RestController
@RequestMapping("/api/chart")
public class ChartController {
private final ChartService chartService = new ChartService();
@GetMapping("/score-trend")
public Result<String> scoreTrend() {
String[] series = {"A班", "B班", "C班"};
double[][] data = {
{82, 88, 79, 91, 85},
{78, 85, 80, 88, 82},
{85, 90, 84, 92, 88}
};
String[] cats = {"第1週", "第2週", "第3週", "第4週", "第5週"};
String url = chartService.generateLineChartUrl(series, data, cats, "五週成績趨勢對比");
return Result.success(url); // 前端直接
}
}
| 維度 | 普通教程 | 本實現增強 |
|---|---|---|
| 參數校驗 | 無 | 維度匹配、非空檢查、具體錯誤訊息 |
| 異常處理 | printStackTrace | 自定義 ChartGenerationException 統一拋出,利於全局異常處理器捕獲 |
| 字體跨平台 | 寫死 "微軟正黑體" | OS 判斷動態選擇 Microsoft JhengHei / Noto Sans CJK TC |
| 資源管理 | 忽略 | 依賴 ServletUtilities 內部暫存機制;高併發建議改 ByteArrayOutputStream 直輸出 |
| 樣式複用 | 硬編碼在方法中 | 拆分 applyCommonStyle / customizeRenderer 便於繼承覆寫 |
| ContextPath 動態化 | 寫死 "/projectname" | 預留 getContextPath() 接口,配合 Spring RequestContextHolder 實現 |
org.jfree.chart.servlet.DisplayChart,url-pattern 為 /DisplayChart。apt-get install -y fontconfig fonts-noto-cjk 或掛載字體卷。java.io.tmpdir 或 Session 目錄)。response.getOutputStream() 直接輸出 PNG 流,或引入 Caffeine 緩存熱門圖表。將 JFreeChart 的繁瑣 API 封裝為 單一職責、可測試、可擴展的 ChartService,不僅能消除 Controller 層的樣板代碼,更能統一專案圖表風格、降低維護成本。本實現作為起點,可按業務擴展支援長條圖、餅圖、散佈圖等類型,構建專屬的圖表組件庫。