FreeMarker介绍及基本数据类型和用法
一、FreeMarker介绍
FreeMarker 是一款 模板引擎: 即一种基于模板和要改变的数据, 并用来生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。 它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件。
模板编写为FreeMarker Template Language (FTL)。它是简单的,专用的语言, 不是 像PHP那样成熟的编程语言。 那就意味着要准备数据在真实编程语言中来显示,比如数据库查询和业务运算, 之后模板显示已经准备好的数据。在模板中,你可以专注于如何展现数据, 而在模板之外可以专注于要展示什么数据。
入门
模板 + 数据模型 = 输出
假设在一个在线商店的应用系统中需要一个HTML页面,和下面这个页面类似:
<html>
<head>
<title>Welcome!</title>
</head>
<body>
<h1>Welcome John Doe!</h1>
<p>Our latest product:
<a href="products/greenmouse.html">green mouse</a>!
</body>
</html>
这里的用户名(上面的"Big Joe"),应该是登录这个网页的访问者的名字, 并且最新产品的数据应该来自于数据库,这样它才能随时更新。那么不能直接在HTML页面中输入它们, 不能使用静态的HTML代码。此时,可以使用要求输出的 模板。 模板和静态HTML是相同的,只是它会包含一些 FreeMarker 将它们变成动态内容的指令:
<html>
<head>
<title>Welcome!</title>
</head>
<body>
<h1>Welcome ${user}!</h1>
<p>Our latest product:
<a href="${latestProduct.url}">${latestProduct.name}</a>!
</body>
</html>
模板文件存放在Web服务器上,就像通常存放静态HTML页面那样。当有人来访问这个页面, FreeMarker将会介入执行,然后动态转换模板,用最新的数据内容替换模板中 ${*...*}
的部分, 之后将结果发送到访问者的Web浏览器中。访问者的Web浏览器就会接收到例如第一个HTML示例那样的内容 (也就是没有FreeMarker指令的HTML代码),访问者也不会察觉到服务器端使用的FreeMarker。 (当然,存储在Web服务器端的模板文件是不会被修改的;替换也仅仅出现在Web服务器的响应中。)
请注意,模板并没有包含程序逻辑来查找当前的访问者是谁,或者去查询数据库获取最新的产品。 显示的数据是在 FreeMarker 之外准备的,通常是一些 "真正的" 编程语言(比如Java) 所编写的代码。模板作者无需知道这些值是如何计算出的。事实上,这些值的计算方式可以完全被修改, 而模板可以保持不变,而且页面的样式也可以完全被修改而无需改动模板。 当模板作者(设计师)和程序员不是同一人时,显示逻辑和业务逻辑相分离的做法是非常有用的, 即便模板作者和程序员是一个人,这么来做也会帮助管理应用程序的复杂性。 保证模板专注于显示问题(视觉设计,布局和格式化)是高效使用模板引擎的关键。
为模板准备的数据整体被称作为 数据模型。 模板作者要关心的是,数据模型是树形结构(就像硬盘上的文件夹和文件),在视觉效果上, 数据模型可以是:
(root)
|
+- user = "Big Joe"
|
+- latestProduct
|
+- url = "products/greenmouse.html"
|
+- name = "green mouse"
Note:
上面只是一个形象化显示;数据模型不是文本格式,它来自于Java对象。 对于Java程序员来说,root就像一个有 getUser()
和 getLatestProduct()
方法的Java对象, 也可以有 "user"
和 "latestProducts"
键值的Java Map
对象。相似地,latestProduct
就像是有 getUrl()
和 getName()
方法的Java对象。
早期版本中,可以从数据模型中选取这些值,使用 user
和 latestProduct.name
表达式即可。如果我们继续类推, 数据模型就像一个文件系统,那么 "(root)" 和 latestProduct
就对应着目录(文件夹),而 user
, url
和 name
就是这些目录中的文件。
总的来说,模板和数据模型是FreeMarker来生成输出(比如第一个展示的HTML)所必须的:
模板 + 数据模型 = 输出
模板一览
最简单的模板通常是普通的HTML文件(或者是其他任何文本文件; FreeMarker本身不属于HTML)。当客户端访问某个页面时, FreeMarker要发送HTML代码至客户端浏览器中去显示。如果想要页面动起来 (这里指动态网页技术,译者注),那么就要在HTML中放置能被FreeMarker所解析的特殊代码片段:
${*...*}
: FreeMarker将会输出真实的值来替换大括号内的表达式,这样的表达式被称为 interpolation(插值,译者注)。- FTL 标签 (FreeMarker模板的语言标签): FTL标签和HTML标签有一些相似之处,但是它们是FreeMarker的指令,是不会在输出中打印的。 这些标签的名字以
#
开头。(用户自定义的FTL标签则需要使用@
来代替#
,但这属于更高级的话题了。) - 注释: 注释和HTML的注释也很相似, 但是它们使用
<#--
and-->
来标识。 不像HTML注释那样,FTL注释不会出现在输出中(不出现在访问者的页面中), 因为 FreeMarker会跳过它们。
其他任何不是FTL标签,插值或注释的内容将被视为静态文本, 这些东西不会被FreeMarker所解析;会被按照原样输出出来。
FTL标签也被称为 指令。 这些指令在HTML的标签 (比如: <table>
和 </table>
) 和HTML元素 (比如: table
元素) 中的关系是相同的。(如果现在还没有感觉到它们的不同, 那么把“FTL标签”和“指令”看做是同义词即可。)
Note:
可以在 http://freemarker-online.kenshoo.com/ 上很方便的尝试编写模板
基本指令
这里我们仅仅来看一些非常常用的指令,当然 (指令还有很多)。
if 指令
使用 if
指令可以有条件地跳过模板的一些片段。 比如,假设在 最初的示例 中, 想向你的老板Big Joe特别地问好,可其他人不同:
<html>
<head>
<title>Welcome!</title>
</head>
<body>
<h1>
Welcome ${user}<#if user == "Big Joe">, our beloved leader</#if>!
</h1>
<p>Our latest product:
<a href="${latestProduct.url}">${latestProduct.name}</a>!
</body>
</html>
此时,告诉 FreeMarker,当和 "Big Joe"
相同时 ", our beloved leader" (我们最尊敬的领导,译者注) 才是if条件中那唯一的 user
变量的值。 通常来讲,如果 *condition*
是false(布尔值),那么介于 <#if *condition*>
和 </#if>
标签中的内容会被略过。
我们来详细说说 *condition*
的使用: ==
是用来判断它两侧的值是否相等的操作符, 比较的结果是布尔值,也就是true或者false。在 ==
的左侧,是 被引用的变量, 我们很熟悉这样的语法结构;最终它会被变量的值所替代。通常来说, 在指令或插值中没有被引号标注的内容都被视为变量的引用。右侧则是指定的字符串, 在模板中的字符串 只能 放在引号内。
当价格为0时,就会打印出 "Pythons are free today!":
<#if animals.python.price == 0>
Pythons are free today!
</#if>
和之前示例中,字符串被直接指定相似, 但这里则是数字(0
)被直接指定了。 请注意,这里的数字 没有 放在引号内。 如果将("0"
)放在引号中, 那么FreeMarker就会将其误判为字符串了(也就是字符串0,译者注)。
当价格不为0时,则会打印出"Pythons are not free today!":
<#if animals.python.price != 0>
Pythons are not free today!
</#if>
你也许就会猜测了, !=
就是"不等于"。
同时,也可以这样编来写代码(使用 数据模型来描述哈希表):
<#if animals.python.price < animals.elephant.price>
Pythons are cheaper than elephants today.
</#if>
使用 <#else>
标签可以指定当条件为false时程序所要执行的内容。比如:
<#if animals.python.price < animals.elephant.price>
Pythons are cheaper than elephants today.
<#else>
Pythons are not cheaper than elephants today.
</#if>
这个示例中,如果蟒蛇的价格比大象的价格低的话, 程序将会打印出 "Pythons are cheaper than elephants today."。 否则会打印 "Pythons are not cheaper than elephants today."。 后面也可以使用 elseif
来完善它:
<#if animals.python.price < animals.elephant.price>
Pythons are cheaper than elephants today.
<#elseif animals.elephant.price < animals.python.price>
Elephants are cheaper than pythons today.
<#else>
Elephants and pythons cost the same today.
</#if>
如果变量本身就是布尔值(true/false),则可以直接让其作为 if
的 *condition*
(判断条件,译者注):
<#if animals.python.protected>
Pythons are protected animals!
</#if>
list 指令
当需要列表显示内容时,list指令是必须的。比如: 如果合并该模板到 前面描述序列的数据模型 中:
<p>We have these animals:
<table border=1>
<#list animals as animal>
<tr><td>${animal.name}<td>${animal.price} Euros
</#list>
</table>
那么输出结果将会是这样的:
<p>We have these animals:
<table border=1>
<tr><td>mouse<td>50 Euros
<tr><td>elephant<td>5000 Euros
<tr><td>python<td>4999 Euros
</table>
list
指令的一般格式为: <#list *sequence* as *loopVariable*>*repeatThis*</#list>
。 *repeatThis*
部分将会在给定的 *sequence*
遍历时在每一项中重复, 从第一项开始,一个接着一个。在所有的重复中, *loopVariable*
将持有当前遍历项的值。 这个变量仅存在于 <#list *...*>
和 </#list>
标签内。
*sequence*
可以是任意表达式, 比如我们可以列表显示示例数据模型中的水果,就像这样:
<ul>
<#list misc.fruits as fruit>
<li>${fruit}
</#list>
</ul>
你应该很熟悉表达式 misc.fruits
了; 它 引用了数据模型中的变量。
上面示例中的一个问题是如果我们有0个水果,它仍然会输出一个空的 <ul></ul>
,而不是什么都没有。 要避免这样的情况,可以这么来使用 list
:
<#list misc.fruits>
<ul>
<#items as fruit>
<li>${fruit}
</#items>
</ul>
</#list>
此时, list
指令将列表视为一个整体, 在 items
指令中的部分才会为每个水果重复。 如果我们有0个水果,那么在 list
中的所有东西都被略过了, 因此就不会有 ul
标签了。
另一个列表相关的常见任务是:使用一些分隔符来列出水果,比如逗号:
<p>Fruits: <#list misc.fruits as fruit>${fruit}<#sep>, </#list>
<p>Fruits: orange, banana
被 sep
覆盖的部分(我们也可以这么来写: *...*<#sep>, </#sep></#list>
) 只有当还有下一项时才会被执行。 因此最后一个水果后面不会有逗号。
再次回到这个话题,如果我们有0个水果,会怎么样?只是打印 "Fruits:" 也没有什么不方便。 list
指令,也像 if
指令那样,可以有 else
部分,如果列表中有0个元素时就会被执行:
<p>Fruits: <#list misc.fruits as fruit>${fruit}<#sep>, <#else>None</#list>
Note:
事实上,这个过于简单的示例可以这么来写, 但是它使用了本主题中没有介绍的语言特性:
<p>Fruits: ${fruits?join(", ", "None")}
所有的这些指令(list
, items
, sep
, else
)可以联合起来使用:
<#list misc.fruits>
<p>Fruits:
<ul>
<#items as fruit>
<li>${fruit}<#sep> and</#sep>
</#items>
</ul>
<#else>
<p>We have no fruits.
</#list>
Note:
在 指令参考 中, 可以获取到更多关于这些指令的内容。
include 指令
使用 include
指令, 我们可以在模板中插入其他文件的内容。
假设要在一些页面中显示版权声明的信息。那么可以创建一个文件来单独包含这些版权声明, 之后在需要它的地方插入即可。比方说,我们可以将版权信息单独存放在页面文件 copyright_footer.html
中:
<hr>
<i>
Copyright (c) 2000 <a href="http://www.acmee.com">Acmee Inc</a>,
<br>
All Rights Reserved.
</i>
当需要用到这个文件时,可以使用 include
指令来插入:
<html>
<head>
<title>Test page</title>
</head>
<body>
<h1>Test page</h1>
<p>Blah blah...
<#include "/copyright_footer.html">
</body>
</html>
此时,输出的内容为:
<html>
<head>
<title>Test page</title>
</head>
<body>
<h1>Test page</h1>
<p>Blah blah...
<hr>
<i>
Copyright (c) 2000 <a href="http://www.acmee.com">Acmee Inc</a>,
<br>
All Rights Reserved.
</i>
</body>
</html>
当修改了 copyright_footer.html
文件, 那么访问者在所有页面都会看到版权声明的新内容。
Note:
重用代码片段的一个更有力的方式是使用宏,但是只是更为高级的话题了, 将会在 后续讨论。
联合使用指令
在页面上也可以多次使用指令,而且指令间也可以很容易地相互嵌套。 比如,在 list
指令中嵌套 if
指令:
<#list animals as animal>
<div<#if animal.protected> class="protected"</#if>>
${animal.name} for ${animal.price} Euros
</div>
</#list>
请注意,FreeMarker并不解析FTL标签以外的文本、插值和注释, 上面示例在HTML属性中使用FTL标签也不会有问题。
使用内建函数
内建函数很像子变量(如果了解Java术语的话,也可以说像方法), 它们并不是数据模型中的东西,是 FreeMarker 在数值上添加的。 为了清晰子变量是哪部分,使用 ?
(问号)代替 .
(点)来访问它们。常用内建函数的示例:
user?html
给出user
的HTML转义版本, 比如&
会由&
来代替。user?upper_case
给出user
值的大写版本 (比如 "JOHN DOE" 来替代 "John Doe")animal.name?cap_first
给出animal.name
的首字母大写版本(比如 "Mouse" 来替代 "mouse")user?length
给出user
值中 字符的数量(对于 "John Doe" 来说就是8)animals?size
给出animals
序列中 项目 的个数(我们示例数据模型中是3个)- 如果在
<#list animals as animal>
和对应的</#list>
标签中:animal?index
给出了在animals
中基于0开始的animal
的索引值animal?counter
也像index
, 但是给出的是基于1的索引值animal?item_parity
基于当前计数的奇偶性,给出字符串 "odd" 或 "even"。在给不同行着色时非常有用,比如在<td class="${animal?item_parity}Row">
中。
一些内建函数需要参数来指定行为,比如:
animal.protected?string("Y", "N")
基于animal.protected
的布尔值来返回字符串 "Y" 或 "N"。animal?item_cycle('lightRow','darkRow')
是之前介绍的item_parity
更为常用的变体形式。fruits?join(", ")
通过连接所有项,将列表转换为字符串, 在每个项之间插入参数分隔符(比如 "orange,banana")user?starts_with("J")
根据user
的首字母是否是 "J" 返回布尔值true或false。
内建函数应用可以链式操作,比如user?upper_case?html
会先转换用户名到大写形式,之后再进行HTML转义。(这就像可以链式使用 .
(点)一样)
可以阅读 全部内建函数参考。
处理不存在的变量
数据模型中经常会有可选的变量(也就是说有时并不存在)。 除了一些典型的人为原因导致失误外,FreeMarker 绝不能容忍引用不存在的变量, 除非明确地告诉它当变量不存在时如何处理。这里来介绍两种典型的处理方法。
这部分对程序员而言: 一个不存在的变量和一个是 null
值的变量, 对于FreeMarker来说是一样的,所以这里所指的"丢失"包含这两种情况。
不论在哪里引用变量,都可以指定一个默认值来避免变量丢失这种情况, 通过在变量名后面跟着一个 !
(叹号,译者注)和默认值。 就像下面的这个例子,当 user
不存在于数据模型时, 模板将会将 user
的值表示为字符串 "visitor"
。(当 user
存在时, 模板就会表现出 ${user}
的值):
<h1>Welcome ${user!"visitor"}!</h1>
也可以在变量名后面通过放置 ??
来询问一个变量是否存在。将它和 if
指令合并, 那么如果 user
变量不存在的话将会忽略整个问候的代码段:
<#if user??><h1>Welcome ${user}!</h1></#if>
关于多级访问的变量,比如 animals.python.price
, 书写代码:animals.python.price!0
当且仅当 animals.python
永远存在, 而仅仅最后一个子变量 price
可能不存在时是正确的 (这种情况下我们假设价格是 0
)。 如果 animals
或 python
不存在, 那么模板处理过程将会以"未定义的变量"错误而停止。为了防止这种情况的发生, 可以如下这样来编写代码 (animals.python.price)!0
。 这种情况就是说 animals
或 python
不存在时, 表达式的结果是 0
。对于 ??
也是同样用来的处理这种逻辑的; 将 animals.python.price??
对比 (animals.python.price)??
来看。
二、FreeMarker常用语法
一、基本数据类型
-
布尔型:等价于java中的boolean类型, 不同的是不能直接输出,可以转换成字符串再输出
-
日期型:等价于java中的Date类型, 不同之处在于不能直接输出,需要转换成字符串再输出
-
数值型:等价于java 中的int, float, double 等数值类型,有三种显示形式:数值型(默认) 、货币型、百分比型
-
字符串型:等价于java 中的字符串,有很多内置函数
-
sequence 类型:等价于java中的数组,list,set 等集合类型
-
hash 类型:等价于java 中的Map 类型
二、 数据类型示例
【1. 布尔型】:
-
不能直接输出布尔型的值, 必须转换为string:$
-
在if标签中可以直接使用
<#if b> b 的值为 true </#if>
【2. 日期型】
- 输出日期:$
- 只输出时间:$
- 输出日期时间:$
- 格式化日期: $
【3. 数值型】:
1. Freemarker 中预定义了三种数字格式,货币,百分比,数字,默认为数字格式
货币::${0.3?string.currency}
百分比:${0.3?string.percent}
数字(默认):${0.3?string.number}
2.取整
-
向上取整
3.4 --> ${3.4?ceiling} 3.5 --> ${3.5?ceiling}
-
向下取整
3.4 --> ${3.4?floor} 3.5 --> ${3.5?floor}
-
四舍五入
3.4 --> ${3.4?round} 3.5 --> ${3.5?round}
3. 数字格式化, 使用0 表示不够 由0 补齐, 用# 表示不够不补齐
-
保留两位小数: 必须两位,不够补0, 当前一位为偶数时,五舍六入, 当前一位为基数时,四舍五入
0.135 -- > ${0.135?string('.00')} 0.125 -- > ${0.125?string('.00')} 0.1 -- > ${0.1?string('.00')}
-
保留两位小数: 最多两位,不够不补0, 当前一位为偶数时,五舍六入, 当前一位为基数时,四舍五入
0.135 -- > ${0.135?string('#.##')} 0.125 -- > ${0.125?string('#.##')} 0.1 -- > ${0.1?string('#.##')}
-
-
格式化整数, 用0 表示必须三位整数,不够由0 补齐
12.1 -- > ${12.1?string('000.00')} 12.125 -- > ${12.125?string('000.00')} 12.135 -- > ${12.135?string('000.00')}
-
格式化整数, 用0 表示必须三位整数,不够由0 补齐, 一个# 和 多个# 是一样的
12.1 -- > ${12.1?string('#.00')} 12.125 -- > ${12.125?string('#.00')} 12.135 -- > ${12.135?string('#.00')}
-
千位分割
123456789 --> ${123456789?string(',###')} 23456789 --> ${123456789?string(',####')}
4 数字转换成字符串:
数字转换成字符串后,就可以直接用字符串的内置函数了
1234 -- > ${123?string}
1234 -- > ${123?string[0]}
<#-- ${123[2]} 报错 -->
**【4. 字符串型】
4.1 截取字符串subString(start,end):"hello,wold"**
-
截取
6~end: ${"hello,wold"?substring(6)}
-
截取
0~5: ${"Hello,World"?substring(0,5)}
4.2 字母大小写转换
-
首个单词的首字母大写:
${"hello world"?cap_first}
- 首个单词的首个字母母小写:
${"Hello World"?uncap_first}
- 首个单词的首个字母母小写:
-
所有单词首字母大写:
${"hello world"?capitalize}
-
字符串大写:
${"hello,world"?upper_case}
-
字符串小写:
${"hello,world"?lower_case}
4.3 判断是否以xxx 结尾
${"hello,world"?ends_with("world")?string} `
<#if "hello,world"?ends_with("world")>
hello,world 以字符串 world 结尾
</#if>
4.4 判断是否以xxx 开头
${"hello,world"?starts_with("hello")?string}
<#if "hello,world"?starts_with("hello")>
hello,world 以字符串 hello 开头
</#if>
4.5 返回字符串长度
${"hello,world"?length}
4.6 是否包含子串
返回为布尔值,布尔值不能直接输出,必须转换为string
${"hello,world"?contains("llo")?string};
<#if "hello,world"?contains("llo")>
"hello,world" 包含子串 "llo"
</#if>
4.7 去除首尾空格
字符串:${" hello,world "?trim}
4.8 替换字符串
${"hello,world"?replace("o","0")}
4.9 查询字符串第一次出现的索引位置,如果不存在返回0
${"hello,world"?index_of("o")}
${"hello,world"?index_of("aaa")}
4.10 字符串分割数组
<#assign citys="beijing,tianjin,shanghai"?split(",")/>
<#list citys as city>
${city_index} --> ${city}
</#list>
4.11 输出单个字母
${"hello"[0]}
【5. sequence】
1. 获取第一个元素:sequence?first
array: ${cityArray?first}
list: ${cityList?first}
set: ${citySet?first}
2. 获取最后一个元素:sequence?last
array: ${cityArray?last}
list: ${cityList?last}
set: ${citySet?last}
3. 返回sequence 的大小sequence?size
array: ${cityArray?size}
list: ${cityList?size}
set: ${citySet?size}
4. 排序:sequence?sort
- sequence 元素为基本元素时(能转换为String的元素,非sequence 和 hash 元素)
array:sort,reverse
正 序:<#list cityArray as city>${city},</#list>
倒 序:<#list cityArray?reverse as city>${city},</#list>
升 序:<#list cityArray?sort as city>${city},</#list>
降 序:<#list cityArray?sort?reverse as city>${city},</#list>
list:sort,reverse
正 序:<#list cityList as city>${city},</#list>
倒 序:<#list cityList?reverse as city>${city},</#list>
升 序:<#list cityList?sort as city>${city},</#list>
降 序:<#list cityList?sort?reverse as city>${city},</#list>
set:sort,reverse
正 序:<#list citySet as city>${city},</#list>
倒 序:<#list citySet?reverse as city>${city},</#list>
升 序:<#list citySet?sort as city>${city},</#list>
降 序:<#list citySet?sort?reverse as city>${city},</#list>
-
sequence 元素为JavaBean时
正 序: <#list department.employees as employee> ${employee_index} --> ${employee.name} --> ${employee.age} --> ${employee.sex} </#list> 逆 序: <#list department.employees?reverse as employee> ${employee_index} --> ${employee.name} --> ${employee.age} --> ${employee.sex} </#list> 按name属性升序: <#list department.employees?sort_by("name") as employee> ${employee_index} --> ${employee.name} --> ${employee.age} --> ${employee.sex} </#list> 按name属性降序: <#list department.employees?sort_by("name")?reverse as employee> ${employee_index} --> ${employee.name} --> ${employee.age} --> ${employee.sex} </#list>
5. 遍历sequence, 包含索引值
array: <#list cityArray as city> ${city_index} --> ${city} </#list> list: <#list cityList as city> ${city_index} --> ${city} </#list> set: <#list citySet as city> ${city_index} --> ${city} </#list>
6. 根据索引获取sequence 元素
array: ${cityArray[0]}
list: ${cityList[0]}
set: ${citySet[0]}
【6. map 类型】
1. map长度:${cityMap?size};
2. map的keys:cityMap.keys 返回的是一个sequence,类似于数组,所以不能直接输出,需要遍历
<#assign mapKeys=cityMap?keys/>
<#list mapKeys as mapKey>
${mapKey}
</#list>
3. map的values: cityMap.values 返回的是一个sequence,类似于数组,所以不能直接输出,需要遍历
<#assign mapValues=cityMap?values/>
<#list mapValues as mapValue>
${mapValue}
</#list>
4. 遍历map 元素: map 通过key获取value的方法用[]
<#list cityMap?keys as key>
${key_index} --> ${key} --> ${cityMap[key]}
</#list>
**【7. JavaBean 类型】
1. 获取属性:${department.id} --> ${department.name}
2. 级联属性:${department.employees[0].name} --> ${department.employees[0].age} --> ${department.employees[0].sex}
3. 遍历数组:
<#list department.employees as employee>
${employee_index} --> ${employee.name} --> ${employee.age} --> ${employee.sex}
</#list>
4. 排序
<#list department.employees?sort_by("name") as employee>
${employee_index} --> ${employee.name} --> ${employee.age} --> ${employee.sex}
</#list>
三、maven依赖
https://mvnrepository.com/artifact/org.freemarker/freemarker
<!-- https://mvnrepository.com/artifact/org.freemarker/freemarker -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.31</version>
</dependency>
标签:string,FreeMarker,--,数据类型,用法,user,employee,模板
From: https://www.cnblogs.com/ghostmen/p/17457374.html