领先一步
VMware 提供培训和认证,助你快速提升。
了解更多注意:如果你在 2023 年 12 月或之后阅读此文,JMustache 发布了 1.16 版本,增加了“继承”支持。这是 Mustache 规范中的一个可选特性 (Mustache spec),但之前在 JMustache 中并未实现。它允许你进行网络应用中非常常见的“包含主体内容的布局”类型的模板设计,也是本文示例所需要的。GitHub 中的示例已更新,使用继承代替了下文介绍的 Lambda 变通方法。
我很少做服务器端模板开发,但一旦做了……说实话,我经常会忘记一些东西。每种模板语言都有其优缺点,都有需要记住的语法,而更常见的是忘记。最近,我完成了一些关于老项目 Spring Petclinic 的工作,将其视图层转换为使用 Thymeleaf,并对代码进行了稍微“现代化”的重组。我很享受使用 Thymeleaf 3 的过程,感觉很愉快,但不得不花费大量时间查阅文档和示例。后来我有一个小项目需要用到模板,这时我想起了我对 Mustache 的喜爱,我们在 Spring Boot 1.2 版本中就添加了它,它在出色的 Spring REST Docs 工具中扮演着重要角色。我将 spring-boot-starter-mustache
添加到我的新项目中,并在几秒钟内就让它跑起来了。
我想向你展示 JMustache 这个小巧而强大的工具,它用于服务器端渲染 HTML(或任何其他纯文本内容)。我一直喜欢 Mustache,因为它足够简单——“恰到好处”的模板功能——如果你需要在 JVM 中渲染模板,你真的找不到比它更简洁、更精简、更轻量级的库了。它只有一个没有依赖的 Jar 文件,只会给你的类路径增加 78kb,这对任何人来说都没有坏处,反而会让许多人微笑。它的特性非常少,这对于记不住语法的人来说是极好的优点,而且手册很短、内容全面、易于阅读且实用。
如果你继续阅读,我们将构建一个示例应用,你会看到如何使用 Mustache 构建 HTML 页面,渲染静态和动态内容,构建表单和菜单,并将页面布局抽象为单独的组件。Mustache 的简洁性贯穿其中,引导你将逻辑放在 Java 中,使模板尽可能保持干净。作为补充,你还将看到如何以一种稍微不寻常但有趣的方式使用自定义登录表单来保护应用。
GitHub 中有一些跟随本文的示例代码。这是一个小巧的 Spring MVC 应用,同时也使用了 Spring Security。如果你想跟随本文的进度查看其开发过程,可以使用以下标签:
"base" 是一个包含可运行应用的起始点
"includes" 使用页眉和页脚创建了一个可重用布局
"layout" 是一个使用 Mustache lambda 的稍微更高级的实现
"menus" 使用更多 Spring Boot 和 Mustache 特性添加了一些更多的 UI 元素
在每个阶段,你都可以检出相应的标签并运行应用。项目根目录中有一个 Maven Wrapper,因此你可以从命令行构建并运行它,例如:
$ git clone https://github.com/dsyer/mustache-sample
$ cd mustache-sample
$ git checkout base
$ ./mvnw spring-boot:run
除了从命令行运行,你还可以将项目导入到你喜欢的 IDE 中,并运行 DemoApplication
中的 main 方法。
应用运行在 http://localhost:8080,你可以使用任意用户名和密码(甚至为空!)进行身份验证。示例应用中没有实际功能,但它包含登录、注销和主页,以便提供一些展示模板特性的切入点。
Spring Boot 提供了 JMustache 的自动配置支持,因此很容易就能启动一个 Spring MVC 应用。你可以从 Spring Initializr 生成一个项目,并选择 spring-boot-starter-mustache
依赖。
Spring Boot 会自动为 JMustache 配置一个 ViewResolver
,因此你可以通过提供一个返回视图名称的控制器来实现主页,例如:
HomeController.java
@Controller
class HomeController {
@GetMapping("/")
String home() {
return "index";
}
}
有了这个控制器,当用户访问主页("/")时,Spring 将渲染 classpath:/templates/index.html
处的模板,这意味着它会在你项目的 src/main/resources/templates
目录下查找。例如,你可以将下面的内容放入该文件并确认它是否工作:
index.html
<!doctype html>
<html lang="en">
<body>
<h1>Demo</h1>
<div>Hello World</div>
</body>
</html>
目前还没有动态(模板)内容。让我们来保护应用,并添加一个登录表单,这时你就需要动态内容了。所以,在你的依赖中添加 spring-cloud-starter-security
,主页就会自动受到保护。假设你想在 "/login" 路径下有一个登录表单,那么你就需要这个控制器:
LoginController.java
@Controller
@RequestMapping("/login")
class LoginController {
@GetMapping
public String form() {
return "login";
}
}
你还需要一些基本的安全配置,如果你继承了 Spring Security 的基类,可以将其作为主应用中的一个方法添加:
DemoApplication.java
@SpringBootApplication
public class DemoApplication extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/login", "/error").permitAll()
.antMatchers("/**").authenticated()
.and().exceptionHandling()
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"));
}
}
注意
这是一个稍微有些非传统的表单登录配置,因为它没有使用内置的 formLogin()
方法,后者会自动添加身份验证入口点。如果这让你感到困惑,可以跳过下一节,直接在你的配置中添加 .formLogin()
来代替上面(在 .and()
之后)的 exceptionHandling()
。
Spring Security 通过使用 Filter
来实现表单登录(以及其他所有功能),这就是使用内置的 formLogin()
配置会得到的结果。为了增加趣味性,你将把身份验证放在 Spring MVC 处理器中完成,这样你就可以添加一些自定义逻辑,而且 MVC 比 Filter 更容易使用。
那么,让我们扩展 LoginController
,添加一个处理用户名/密码身份验证的方法(很容易扩展到更复杂的逻辑)。主要需要做的是验证输入,如果是真实用户,则填充 SecurityContext
。
LoginController.java
@PostMapping
public void authenticate(@RequestParam Map<String, String> map) throws Exception {
Authentication result = new UsernamePasswordAuthenticationToken(
map.get("username"), "N/A",
AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_USER"));
SecurityContextHolder.getContext().setAuthentication(result);
}
注意
在这个简单示例中,只有一条“成功路径”——所有用户都被认证。显然,这不是一个非常安全的身份验证过程,在实际的控制器中,你会希望抛出 AuthenticationException
,例如 BadCredentialsException
。异常会由 Spring Security 处理。
为了模拟内置 Spring Security 登录表单的行为,你还需要能够重定向到用户在登录前尝试访问的“保存请求”。Spring Security 为此提供了一个 AuthenticationSuccessHandler
抽象,以及一个知道保存请求的简单实现。因此,authenticate
方法可以使用它(它需要 servlet 请求和响应,你可以将它们作为方法参数添加,Spring MVC 会注入它们)。
LoginController.java
private AuthenticationSuccessHandler handler = new SavedRequestAwareAuthenticationSuccessHandler();
@PostMapping
public void authenticate(@RequestParam Map<String, String> map,
HttpServletRequest request, HttpServletResponse response) throws Exception {
// ... authenticate user from request parameters
handler.onAuthenticationSuccess(request, response, result);
}
现在你已经准备好接受身份验证请求了,你需要一个供用户填写和提交的表单。LoginController
渲染 "login" 模板,所以你需要在你的模板文件夹中添加一个 "login.html" 文件。例如:
login.html
<!doctype html>
<html lang="en">
<body>
<h1>Login</h1>
<form action="/login" method="post"> (1)
<label for="username">Username:</label>
<input type="text" name="username" /> (2)
<label for="password">Password:</label>
<input type="password" name="password" /> (3)
<input type="hidden" name="_csrf" value="{{_csrf.token}}" /> (4)
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</body>
</html>
一个表单,带有一个提交按钮,将内容发送到 "POST /login"
用户名字段输入框
密码字段输入框
CSRF token,格式符合 Spring Security 的要求。
CSRF token 是你的第一块动态内容,它展示了 Mustache 的工作原理,顺便也解释了为什么它被称为“Mustache”。来自“上下文”(在本例中是 Spring MVC 模型对象)的变量可以使用双花括号,或者说“mustaches”({{
和 }}
)来渲染。JMustache 还可以导航变量内部的对象图,所以 _csrf.token
会解析为 "_csrf" 对象的 "token" 属性。
Spring Security 将 "_csrf" 对象放入请求属性中。要将其复制到 MVC 模型中,你需要在 application.properties
中进行设置:
application.properties
spring.mustache.expose-request-attributes=true
完成所有这些设置后,你会发现通过浏览器访问应用时,会首先重定向到 "/login"。由于你的处理器中的身份验证逻辑很弱(不存在),你可以在表单中输入任何内容并提交,就能看到主页。
注意
在示例应用中,我们通过 webjars 导入了一些样式表,让应用看起来更好看一些,但这并没有增加任何功能。
示例代码中有一个 "base" 标签,它是一个包含我们目前为止看到的所有功能的应用程序。
我们的应用只有 2 个页面,但即使代码库这么小,HTML 中还是会有不少重复的内容。将所有页面的共同元素提取到可重用模板中是非常有用的。一种方法是使用 "includes"。所以我们可以将页面的顶部内容和底部内容提取到 "header.html" 文件中:
header.html
<!doctype html>
<html lang="en">
<body>
和 "footer.html"
footer.html
</body>
</html>
(这些是故意简化的示例。在真实的应用中,它们可能会包含大量的样式表、脚本和 meta 标签。)
有了这些模板,我们可以重写主页:
index.html
{{>header}}
<h1>Demo</h1>
<div>Hello World</div>
{{>footer}}
登录表单也会看起来类似(只包含 HTML 的主体)。在这些示例中,你可以看到 Mustache 用于 "includes" 的语法,它有点像一个变量,但在开始标签中多了一个 ">"。模板的名称解析方式与视图模板相同(因此 "footer" 映射到 "templates" 目录下的 "footer.html")。
有些人喜欢使用可以独立渲染并在浏览器中查看的 HTML 模板。能够编辑模板并独立于任何服务器或应用逻辑查看结果是很不错的。Mustache 对于这种“自然”模板来说并不是一种完美的语言,但它确实有一个可以让你接近这种效果的特性。那个特性就是“注释”。
所以,举个例子,你可以为你的主页模板添加一个静态的页眉和页脚,这样它在浏览器中渲染时(几乎)就像在应用中一样。只需用 Mustache 注释标签({{!
和 }}
)将静态内容围起来即可。例如:
index.html
{{!
<!doctype html>
<html lang="en">
<body>
}}
{{>header}}
<h1>Demo</h1>
<div>Hello World</div>
{{>footer}}
{{!
</body>
</html>
}}
浏览器仍然会将 Mustache 标签渲染为字面花括号,但你可以眯着眼睛忽略它们,其余内容将完全按照在应用中的样子布局。显然,对于这样基础的内容来说,收益不大,但当内容更复杂并包含样式和脚本时,这样做可能更有意义。
GitHub 中的示例代码有一个名为 "includes" 的标签,它是一个包含我们目前为止看到的所有功能的应用程序。
有些人会很乐意将页眉和页脚放在单独的模板中,但另一些人会抱怨。老实说,在布局分层内容(HTML)时,强制将元素(如示例中的 <body>
标签)拆分到多个文件中的确感觉有点别扭。如果能在一个文件中控制布局,像这样,会更好:
layout.html
<!doctype html>
<html lang="en">
<body>
{{{layout.body}}}
</body>
</html>
然后以某种方式在我们的主页和登录页中生成“body”内容。
Mustache 允许你在模板中插入通用的“可执行”内容。这是一个非常强大的功能,你可以用它将布局提取到自己的模板中,也可以做其他涉及一些逻辑的事情。它的语法是一个通用的 Mustache 标签,解析后会得到一个可执行的东西。主页看起来会像这样:
index.html
{{#layout}}
<h1>Demo</h1>
<div>Hello World</div>\
{{/layout}}
要实现这一点,你首先需要在我们的 MVC 模型中有一个名为 "layout"、类型为 Mustache.Lambda
的对象。你可以在控制器方法中完成此操作,或者(更好的是)使用 @ControllerAdvice
为所有视图添加模型属性。例如:
LayoutAdvice.java
@ControllerAdvice
class LayoutAdvice {
@ModelAttribute("layout")
public Mustache.Lambda layout() {
return new Layout();
}
}
class Layout implements Mustache.Lambda {
String body;
@Override
public void execute(Fragment frag, Writer out) throws IOException {
body = frag.execute();
}
}
请注意,"layout" 属性使用 Fragment.execute()
渲染其主体内容,并将其赋值给一个名为 "body" 的属性,这个属性可以在 Mustache 中作为变量引用。"layout.html" 模板已经包含了引入主体内容的语法,即 {{{layout.body}}}
,所以剩下的就是实际渲染布局(到目前为止我们只渲染了主体内容)。我们可以通过将布局显式导入到主页中,作为第一步来实现这一点:
index.html
{{#layout}}
<h1>Demo</h1>
<div>Hello World</div>\
{{/layout}}
{{>layout}}
对登录模板也做同样的操作:
login.html
{{#layout}}
<h1>Login</h1>
<form action="/login" method="post">
<label for="username">Username:</label>
<input type="text" name="username" />
<label for="password">Password:</label>
<input type="password" name="password" />
<input type="hidden" name="_csrf" value="{{_csrf.token}}" />
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{{/layout}}
{{>layout}}
然后你就可以开始了。一切正常,应用会显示具有相同布局的登录页和主页。
提示
你可能已经注意到 "layout.html" 中的三花括号({{{
和 }}}
)。这是 JMustache 的一个特性:默认情况下所有内容都会被转义,但这部分内容会渲染两次,所以我们只需要在第一次渲染时转义,因此使用了三花括号。
为了消除在使用 {{#layout}}
的每个页面中对 {{>layout}}
的显式引入,你可以在 lambda 内部完成这一部分。你需要引用 Mustache 编译器,然后编译一个包含布局的模板并执行它:
Layout.java
class Layout implements Mustache.Lambda {
String body;
private Compiler compiler;
public Layout(Compiler compiler) {
this.compiler = compiler;
}
@Override
public void execute(Fragment frag, Writer out) throws IOException {
body = frag.execute();
compiler.compile("{{>layout}}").execute(frag.context(), out);
}
}
编译器在其构造函数中被注入到 Layout
中,并且可以使用 @Autowired
将其注入到控制器通知中:
LayoutAdvice.java
@ControllerAdvice
class LayoutAdvice {
private final Mustache.Compiler compiler;
@Autowired
public LayoutAdvice(Compiler compiler) {
this.compiler = compiler;
}
@ModelAttribute("layout")
public Mustache.Lambda layout(Map<String, Object> model) {
return new Layout(compiler);
}
}
就这样。你可以从视图模板中移除 include 了。例如,这对主页是有效的:
index.html
{{#layout}}
<h1>Demo</h1>
<div>Hello World</div>\
{{/layout}}
旧版模板的最后一行实际上已经被移到了 Layout
lambda 中。
我们正在开发的布局模板中,内容在不同使用场景下有所变化是很常见的。例如,你可能希望主页的“标题”与登录页的标题不同,但标题是 HTML 头部的一部分,而不是主体,所以从逻辑上讲,它是布局的一部分。让我们通过将标题添加到布局的头部来明确这一点:
layout.html
<!doctype html>
<html lang="en">
<head>
<title>{{{layout.title}}}</title>
</head>
<body>
{{{layout.body}}}
</body>
</html>
这强烈暗示了如何实现这个特性:布局有一个名为“title”的新属性,你可以在类声明中给它一个默认值:
Layout.java
class Layout implements Mustache.Lambda {
String body;
String title = "Demo Application";
...
}
现在,剩下的就是填充这个属性了。从逻辑上讲,设置标题是页面视图的一部分,而不是布局的一部分,所以你会希望在声明页面其余内容的地方设置它。其他模板语言有“参数化片段”,但 Mustache 对此来说太简约了。简约是它的一个特性,实际上它为这个问题带来了一个相当优雅的解决方案。
你所有的只有标签,所以你可能会想这样做:
index.html
{{#layout}}{{#title}}Home Page{{/title}}
<h1>Demo</h1>
<div>Hello World</div>\
{{/layout}}
这看起来可行。你只需要提供一个 lambda 来捕获标题。在布局通知中,你可以这样做:
LayoutAdvice.java
@ControllerAdvice
class LayoutAdvice {
...
@ModelAttribute("title")
public Mustache.Lambda defaults(@ModelAttribute Layout layout) {
return (frag, out) -> {
layout.title = frag.execute();
};
}
}
只要对 {{#title}}
的调用嵌套在对 {{#layout}}
的调用内部,一切都会顺利进行。你清理了模板,并将一小部分逻辑移到了它所属的 Java 中。
如果你想检出并对照查看,示例代码在此处标记为 "layout"。
你可以通过表单加载主页并登录到你的应用。用户还不能注销,所以你可能想添加这个功能,最好是在所有页面上都显示一个链接,这样它就成了布局的一部分。为了展示这一点如何实现,让我们给应用添加一个通用的、声明式的菜单栏,并将其中一部分设置为注销按钮。
注销链接其实非常简单。我们只需要一个包含 CSRF token 的表单和一个用于提交它的链接,例如:
layout.html
<!doctype html>
<html lang="en">
<head>
<title>{{{layout.title}}}</title>
</head>
<body>
<form id="logout" action="/logout" method="post">
<input type="hidden" name="_csrf" value="{{_csrf.token}}" />
<button type="submit" class="btn btn-primary">Logout</button>
</form>
{{{layout.body}}}
</body>
</html>
那应该已经可以工作了。但是,让我们将注销功能集成到一组更通用的菜单链接中。HTML 中的元素列表可以用带有嵌套 <li/>
的 <ul/>
表示,因此应用的菜单可以用这种方式渲染。在 Mustache 中,你可以像使用 lambdas 一样进行迭代,使用一个标签,所以让我们创造一个新的标签,叫做 {{#menus}}
:
layout.html
<!doctype html>
<html lang="en">
<head>
<title>{{{layout.title}}}</title>
</head>
<body>
<ul class="nav nav-pills" role="tablist">
{{#menus}}<li><a href="{{path}}">{{name}}</a></li>{{/menus}}
<li><a href="#" onclick="document.getElementById('#logout').submit()">Logout</a></li>
</ul>
{{{layout.body}}}
<form id="logout" action="/logout" method="post">
<input type="hidden" name="_csrf" value="{{_csrf.token}}" />
</form>
</body>
</html>
注意,在 {{#menus}}
标签内部,我们使用标准的 Mustache 语法提取了变量 "name" 和 "path"。
现在你必须在控制器通知中(或等效地在控制器中)定义该标签,以便 "menus" 解析为一个可迭代对象:
LayoutAdvice.java
@ModelAttribute("menus")
public Iterable<Menu> menus() {
return application.getMenus();
}
因此,这段新代码引入了一个 Menu
类型,它包含了 UI 中每个菜单的静态内容。布局中需要 "name" 和 "path",所以你需要这些属性:
Menu.java
class Menu {
private String name;
private String path;
// ... getters and setters
}
在上面的布局通知中,菜单来自于一个 application
对象。这并非严格必要:你也可以在 menus()
方法中直接声明菜单列表,但将其提取到另一个对象中,给了我们使用 Spring Boot 一个很棒的特性,即可以在配置文件中以紧凑的格式声明菜单。
所以现在你需要创建 Application
对象来持有菜单,并将其注入到布局通知中:
Layout.java
private Application application;
@Autowired
public LayoutAdvice(Compiler compiler, Application application) {
this.compiler = compiler;
this.application = application;
}
其中在 Application
中你有类似这样的代码:
Application.java
@Component
@ConfigurationProperties("app")
class Application {
private List<Menu> menus = new ArrayList<>();
// .. getters and setters
}
@ConfigurationProperties
告诉 Spring Boot 从环境中绑定到这个 Bean。从 application.properties
切换到 application.yml
,你可以像这样创建一个 "Home" 和一个 "Login" 菜单:
application.yml
app.menus:
- name: Home
path: /
- name: Login
path: /login
完成这些设置后,你已经定义的 "layout.html" 现在就具备了所需的一切,可以正常工作了。
如果你想检出并对照查看,GitHub 中的示例代码在此处标记为 "menus"。这也是最终状态,所以与 master 分支中的代码相同,可能包含 bug 修复和库更新。我希望你像我一样享受使用 Mustache 的乐趣。
示例代码在文本中的代码基础上增加了一两个额外的特性。其中之一是使用 CSS 样式将“active”菜单与其他菜单区别渲染。为了实现这一点,你需要为 Menu
添加一个标志,并在布局通知中重置它。该逻辑非常自然,也很容易添加到通知中。另一个特性是页面的标题成为了菜单定义的一部分,而不是一个单独的 lambda。