展现信息
从根本上来讲,Taco Cloud 是一个可以在线订购 taco 的地方。但是,除此之外,Taco Cloud 允许客户展现其创意,能够让他们通过丰富的配料(ingredient)设计自己的 taco。
因此,Taco Cloud 需要有一个页面为 taco 艺术家展现可以选择的配料。可选的配料可能随时会发生变化,所以不能将它们硬编码到 HTML 页面中。我们应该从数据库中获取可用的配料并将其传递给页面,进而展现给客户。
在 Spring Web 应用中,获取和处理数据是控制器的任务,而将数据渲染到 HTML 中并在浏览器中展现是视图的任务。为了支撑 taco 的创建页面,我们需要构建如下的组件:
-
用来定义 taco 配料属性的领域类;
-
用来获取配料信息并将其传递至视图的 Spring MVC 控制器类;
-
用来在用户的浏览器中渲染配料列表的视图模板。
这些组件之间的关系如图2.1所示。

因为本章主要关注 Spring 的 Web 框架,所以我们会将数据库相关的内容放到第 3 章中进行讲解。现在的控制器只负责向视图提供配料。在第 3 章中,我们会重新改造这个控制器,让它能够与存储库协作,从数据库中获取配料数据。
在编写控制器和视图之前,我们首先确定用来表示配料的领域类型,它会为开发 Web 组件奠定基础。
构建领域类
应用的领域指的是它所要解决的主题范围,也就是会影响应用理解的理念和概念。在 Taco Cloud 应用中,领域对象包括 taco设计、组成这些设计的配料、顾客以及顾客所下的 taco 订单。图2.2展示了这些实体以及它们是如何关联到一起的。

