实验介绍
第一印象是非常重要的:Curb Appeal 能够在购房者真正进门之前就将房子卖掉;如果一辆车喷成了樱桃色,那么它的油漆会比它的引擎更引人注目;文学作品中充满了一见钟情的故事。内在固然非常重要,但是外在的,也就是第一眼看到的东西同样非常重要。
我们使用 Spring 所构建的应用能完成各种各样的事情,包括处理数据、从数据库中读取信息以及与其他应用进行交互。但是,用户对应用程序的第一印象来源于用户界面。在很多应用中,UI 是以浏览器中的 Web 应用的形式来展现的。
在第 1 章中,我们创建了第一个 Spring MVC 控制器来展现应用的主页。但是,Spring MVC 能做很多的事情,并不局限于展现静态内容。在本章中,我们将会开发 Taco Cloud 的第一个主要功能:设计定制 taco 的能力。在这个过程中,我们将会深入研究 Spring MVC 并会看到如何展现模型数据和处理表单输入。
知识点
-
在浏览器中展现模型数据
-
处理和校验表单输入
-
选择视图模板库
展现信息
从根本上来讲,Taco Cloud 是一个可以在线订购 taco 的地方。但是,除此之外,Taco Cloud 允许客户展现其创意,能够让他们通过丰富的配料(ingredient)设计自己的 taco。
因此,Taco Cloud 需要有一个页面为 taco 艺术家展现都可以选择哪些配料。可选的配料可能随时会发生变化,所以不能将它们硬编码到 HTML 页面中。我们应该从数据库中获取可用的配料并将其传递给页面,进而展现给客户。
在 Spring Web 应用中,获取和处理数据是控制器的任务,而将数据渲染到 HTML 中并在浏览器中展现则是视图的任务。为了支撑 taco 的创建页面,我们需要构建如下组件。
- 用来定义 taco 配料属性的领域类。
- 用来获取配料信息并将其传递至视图的 Spring MVC 控制器类。
- 用来在用户的浏览器中渲染配料列表的视图模板
这些组件之间的关系如下图所示。
因为本章主要关注 Spring 的 Web 框架,所以我们会将数据库相关的内容放到第 3 章中进行讲解。现在的控制器只负责向视图提供配料。在第 3 章中,我们会重新改造这个控制器,让它能够与 repository 协作,从数据库中获取配料数据。
在编写控制器和视图之前,我们首先确定一下用来表示配料的领域类型,它会为我们开发 Web 组件奠定基础。
构建领域类
应用的领域指的是它所要解决的主题范围:也就是会影响到对应用理解的理念和概念 [1]。
在 Tao Cloud 应用中,领域对象包括 taco 设计、组成这些设计的配料、顾客以及顾客所下的订单。作为开始,我们首先关注 taco 的配料。
在我们的领域中,taco 配料是非常简单的对象。每种配料都有一个名称和类型,以便于对其进行可视化的分类(蛋白质、奶酪、酱汁等)。每种配料还有一个 ID,这样的话对它的引用就能非常容易和明确。如下的 Ingredient 类定义了我们所需的领域对象。
在 src/main/java/tacos 目录下新建 Ingredient.java 文件,编写代码如下。
package tacos;
import lombok.Data;
import lombok.RequiredArgsConstructor;
@Data
@RequiredArgsConstructor
public class Ingredient {
private final String id;
private final String name;
private final Type type;
public static enum Type {
WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
}
}
我们可以看到,这是一个非常普通的 Java 领域类,它定义了描述配料所需的 3 个属性。在上面代码中,Ingredient 类最不寻常的一点是它似乎缺少了常见的 getter 和 setter 方法,以及 equals()、hashCode()、toString()等方法。
在代码中没有这些方法,部分原因是节省空间,此外还因为我们使用了名为 Lombok 的库(这是一个非常棒的库,它能够在运行时动态生成这些方法)。实际上,类级别的 @Data 注解就是由 Lombok 提供的,它会告诉 Lombok 生成所有缺失的方法,同时还会生成所有以 final 属性作为参数的构造器。通过使用 Lombok,我们能够让 Ingredient 的代码简洁明了。
然后在 src/main/java/tacos 包下创建 Taco.java 文件,编写代码如下。
package tacos;
import java.util.List;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import lombok.Data;
@Data
public class Taco {
private String name;
private List<String> ingredients;
}
在 src/main/java/tacos 包下创建 Order.java 文件,编写代码如下。
package tacos;
import javax.validation.constraints.Digits;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import org.hibernate.validator.constraints.CreditCardNumber;
import lombok.Data;
@Data
public class Order {
private String name;
private String street;
private String city;
private String state;
private String zip;
private String ccNumber;
private String ccExpiration;
private String ccCVV;
}
Lombok 并不是 Spring 库,但是它非常有用,我发现如果没有它,开发工作将很难开展。当我需要在书中将代码示例编写得短小简洁时,它简直成了我的救星。
要使用 Lombok,首先要将其作为依赖添加到项目中。在 pom.xml 中通过如下条目进行手动添加:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
这个依赖将会在开发阶段为你提供 Lombok 注解(例如 @Data),并且会在运行时进行自动化的方法生成。参见 Lombok 项目页面,以查阅如何在你所选择的 IDE 上安装 Lombok。
我相信你会发现 Lombok 非常有用,但你也要知道,它是可选的。在开发 Spring 应用的时候,它并不是必备的,所以如果你不想使用它的话,完全可以手动编写这些缺失的方法。当你完成之后,我们将会在应用中添加一些控制器,让它们来处理 Web 请求。
创建控制器类
在 Spring MVC 框架中,控制器是重要的参与者。它们的主要职责是处理 HTTP 请求,要么将请求传递给视图以便于渲染 HTML(浏览器展现),要么直接将数据写入响应体(RESTful)。在本章中,我们将会关注使用视图来为 Web 浏览器生成内容的控制器。在第 6 章中,我们将会看到如何以 REST API 的形式编写控制器来处理请求。
对于 Taco Cloud 应用来说,我们需要一个简单的控制器,它要完成如下功能。
- 处理路径为 /design 的 HTTP GET 请求。
- 构建配料的列表。
- 处理请求,并将配料数据传递给要渲染为 HTML 的视图模板,发送给发起请求的 Web 浏览器。
下面代码中的 DesignTacoController 类解决了这些需求。
在 src/main/java/tacos/web 包(没有则创建)下新建 DesignTacoController.java 文件,编写代码如下。
package tacos.web;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import javax.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
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 lombok.extern.slf4j.Slf4j;
import tacos.Taco;
import tacos.Ingredient;
import tacos.Ingredient.Type;
@Slf4j
@Controller
@RequestMapping("/design")
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));
}
}
@GetMapping
public String showDesignForm(Model model) {
model.addAttribute("design", new Taco());
return "design";
}
private List<Ingredient> filterByType(
List<Ingredient> ingredients, Type type) {
return ingredients
.stream()
.filter(x -> x.getType().equals(type))
.collect(Collectors.toList());
}
}
对于 DesignTacoController,我们先要注意在类级别所应用的注解。首先是 @Slf4j,这是 Lombok 所提供的注解,在运行时,它会在这个类中自动生成一个 SLF4J(Simple Logging Facade for Java)Logger。这个简单的注解和在类中通过如下代码显式声明的效果是一样的:
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 开头的请求。
处理 GET 请求
修饰 showDesignForm() 方法的 @GetMapping 注解对类级别的 @RequestMapping 进行了细化。 @GetMapping 结合类级别的 @RequestMapping,指明当接收到对 /design 的 HTTP GET 请求时,将会调用 showDesignForm() 来处理请求。
@GetMapping 是一个相对较新的注解,是在 Spring 4.3 引入的。在 Spring 4.3 之前,你可能需要使用方法级别的 @RequestMapping 注解作为替代:
@RequestMapping(method=RequestMethod.GET)
显然,@GetMapping 更加简洁,并且指明了它的目标 HTTP 方法。@GetMapping 只是诸多请求映射注解中的一个。下表列出了 Spring MVC 中所有可用的请求映射注解。
让正确的事情变得更容易
在为控制器方法声明请求映射时,越具体越好。这意味着至少要声明路径(或者从类级别的 @RequestMapping 继承一个路径)以及它所处理的 HTTP 方法。
但是更长的 @RequestMapping(method=RequestMethod.GET) 注解很容易让开发人员采取懒惰的方式,也就是忽略掉 method 属性。幸亏有了 Spring 4.3 的新注解,正确的事情变得更容易了,我们的输入变得更少了。
新的请求映射注解具有和 @RequestMapping 完全相同的属性,所以我们可以在使用 @RequestMapping 的任何地方使用它们。
通常,我喜欢只在类级别上使用 @RequestMapping,以便于指定基本路径。在每个处理器方法上,我会使用更具体的 @GetMapping、@PostMapping 等注解。
现在,我们已经知道 showDesignForm() 方法会处理请求,接下来我们看一下方法体,看它都做了些什么工作。这个方法构建了一个 Ingredient 对象的列表。现在,这个列表是硬编码的。当我们学习第 3 章的时候,会从数据库中获取可用 taco 配料并将其放到列表中。
配料列表准备就绪之后,showDesignForm() 方法接下来的几行代码会根据配料类型过滤列表。配料类型的列表会作为属性添加到 Model 对象上,这个对象是以参数的形式传递给 showDesignForm() 方法的。Model 对象负责在控制器和展现数据的视图之间传递数据。实际上,放到 Model 属性中的数据将会复制到 Servlet Response 的属性中,这样视图就能在这里找到它们了。showDesignForm() 方法最后返回 design,这是视图的逻辑名称,会用来将模型渲染到视图上。
我们的 DesignTacoController 已经具备雏形了。如果你现在运行应用并在浏览器上访问 /design 路径,DesignTacoController 的 showDesignForm() 将会被调用,它会从 repository 中获取数据并放到模型中,然后将请求传递给视图。但是,我们现在还没有定义视图,请求将会遇到很糟糕的问题,也就是 HTTP 404(Not Found)。为了解决这个问题,我们将注意力切换到视图上,在这里数据将会使用 HTML 进行装饰,以便于在用户的 Web 浏览器中进行展现。
设计视图
在控制器完成它的工作之后,现在就该视图登场了。Spring 提供了多种定义视图的方式,包括 JavaServer Pages(JSP)、Thymeleaf、FreeMarker、Mustache 和基于 Groovy 的模板。就现在来讲,我们会使用 Thymeleaf,这也是我们在第 1 章开启这个项目时的选择。
在运行时,Spring Boot 的自动配置功能会发现 Thymeleaf 在类路径中,因此会为 Spring MVC 创建支撑 Thymeleaf 视图的 bean。
像 Thymeleaf 这样的视图库在设计时是与特定的 Web 框架解耦的。这样的话,它们无法感知 Spring 的模型抽象,因此无法与控制器放到 Model 中的数据协同工作。但是,它们可以与 Servlet 的 request 属性协作。所以,在 Spring 将请求转移到视图之前,它会把模型数据复制到 request 属性中,Thymeleaf 和其他的视图模板方案就能访问到它们了。
Thymeleaf 模板就是增加一些额外元素属性的 HTML,这些属性能够指导模板如何渲染 request 数据。举例来说,如果有一个请求属性的 key 为 message,我们想要使用 Thymeleaf 将其渲染到一个 HTML 的
标签中,那么在 Thymeleaf 模板中我们可以这样写:
<p th:text="${message}">placeholder message</p>
当模板渲染成 HTML 的时候,
元素体将会被替换为 Servlet Request 中 key 为 message 的属性值。th:text 是 Thymeleaf 命名空间中的属性,它会执行这个替换过程。${} 会告诉它要使用某个请求属性(在本例中,也就是 message)中的值。
Thymeleaf 还提供了一个属性 th:each,它会迭代一个元素集合,为集合中的每个条目渲染 HTML。在我们设计视图展现模型中的配料列表时,这就非常便利了。举例来说,如果只想渲染 wrap 配料的列表,我们可以使用如下的 HTML 片段:
<h3>Designate your wrap:</h3>
<div th:each="ingredient : ${wrap}">
<input name="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:text
将 INGREDIENT 占位符文本替换为 ingredient 的 name 属性。
当用实际的模型数据进行渲染的时候,其中一个<div>
迭代的渲染结果可能会如下所示:
<div>
<input name="ingredients" type="checkbox" value="FLTO" />
<span>Flour Tortilla</span><br />
</div>
最终,上述的 Thymeleaf 片段会成为一大段 HTML 表单的一部分,我们 taco 艺术家用户会通过这个表单来提交其美味的作品。完整的 Thymeleaf 模板会包括所有的配料类型。
在 src/main/resources/templates/ 目录下创建 design.html 文件,代码如下。
<!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}"
th:action="@{/design}"
id="tacoForm"
>
<div class="grid">
<div class="ingredient-group" id="wraps">
<h3>Designate your wrap:</h3>
<div th:each="ingredient : ${wrap}">
<input
name="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
name="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
name="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
name="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
name="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"/>
<br />
<button>Submit your taco</button>
</div>
</form>
</body>
</html>
可以看到,我们会为各种类型的配料重复定义
值得注意的是,完整的模板包含了一个 Taco Cloud 的 logo 图片以及对样式表的 引用。样式的类容与我们的讨论无关,它只是包含了让配料两列显示的样式,避免出现一个很长的配料列表。
在 src/main/resources/static/ 目录下新建 styles.css,编写代码如下。
div.ingredient-group:nth-child(odd) {
float: left;
padding-right: 20px;
}
div.ingredient-group:nth-child(even) {
float: left;
padding-right: 0;
}
div.ingredient-group {
50%;
}
.grid:after {
content: '';
display: table;
clear: both;
}
*,
*:after,
*:before {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
span.validationError {
color: red;
}
在这两个场景中,都使用了 Thymeleaf 的 @{} 操作符,用来生成一个相对上下文的路径,以便于引用我们需要的静态制件(artifact)。正如我们在第 1 章中所学到的,在 Spring Boot 应用中,静态内容要放到根路径的 /static 目录下。
我们的控制器和视图已经完成了,现在我们可以将应用启动起来,看一下我们的劳动成果。
在实验楼 WebIDE 中执行以下命令运行程序。
# 进入项目根目录
cd /home/project/taco-cloud
# 运行程序
mvn clean spring-boot:run
在启动之后,打开 Web 服务,在地址末尾加上 /design 来进行访问。你将会看到如下图所示的一个页面。
给的源码中
但是有这句话 会报错
于是我给它删掉了
就可以了
知道原因了
这看上去非常不错!访问你站点的 taco 艺术家可以看到一个表单,这个表单中包含了各种 taco 配料,他们可以使用这些配料创建自己的杰作。但是当他们点击 Submit Your Taco 按钮的时候会发生什么呢?
我们的 DesignTacoController 还没有为接收创建 taco 的请求做好准备。如果提交设计表单,用户就会遇到一个错误(具体来讲,将会是一个 HTTP 405 错误:Request Method “POST” Not Supported)。如下图所示。
接下来,我们通过编写一些处理表单提交的控制器代码来修正这个错误。