Mustache 的乐趣:JVM 的服务器端模板

工程 | Dave Syer | 2016年11月21日 | ...

注意:如果您在2023年12月或之后阅读本文,JMustache 的 1.16 版本添加了“继承”支持。这是一个可选的Mustache 规范功能,但之前在 JMustache 中未实现。它允许您执行在 Web 应用程序中非常常见的“包含正文的布局”类型模板,并且本文的示例中需要此功能。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 包装器,因此您可以从命令行构建和运行它,例如:

$ git clone https://github.com/dsyer/mustache-sample
$ cd mustache-sample
$ git checkout base
$ ./mvnw spring-boot:run

您可以将项目导入到您喜欢的 IDE 中,并在DemoApplication中运行主方法,而不是从命令行运行。

该应用程序在https://127.0.0.1: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()添加到您的配置中,而不是上面的exceptionHandling().and()之后的所有内容)

使用 Spring MVC 的自定义身份验证处理

Spring Security 使用Filter实现表单登录(以及其他所有内容),这就是使用内置formLogin()配置将获得的内容。为了使事情变得有趣,您将在 Spring MVC 处理程序中进行身份验证,使您可以添加一些自定义逻辑,并且 MVC 比过滤器更容易使用。

因此,让我们扩展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>
  1. 一个表单,带有一个提交按钮,用于将内容发送到“POST /login”

  2. 用户名字段输入

  3. 密码字段输入

  4. CSRF 令牌,采用 Spring Security 要求的格式。

CSRF 令牌是您的第一部分动态内容,它向您展示了 Mustache 的工作原理,顺便说一下,为什么它被称为“Mustache”。“上下文”(在本例中为 Spring MVC 模型对象)中的变量可以使用双大括号或“胡子”({{}})进行渲染。JMustache 还导航变量内部的对象图,因此_csrf.token解析为“_csrf”对象的“token”属性。

Spring Security 将“_csrf”对象放入请求属性中。要将其复制到 MVC 模型,您需要在application.properties中进行设置

application.properties

spring.mustache.expose-request-attributes=true

完成所有这些操作后,您应该会发现访问浏览器中的应用程序将首先重定向到“/login”。由于您的处理程序中存在弱(不存在)身份验证逻辑,您可以随意在表单中输入任何内容并提交它以查看主页。

注意

示例应用程序中通过 Webjars 导入了一些样式表,使应用程序看起来更美观,但它们并没有增加任何功能。

示例代码包含一个“base”标签,这是一个包含我们目前为止看到的所有功能的应用程序。

布局抽象:使用包含

我们的应用程序只有两个页面,但即使代码库很小,HTML 中也会有很多重复内容。将所有页面的某些公共元素提取到可重用的模板中非常有用。一种方法是使用“包含”。因此,我们可以将页眉和页脚内容提取到“header.html”中。

header.html

<!doctype html>
<html lang="en">
<body>

和“footer.html”中。

footer.html

</body>
</html>

(这些是故意简化的示例。在实际应用中,它们可能包含许多样式表、脚本和元标记。)

使用这些模板,我们可以重写主页

index.html

{{>header}}
    <h1>Demo</h1>
    <div>Hello World</div>
{{>footer}}

登录表单看起来类似(只是 HTML 的主体)。在这些示例中,您可以看到 Mustache 语法用于“包含”,它有点像变量,但在开始标签中多了一个“>”。模板的名称解析方式与视图模板相同(因此“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”的标签,这是一个包含我们目前为止看到的所有功能的应用程序。

布局抽象:使用 Lambda

有些人对在单独模板中使用页眉和页脚非常满意,但其他人会抱怨。说实话,布局分层内容(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 功能:默认情况下所有内容都会转义,但是此内容将被渲染两次,因此我们只需要第一次转义它,所以我们使用三个花括号。

在 Lambda 中渲染布局

要消除在每个使用{{#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);
	}
}

就是这样。您可以从视图模板中删除包含。例如,这对主页有效

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”。

注销:菜单和 Spring Boot 配置

您可以加载主页并使用表单登录到您的应用程序。用户尚无法注销,因此您可能需要添加此功能,理想情况下是作为所有页面上的链接,因此使其成为布局的一部分。为了展示其工作原理,让我们向应用程序添加一个通用的声明式菜单栏,并使其一部分成为注销按钮。

注销链接实际上非常简单。我们只需要一个包含 CSRF 令牌和提交链接的表单,例如

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 中,您就像 lambda 一样进行迭代,使用一个标签,因此让我们发明一个名为{{#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,您可以创建“主页”和“登录”菜单,如下所示

application.yml

app.menus:
  - name: Home
    path: /
  - name: Login
    path: /login

有了这个,您已经定义的“layout.html”现在拥有使其正常工作所需的一切。

如果您想查看并比较注释,示例代码在github的这个位置已标记为“menus”。它也是最终状态,因此它与master分支中的代码相同,可能包含错误修复和库更新。我希望您像我一样喜欢使用Mustache。

脚注

示例代码在文本代码的基础上添加了一两个额外功能。其中之一是使用CSS样式对“active”菜单进行不同的渲染。为此,您需要向Menu添加一个标志,并在布局建议中将其重置。该逻辑很自然,易于添加到建议中。另一个是页面标题是菜单定义的一部分,而不是单独的lambda表达式。

获取Spring通讯

通过Spring通讯保持联系

订阅

领先一步

VMware提供培训和认证,以加速您的进步。

了解更多

获取支持

Tanzu Spring在一个简单的订阅中提供OpenJDK™、Spring和Apache Tomcat®的支持和二进制文件。

了解更多

即将举行的活动

查看Spring社区中所有即将举行的活动。

查看全部