昨天示範了如何發送 HTML 郵件,並嵌入圖片。然而昨天的郵件內容可說是 hard code,在面對開發需求,如何根據不同情境,產生對應的 HTML 內容並發送,就是一門學問了。
本文將介紹 FreeMarker 模板引擎。透過它,我們能將自定義的 POJO 物件與挖好空格的「模板」組合在一起,來動態產生 HTML 內容。
FreeMarker 是一種模板引擎(template engine)。概念上就像國小國語課所寫的「照樣造句」,一段文字會有幾個地方被挖空,而我們要自己把詞語給填進去。
模板引擎的用途,是幫助我們將資料填入被挖空的模板中,產生完整的內容。以本文的範例來說,目標是透過它來產生 HTML 內容,進而發送 email。
讓我們直接看例子吧!假設在 Java 程式碼中有如下的物件(此處以 JSON 來表示)。
{
"name": "Vincent",
"products": [
{ "name": "耳機", "price": 200, "amount": 2 },
{ "name": "充電器", "price": 150, "amount": 1 }
],
"total": 550,
"paymentMethod": "CASH_ON_DELIVERY"
}
並且已經事先撰寫好如下的「模板」。
<!-- 填空 -->
<p>您好,${name}!</p>
<p>這次的購物清單如下:</p>
<table>
<!-- 迴圈 -->
<#list products as p>
<tr>
<td>${p.name}</td><td>${p.price}</td><td>${p.amount}</td>
<tr>
</#list>
</table>
<p>總金額:${total}</p>
<!-- 條件判斷 -->
<#if paymentMethod == "CASH_ON_DELIVERY">
<p>取貨時請攜帶現金付款。</p>
<#else>
<p>請匯款到指定帳戶,確認付款後才會出貨。</p>
</#if>
透過 FreeMarker,可以產生出完整的內容。
<p>您好,Vincent!</p>
<p>這次的購物清單如下:</p>
<table>
<tr>
<td>耳機</td><td>200</td><td>2</td>
<tr>
<tr>
<td>充電器</td><td>150</td><td>1</td>
<tr>
<table>
<p>總金額:550</p>
<p>取貨時請攜帶現金付款。</p>
除了在模板中安置「空格」,FreeMarker 也提供了如判斷、迴圈等語法,讓動態產生的效果更強大。
看完前面的例子後,接下來就將它引進到 Spring Boot 專案中。筆者使用的 Java 版本為 17(zulu-17);Spring Boot 版本為 3.1.3;建置工具使用 Maven。
請在 pom.xml 檔案添加 FreeMarker 的依賴。Spring 本身剛好有提供一個封裝過後的依賴,本文就使用它。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
接著在 application.properties 檔案配置一些參數。
# 模板檔案的路徑,此例為 resources 下的 templates 資料夾
spring.freemarker.template-loader-path=classpath:/templates/
# 模板檔案的副檔名
spring.freemarker.suffix=.ftl
# 模板的內容種類
spring.freemarker.content-type=text/html
# 編碼
spring.freemarker.charset=UTF-8
# 是否將模板內容快取起來,是的話則無法 hot reload
spring.freemarker.cache=false
使用這款封裝好的依賴,特色是可以用如上的方式來配置參數,Spring 會自己讀取,讓整個專案都共用。若是依照文末參考資料的方式,就得在程式碼中自行配置。
本節讓我們在程式碼中實際操作 FreeMarker 的 API。情境是產生學生選課的清單,以及上課用書的資料。
在進入實作過程前,先讓讀者看看完成後的效果如何。
有 Logo 小圖片、課程表格,以及用書的封面圖片。且其中一張封面圖從缺。
下面就正式進入程式實作。
Book
代表上課用書。
書的封面圖片檔案理應會存在某個地方(本地或雲端),我們假想有個唯一的 key 值可以用來指向它。此為 coverImageKey
欄位的值。
public class Book {
private String name; // 書名
private String publisher; // 出版社
private int price; // 價格
private String coverImageKey;
// getter, setter ...
}
Course
代表課程。
public class Course {
private String name; // 課名
private int point; // 學分
private Book textBook; // 用書
// getter, setter ...
}
CourseSelectionEmailInfo
代表選課資料。所有名稱為「get」開頭的 getter 方法,其回傳值都能夠被用來填入模板。
public class CourseSelectionEmailInfo {
private String studentName; // 學生名字
private String period; // 上課期數
private List<Course> courses = new ArrayList<>(); // 已選課程
public void addCourse(Course course) {
this.courses.add(course);
}
public int getTotalPoint() {
return this.courses.stream()
.mapToInt(Course::getPoint)
.sum();
}
public int getTotalAmount() {
return this.courses.stream()
.map(Course::getTextBook)
.mapToInt(Book::getPrice)
.sum();
}
// getter, setter ...
}
請在專案的 resources/templates
路徑下,建立名為 course-selection-confirm-email.ftl
的模板檔案。
撰寫模板需要具備基本的 HTML 知識,筆者在此不著墨太多。主要是簡單介紹 FreeMarker 的語法。
${...}
:填入值。前面有提過,POJO 物件中「get」開頭的方法,其回傳值可填入模板。<#list ...></#list>
:對集合欄位做 for each。寫法為 集合名稱 as 元素區域變數名稱
。?counter
:集合元素的序數,會從 1 開始。<#if ...><#else></#if>
:條件判斷,視條件選擇要使用的 tag。<#include "...">
:引入其他的模板,例如頁首頁尾的 Logo 圖片、公司名稱或聯絡方式等,達到重複利用。?has_content
:字串或集合是否為 null 或空值。若這些語法還不能滿足讀者的需求,可以另外去 google。
以下是完成後的模板。
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<header>
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
</header>
<body>
<#include "header.ftl">
<p>親愛的${studentName},你在${period}期的修課清單及用書如下。</p>
<table cellpadding='0', cellspacing='0' border='1'>
<tr>
<th style='padding: 10px;'>No.</th>
<th style='padding: 10px;'>課程名稱</th>
<th style='padding: 10px;'>學分</th>
<th style='padding: 10px;'>上課用書</th>
<th style='padding: 10px;'>價格</th>
<th style='padding: 10px;'>用書封面</th>
</tr>
<#list courses as course>
<tr>
<td align="center" style='padding: 10px;'>
${course?counter}
</td>
<td style='padding: 10px;'>
${course.name}
</td>
<td align="center" style='padding: 10px;'>
${course.point}
</td>
<td style='padding: 10px;'>
${course.textBook.name}
<br />
${course.textBook.publisher}
</td>
<td align="center" style='padding: 10px;'>
${course.textBook.price}
</td>
<td style='padding: 10px;'>
<#if course.textBook.coverImageKey?has_content>
<img src='cid:${course.textBook.coverImageKey}' style='width: 180px; height: auto;' />
<#else>
<p>N/A</p>
</#if>
</td>
</tr>
</#list>
</table>
<p>總學分:${totalPoint}</p>
<p>用書總金額:${totalAmount}</p>
</body>
</html>
而以下是「header.ftl」的內容。
<table cellpadding='0', cellspacing='0' border='0'>
<tr>
<td>
<img src='cid:logo.png' height='40px' width='40px'/>
</td>
<td style='width: 20px;'>
</td>
<td>
<font style='font-weight: 700; font-size: 20px; color: #576275;'>
文森程式教室
</font>
</td>
</tr>
</table>
準備好資料 model 和模板後,就能寫程式來產生 HTLM 內容了。筆者直接透過測試程式,展示操作方式(省略 controller 與 service)。
import freemarker.template.Configuration;
import java.io.StringWriter;
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ApplicationTests {
@Autowired
private Configuration freeMarkerConfig;
@Test
public void testHtmlTemplate() throws IOException, TemplateException {
// 準備資料
var book1 = Book.of("計算機概論 第三版", "歐歐書局", 350, "img1");
var book2 = Book.of("SQL Server 設計實務", "旗旗出版社", 500, "img2");
var book3 = Book.of("資料結構(使用 Java)", "基基資訊", 280, null);
var course1 = Course.of("計算機概論", 4, book1);
var course2 = Course.of("資料庫管理系統", 3, book2);
var course3 = Course.of("資料結構", 3, book3);
var info = CourseSelectionEmailInfo.of("Vincent", "001");
info.addCourse(course1);
info.addCourse(course2);
info.addCourse(course3);
// 將資料填入模板,產生 HTML
var writer = new StringWriter();
var template = freeMarkerConfig.getTemplate("course-selection-confirm-email.ftl");
template.process(info, writer);
// 輸出 HTML 結果
var htmlStr = writer.toString();
System.out.println(htmlStr);
}
}
首先會把要用來寫入模板的 POJO 物件準備好,此處為 CourseSelectionEmailInfo
。接著呼叫 Configuration
的 getTemplate
方法,指定模板檔案。
最後呼叫 Template
的 process
方法,傳入 POJO 物件與 StringWriter
。此時這個 writer 物件裡面就會包含產生出來的 HTML 字串,將其取出使用即可。
使用 FreeMarker 產生 HTML 的流程就到上一節為止。本節主要是想示範將 HTML 當作 email 內容發送出去,算是結合昨天的文章。
為了發送具有內嵌圖片的 HTML 郵件,我們需要準備附件。除了固定要用的 Logo 圖,還會從 Book
物件中收集 coverImageKey
,然後取得 DataSource
。
public class ApplicationTests {
// ...
@Test
public void testHtmlTemplate() throws IOException, TemplateException {
// ...
var htmlStr = writer.toString();
List<DataSource> attachments = info.getCourses()
.stream()
.map(x -> {
var coverImgId = x.getTextBook().getCoverImageKey();
return getImageDataSourceById(coverImgId);
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
attachments.add(new FileDataSource("logo.png"));
// send email...
}
}
至於這個名為 getImageDataSourceById
的方法,封裝了用 coverImageKey
取得 BufferedImage
,再轉換為 ByteArrayDataSource
的過程。
而作為範例,此處 BufferedImage
的來源僅用專案中的圖檔來代替。讀者在開發工作中,應該撰寫自己的邏輯,方法的輸入是圖片資源的 key,輸出是 DataSource
。
public class ApplicationTests {
// ...
private final Map<String, BufferedImage> imageMap = new HashMap<>();
@Before
public void setup() throws IOException {
imageMap.put("img1", ImageIO.read(new File("book1-cover.png")));
imageMap.put("img2", ImageIO.read(new File("book2-cover.png")));
}
@Test
public void testHtmlTemplate() throws IOException, TemplateException {
// ...
}
private ByteArrayDataSource getImageDataSourceById(String imageId) {
if (!StringUtils.hasText(imageId)) {
return null;
}
var bufferedImage = this.imageMap.get(imageId);
var os = new ByteArrayOutputStream();
try {
ImageIO.write(bufferedImage, "png", os);
var source = new ByteArrayDataSource(os.toByteArray(), "image/png");
source.setName(imageId);
return source;
} catch (IOException e) {
return null;
}
}
}
最後搭配昨天實作的 MailService.sendMixedHtml
方法,並執行整個程式,便能在郵件中看到結果了。
Ref:FreeMarker — Java 模版引擎 (2)
今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教