作为开始,我们首先关注 taco 的配料。在我们的领域中,taco 配料是非常简单的对象。每种配料都有一个名称和类型,以便于对其进行可视化的分类(蛋白质、奶酪、酱汁等)。每种配料还有一个 ID,这样的话对它的引用就能非常容易和明确。程序清单2.1所示的 Ingredient 类定义了我们所需的领域对象。
package tacos;
import lombok.Data;
@Data
public class Ingredient {
private final String id;
private final String name;
private final Type type;
public enum Type {
// 卷饼、蛋白质、蔬菜、奶酪、酱汁
WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
}
}
我们可以看到,这是一个非常普通的 Java 领域类,它定义了描述配料所需的 3 个属性。在程序清单2.1中,Ingredient 类最不寻常的一点就是它似乎缺少了常见的 getter 和 setter 方法,以及像 equals()、hashCode()、toString() 等这些有用的方法。
在程序清单中没有这些方法,除了节省篇幅的目的外,还因为我们使用了名为 Lombok 的库。这是一个非常棒的库,它能够在编译期自动生成这些方法,这样一来,在运行期就能使用它们了。实际上,类级别的 @Data 注解就是由 Lombok 提供的,它会告诉 Lombok 生成所有缺失的方法,同时还会生成所有以 final 属性为参数的构造器。使用 Lombok 能够让 Ingredient 的代码简洁明了。
Lombok 并不是 Spring 库,但是它非常有用,如果没有它,开发工作将很难开展。当我需要在书中将代码示例编写得短小简洁时,它简直成了我的救星。
要使用 Lombok,首先要将其作为依赖添加到项目中。如果你使用 Spring Tool Suite,只需要右键点击 pom.xml,并从 Spring 上下文菜单选项中选择 “Add Starters”。在第 1 章中看到的选择依赖的对话框将会再次出现(参见图1.4),这样,我们就有机会添加依赖或修改已选择的依赖。在 Developer Tools 下找到 Lombok 选项,并确保它处于已选中的状态,然后选择 “OK”,Spring Tool Suite 会自动将其添加到构建规范中。
另外,你也可以在 pom.xml 中通过如下的条目进行手动添加:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
如果想要手动添加 Lombok 到构建之中,还需要在 pom.xml 文件的 <build> 部分将其从 Spring Boot Maven 插件中排除:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
Lombok 的魔力是在编译期发挥作用的,所以在运行期没有必要用到它们。像这样将其排除出去,在最终形成的 JAR 或 WAR 文件中就不会包含它了。
Lombok 依赖将会在开发阶段为你提供 Lombok 注解(例如 @Data),并且会在编译期进行自动化的方法生成。但是,我们还需要将 Lombok 作为扩展添加到 IDE 上,否则 IDE 将会报错,提示缺少方法和 final 属性没有赋值。请访问 Project Lombok 网站以查阅如何在你所选择的 IDE 上安装 Lombok。
我相信你会发现 Lombok 非常有用,但你也需要知道,它是可选的。在开发 Spring 应用时,它并不是强制要使用的,所以你如果不想使用它,完全可以手动编写这些缺失的方法。你尽可以合上本书去这样做……我会在这里等你。
配料是 taco 的基本构成要素。为了解这些配料是如何组合在一起的,我们要定义 Taco 领域类,如程序清单2.2所示。
package tacos;
import java.util.List;
import lombok.Data;
@Data
public class Taco {
private String name;
private List<Ingredient> ingredients;
}
我们可以看到,Taco 是一个很简单的 Java 领域对象,它包含两个属性。与 Ingredient 一样,Taco 类使用了 @Data 注解,以便 Lombok 在编译期自动生成基本的 JavaBean 方法。
现在已经定义了 Ingredient 和 Taco,我们还需要一个领域类来定义客户如何指定他们想要订购的 taco 并明确支付信息和投递信息(配送地址)。这就是 TacoOrder 类的职责了,如程序清单2.3所示。
package tacos;
import java.util.List;
import java.util.ArrayList;
import lombok.Data;
@Data
public class TacoOrder {
private String deliveryName;
private String deliveryStreet;
private String deliveryCity;
private String deliveryState;
private String deliveryZip;
private String ccNumber;
private String ccExpiration;
private String ccCVV;
private List<Taco> tacos = new ArrayList<>();
public void addTaco(Taco taco) {
this.tacos.add(taco);
}
}
除了比 Ingredient 或 Taco 具有更多的属性外,TacoOrder 并没有什么特殊的新内容可以讨论。它是一个很简单的领域类,具有 9 个属性,其中 5 个是投递相关的信息,3 个是支付相关的信息,还有一个是组成订单的 Taco 对象的列表。它有一个 addTaco() 方法,是为了方便向订单中添加 taco 而增加的。
现在领域类型已经定义完毕,我们可以让它们运行起来了。接下来,我们会在应用中添加一些控制器,让它们来处理应用的 Web 请求。
创建控制器类
在 Spring MVC 框架中,控制器是重要的参与者。它们的主要职责是处理 HTTP 请求,要么将请求传递给视图以便于渲染 HTML(浏览器展现),要么直接将数据写入响应体(RESTful)。在本章中,我们将会关注使用视图来为 Web 浏览器生成内容的控制器。在第 7 章,我们将会看到如何以 REST API 的形式编写控制器来处理请求。
对于 Taco Cloud 应用来说,我们需要一个简单的控制器,它要完成如下的功能:
-
处理路径为 “/design” 的 HTTP GET 请求;
-
构建配料的列表;
-
处理请求,并将配料数据传递给要渲染为 HTML 的视图模板,然后发送给发起请求的 Web 浏览器。
程序清单 2.4 中的 DesignTacoController 类解决了这些需求。
package tacos.web;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import lombok.extern.slf4j.Slf4j;
import tacos.Ingredient;
import tacos.Ingredient.Type;
import tacos.Taco;
@Slf4j
@Controller
@RequestMapping("/design")
@SessionAttributes("tacoOrder")
public class DesignTacoController {
@ModelAttribute
public void addIngredientsToModel(Model model) {
List<Ingredient> ingredients = Arrays.asList(
new Ingredient("FLTO", "Flour Tortilla", Type.WRAP),
new Ingredient("COTO", "Corn Tortilla", Type.WRAP),
new Ingredient("GRBF", "Ground Beef", Type.PROTEIN),
new Ingredient("CARN", "Carnitas", Type.PROTEIN),
new Ingredient("TMTO", "Diced Tomatoes", Type.VEGGIES),
new Ingredient("LETC", "Lettuce", Type.VEGGIES),
new Ingredient("CHED", "Cheddar", Type.CHEESE),
new Ingredient("JACK", "Monterrey Jack", Type.CHEESE),
new Ingredient("SLSA", "Salsa", Type.SAUCE),
new Ingredient("SRCR", "Sour Cream", Type.SAUCE)
);
Type[] types = Ingredient.Type.values();
for (Type type : types) {
model.addAttribute(type.toString().toLowerCase(),
filterByType(ingredients, type));
}
}
@ModelAttribute(name = "tacoOrder")
public TacoOrder order() {
return new TacoOrder();
}
@ModelAttribute(name = "taco")
public Taco taco() {
return new Taco();
}
@GetMapping
public String showDesignForm() {
return "design";
}
private Iterable<Ingredient> filterByType(
List<Ingredient> ingredients, Type type) {
return ingredients
.stream()
.filter(x -> x.getType().equals(type))
.collect(Collectors.toList());
}
}
对于 DesignTacoController,我们先要注意在类级别所应用的注解。首先是 @Slf4j,这是 Lombok 所提供的注解,在编译期,它会在这个类中自动生成一个 SLF4J Logger(SLF4J 即 simple logging facade for Java,请访问 slf4j 网站以了解更多)静态属性。这个简单的注解和在类中通过如下代码显式声明的效果是一样的:
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(DesignTacoController.class);
随后,我们将会用到这个 Logger。
DesignTacoController 用到的下一个注解是 @Controller。这个注解会将这个类识别为控制器,并且将其作为组件扫描的候选者,所以 Spring 会发现它并自动创建一个 DesignTacoController 实例,并将该实例作为 Spring 应用上下文中的 bean。
DesignTacoController 还带有 @RequestMapping 注解。当 @RequestMapping 注解用到类级别的时候,它能够指定该控制器所处理的请求类型。在本例中,它规定 DesignTacoController 将会处理路径以 “/design” 开头的请求。
最后,我们可以看到 DesignTacoController 还带有 @SessionAttributes("tacoOrder") 注解,这表明在这个类中稍后放到模型里面的 TacoOrder 对象应该在会话中一直保持。这一点非常重要,因为创建 taco 也是创建订单的第一步,而我们创建的订单需要在会话中保存,这样能够使其跨多个请求。
处理GET请求
修饰 showDesignForm() 方法的 @GetMapping 注解对类级别的 @RequestMapping 进行了细化。@GetMapping 结合类级别的 @RequestMapping,指明当接收到对 “/design” 的 HTTP GET 请求时,Spring MVC 将会调用 showDesignForm() 来处理请求。
@GetMapping 只是诸多请求映射注解中的一个。表2.1列出了 Spring MVC 中所有可用的请求映射注解。
注解 | 描述 |
---|---|
@RequestMapping |
通用的请求处理 |
@GetMapping |
处理HTTP GET请求 |
@PostMapping |
处理HTTP POST请求 |
@PutMapping |
处理HTTP PUT请求 |
@DeleteMapping |
处理HTTP DELETE请求 |
@PatchMapping |
处理HTTP PATCH请求 |
当 showDesignForm() 处理针对 “/design” 的 GET 请求时,其实并没有做太多的事情。它只不过返回了一个值为 “design” 的 String,这是视图的逻辑名称,用来向浏览器渲染模型。
似乎针对 “/design” 的 GET 请求并没有做太多的事情,但事实恰恰相反,除了在 showDesignForm() 方法中看到的,它还有很多其他的事情做。你可能注意到,程序清单2.4中有一个名为 addIngredientsToModel() 的方法,它带有 @ModelAttribute 注解。这个方法也会在请求处理的时候被调用,构建一个包含 Ingredient 的配料列表并将其放到模型中。现在,这个列表是硬编码的。在第 3 章,我们会从数据库中获取可用的列表。
配料列表准备就绪之后,addIngredientsToModel() 方法接下来的几行代码会根据配料类型过滤列表,这是通过名为 filterByType() 的辅助方法实现的。配料类型的列表会以属性的形式添加到 Model 对象上,并传递给 showDesignForm() 方法。Model 对象负责在控制器和展现数据的视图之间传递数据。实际上,放到 Model 属性中的数据将会复制到 Servlet Request 的属性中,这样视图就能找到它们,并使用它们在用户的浏览器中渲染页面。
addIngredientsToModel() 之后是另外两个带有 @ModelAttribute 注解的方法。这些方法要简单得多,只创建了一个新的 TacoOrder 和 Taco 对象来放置到模型中。TacoOrder 对象在前面阐述 @SessionAttributes 注解的时候曾经提到过,当用户在多个请求之间创建 taco 时,它会持有正在建立的订单的状态。除此之外,Taco 对象也被放置到了模型中,这样一来,为响应 “/design” 的 GET 请求而呈现的视图就能展示一个非空的对象了。
我们的 DesignTacoController 已经具备雏形了。如果现在运行应用并在浏览器上访问 “/design” 路径,DesignTacoController 的 showDesignForm() 和 addIngredientsToModel() 方法将会被调用,它们在将请求传递给视图之前,会将配料和一个空的 Taco 放到模型中。但是,我们现在还没有定义视图,请求将会遇到很糟糕的问题,也就是 HTTP 500 (Internal Server Error) 错误。为了解决这个问题,我们将注意力切换到视图上,在这里数据将会使用 HTML 进行装饰,以便于在用户的 Web 浏览器中展现。
设计视图
在控制器完成它的工作之后,现在就该视图登场了。Spring 提供了多种定义视图的方式,包括 JavaServer Pages(JSP)、Thymeleaf、FreeMarker、Mustache 和基于 Groovy 的模板。就现在来讲,我们会使用 Thymeleaf,这也是我们在第 1 章开启这个项目时的选择。我们会在2.5节考虑其他的可选方案。
在第 1 章,我们已经将 Thymeleaf 作为依赖添加了进来。在运行时,Spring Boot 的自动配置功能会发现 Thymeleaf 在类路径中,因此会为 Spring MVC 自动创建支撑 Thymeleaf 视图的 bean。
像 Thymeleaf 这样的视图库在设计时是与特定的 Web 框架解耦的。这样一来,它们无法感知 Spring 的模型抽象,因此,无法与控制器放到 Model 中的数据协同工作。但是,它们可以与 Servlet 的 request 属性协作。所以,在 Spring 将请求转移到视图之前,它会把模型数据复制到 request 属性中,Thymeleaf 和其他的视图模板方案就能访问到它们了。
Thymeleaf 模板就是增加一些额外元素属性的 HTML,这些属性能够指导模板如何渲染 request 数据。举例来说,如果有个请求属性的 key 为 “message”,我们想要使用 Thymeleaf 将其渲染到一个 HTML <p> 标签中,那么在 Thymeleaf 模板中,可以这样写:
<p th:text = "${message}">placeholder message</p>
模板渲染成 HTML 时,<p> 元素体将会被替换为 Servlet request 中 key 为 “message” 的属性值。“th:text” 是 Thymeleaf 命名空间中的属性,它会执行这个替换过程。${}
操作符会告诉它要使用某个 request 属性(在本例中,也就是 “message”)中的值。
Thymeleaf 还提供了另外一个属性:th:each,它会迭代一个元素集合,为集合中的每个条目渲染 HTML。在我们设计视图展现模型中的配料列表时,这就非常便利了。举例来说,如果只想渲染 “wrap” 配料的列表,可以使用如下的 HTML 片段:
<h3>Designate your wrap:</h3>
<div th:each = "ingredient : ${wrap}">
<input th:field = "*{ingredients}" type = "checkbox"
th:value = "${ingredient.id}"/>
<span th:text = "${ingredient.name}">INGREDIENT</span><br/>
</div>
在这里,我们在 <div> 标签中使用 th:each 属性,从而针对 wrap request 属性所对应集合中的每个元素重复渲染 <div> 标签。每次迭代时,配料元素都会绑定到一个名为 ingredient 的 Thymeleaf 变量上。
在 <div> 元素中,有一个 <input> 复选框元素,还有一个为复选框提供标签的 <span> 元素。复选框使用 Thymeleaf 的 th:value 来为渲染出的 <input> 元素设置 value 属性,这里会将其设置为所找到的 ingredient 的 id 属性。而 th:field 属性最终会用来设置 <input> 元素的 name 属性,用来记住复选框是否被选中。稍后添加校验功能时,这能够确保在出现校验错误的时候,复选框依然能够保持表单重新渲染前的状态。<span> 元素使用 th:text 将 “INGREDIENT” 占位符文本替换为 ingredient 的 name 属性。
用实际的模型数据进行渲染时,其中一个 <div> 迭代的渲染结果可能会如下所示:
<div>
<input name = "ingredients" type = "checkbox" value = "FLTO" />
<span>Flour Tortilla</span><br/>
</div>
最终,上述的 Thymeleaf 片段会成为一大段 HTML 表单的一部分,我们的 taco 艺术家用户会通过这个表单来提交其美味的作品。完整的 Thymeleaf 模板会包括所有的配料类型,这个表单如程序清单2.5所示:
<!DOCTYPE html>
<html xmlns = "http://www.w3.org/1999/xhtml"
xmlns:th = "http://www.thymeleaf.org">
<head>
<title>Taco Cloud</title>
<link rel = "stylesheet" th:href = "@{/styles.css}" />
</head>
<body>
<h1>Design your taco!</h1>
<img th:src = "@{/images/TacoCloud.png}"/>
<form method = "POST" th:object = "${taco}">
<div class = "grid">
<div class = "ingredient-group" id = "wraps">
<h3>Designate your wrap:</h3>
<div th:each = "ingredient : ${wrap}">
<input th:field = "*{ingredients}" type = "checkbox"
th:value = "${ingredient.id}"/>
<span th:text = "${ingredient.name}">INGREDIENT</span><br/>
</div>
</div>
<div class = "ingredient-group" id = "proteins">
<h3>Pick your protein:</h3>
<div th:each = "ingredient : ${protein}">
<input th:field = "*{ingredients}" type = "checkbox"
th:value = "${ingredient.id}"/>
<span th:text = "${ingredient.name}">INGREDIENT</span><br/>
</div>
</div>
<div class = "ingredient-group" id = "cheeses">
<h3>Choose your cheese:</h3>
<div th:each = "ingredient : ${cheese}">
<input th:field = "*{ingredients}" type = "checkbox"
th:value = "${ingredient.id}"/>
<span th:text = "${ingredient.name}">INGREDIENT</span><br/>
</div>
</div>
<div class = "ingredient-group" id = "veggies">
<h3>Determine your veggies:</h3>
<div th:each = "ingredient : ${veggies}">
<input th:field = "*{ingredients}" type = "checkbox"
th:value = "${ingredient.id}"/>
<span th:text = "${ingredient.name}">INGREDIENT</span><br/>
</div>
</div>
<div class = "ingredient-group" id = "sauces">
<h3>Select your sauce:</h3>
<div th:each = "ingredient : ${sauce}">
<input th:field = "*{ingredients}" type = "checkbox"
th:value = "${ingredient.id}"/>
<span th:text = "${ingredient.name}">INGREDIENT</span><br/>
</div>
</div>
</div>
<div>
<h3>Name your taco creation:</h3>
<input type = "text" th:field = "*{name}"/>
<br/>
<button>Submit Your Taco</button>
</div>
</form>
</body>
</html>
可以看到,我们会为各种类型的配料重复定义 <div> 片段。另外,我们还包含了 Submit 按钮和用户用来定义其作品名称的输入域。
还值得注意的是,完整的模板包含了一个 Taco Cloud 的商标图片以及对样式表的 <link> 引用。在这两个场景中,都使用了 Thymeleaf 的 @{}
操作符,用来生成一个相对于上下文的路径,以便于引用我们需要的静态制品(artifact)。正如我们在第 1 章中所学到的,在 Spring Boot 应用中,静态内容要放到根类路径的 “/static” 目录下。
我们的控制器和视图已经完成了,现在我们可以将应用启动起来,看一下我们的劳动成果。运行 Spring Boot 应用有很多种方式。在第 1 章中,我为你展示了如何通过在 Spring Boot Dashboard 中点击 Start 按钮来运行应用。不管采用哪种方式启动 Taco Cloud 应用,在启动之后,都可以通过 http://localhost:8080/design 来进行访问。你将会看到类似于图2.3的页面。

这看上去非常不错!访问你站点的 taco 艺术家可以看到一个包含了各种 taco 配料的表单,他们可以使用这些配料创建自己的杰作。但是当他们点击 Submit your taco 按钮的时候会发生什么呢?
我们的 DesignTacoController 还没有为接收创建 taco 的请求做好准备。此时提交设计表单会遇到一个错误(具体来讲,是一个 HTTP 405 错误:Request Method “POST” Not Supported)。接下来,我们通过编写一些处理表单提交的控制器代码来修正这个错误。