iT邦幫忙

2023 iThome 鐵人賽

DAY 14
0
Software Development

救救我啊我救我!CRUD 工程師的惡補日記系列 第 14

【Spring Boot】整合 FreeMarker 產生 HTML 內容

  • 分享至 

  • xImage
  •  

昨天示範了如何發送 HTML 郵件,並嵌入圖片。然而昨天的郵件內容可說是 hard code,在面對開發需求,如何根據不同情境,產生對應的 HTML 內容並發送,就是一門學問了。

本文將介紹 FreeMarker 模板引擎。透過它,我們能將自定義的 POJO 物件與挖好空格的「模板」組合在一起,來動態產生 HTML 內容。


一、FreeMarker 簡介

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 專案

看完前面的例子後,接下來就將它引進到 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。情境是產生學生選課的清單,以及上課用書的資料。

(一)預期結果

在進入實作過程前,先讓讀者看看完成後的效果如何。
https://ithelp.ithome.com.tw/upload/images/20230915/20131107QnKRglb3Qg.jpg

有 Logo 小圖片、課程表格,以及用書的封面圖片。且其中一張封面圖從缺。

下面就正式進入程式實作。

(二)Model 介紹

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。接著呼叫 ConfigurationgetTemplate 方法,指定模板檔案。

最後呼叫 Templateprocess 方法,傳入 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)


今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教/images/emoticon/emoticon41.gif


上一篇
【Spring Boot】使用 Java Mail 發送 HTML 郵件
下一篇
【Spring Boot】使用 RestTemplate 存取外部 API
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言