处理表单提交
仔细看一下视图中的 <form> 标签,你将会发现它的 method 属性被设置成了 POST。除此之外,<form> 并没有声明 action 属性。这意味着当表单提交的时候,浏览器会收集表单中的所有数据,并以 HTTP POST 请求的形式将其发送至服务器端,发送路径与渲染表单的 GET 请求路径相同,也就是 “/design”。
因此,在该 POST 请求的接收端,我们需要有一个控制器处理方法。在 DesignTacoController 中,我们会编写一个新的处理器方法来处理针对 “/design” 的 POST 请求。
在程序清单2.4中,我们曾经使用 @GetMapping 注解声明 showDesignForm() 方法要处理针对 “/design” 的 HTTP GET 请求。与 @GetMapping 处理 GET 请求类似,我们可以使用 @PostMapping 来处理 POST 请求。为了处理 taco 设计的表单提交,在 DesignTacoController 中添加如程序清单2.6所述的 processTaco() 方法。
@PostMapping
public String processTaco(Taco taco,
@ModelAttribute TacoOrder tacoOrder) {
tacoOrder.addTaco(taco);
log.info("Processing taco: {}", taco);
return "redirect:/orders/current";
}
如 processTaco() 方法所示,@PostMapping 与类级别的 @RequestMapping 协作,指定 processTaco() 方法要处理针对 “/design” 的 POST 请求。我们所需要的正是以这种方式处理 taco 艺术家的表单提交。
表单提交时,表单中的输入域会绑定到 Taco 对象(这个类会在下面的程序清单中进行介绍)的属性中,该对象会以参数的形式传递给 processTaco()。从这里开始,processTaco() 就可以针对 Taco 对象采取任意想要的操作了。在本例中,它将 Taco 添加到了 TacoOrder 对象中(后者是以参数的形式传递到方法中来的),然后将 taco 以日志的形式打印出来。TacoOrder 参数上所使用的 @ModelAttribute 表明它应该使用模型中的 TacoOrder 对象,这个对象是我们在前面的程序清单2.4中借助带有 @ModelAttribute 注解的 order() 方法放到模型中的。
回过头来再看一下程序清单2.5中的表单,你会发现其中包含多个 checkbox 元素,它们的名字都是 ingredients,另外还有一个名为 name 的文本输入元素。表单中的这些输入域直接对应 Taco 类的 ingredients 和 name 属性。
表单中的 name 输入域只需要捕获一个简单的文本值。因此,Taco 的 name 属性是 String 类型的。配料的复选框也有文本值,但是用户可能会选择零个或多个,所以它们所绑定的 ingredients 属性是一个 List<Ingredient>,能够捕获选中的每种配料。
但是,稍等一下!如果配料的复选框是文本型(比如 String)的值,而 Taco 对象以 List<Ingredient> 的形式表示一个配料的列表,那么这里是不是存在不匹配的情况呢?像 ["FLTO", "GRBF", "LETC"] 这样的文本列表该如何绑定到一个 Ingredient 对象的列表上呢?要知道,Ingredient 是一个更丰富的类型,不仅包括 ID,还包括一个描述性的名字和配料类型。
这就是转换器(converter)的用武之地了。转换器是实现了 Spring 的 Converter 接口并实现了 convert() 方法的类,该方法会接收一个值并将其转换成另外一个值。要将 String 转换成 Ingredient,我们要用到如程序清单2.7所示的 IngredientByIdConverter。
package tacos.web;
import java.util.HashMap;
import java.util.Map;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;
import tacos.Ingredient;
import tacos.Ingredient.Type;
@Component
public class IngredientByIdConverter implements Converter<String, Ingredient> {
private Map<String, Ingredient> ingredientMap = new HashMap<>();
public IngredientByIdConverter() {
ingredientMap.put("FLTO",
new Ingredient("FLTO", "Flour Tortilla", Type.WRAP));
ingredientMap.put("COTO",
new Ingredient("COTO", "Corn Tortilla", Type.WRAP));
ingredientMap.put("GRBF",
new Ingredient("GRBF", "Ground Beef", Type.PROTEIN));
ingredientMap.put("CARN",
new Ingredient("CARN", "Carnitas", Type.PROTEIN));
ingredientMap.put("TMTO",
new Ingredient("TMTO", "Diced Tomatoes", Type.VEGGIES));
ingredientMap.put("LETC",
new Ingredient("LETC", "Lettuce", Type.VEGGIES));
ingredientMap.put("CHED",
new Ingredient("CHED", "Cheddar", Type.CHEESE));
ingredientMap.put("JACK",
new Ingredient("JACK", "Monterrey Jack", Type.CHEESE));
ingredientMap.put("SLSA",
new Ingredient("SLSA", "Salsa", Type.SAUCE));
ingredientMap.put("SRCR",
new Ingredient("SRCR", "Sour Cream", Type.SAUCE));
}
@Override
public Ingredient convert(String id) {
return ingredientMap.get(id);
}
}
因为我们现在还没有用来获取 Ingredient 对象的数据库,所以 IngredientByIdConverter 的构造器创建了一个 Map,其中键(key)是String 类型,代表了配料的 ID,值则是 Ingredient 对象。在第 3 章,我们会调整这个转换器,让它从数据库中获取配料数据,而不是像这样硬编码。convert() 方法只是简单地获取 String 类型的配料 ID,然后使用它去 Map 中查找 Ingredient。
注意,IngredientByIdConverter 使用了 @Component 注解,使其能够被 Spring 识别为 bean。Spring Boot 的自动配置功能会发现它和其他 Converter bean。它们会被自动注册到 Spring MVC 中,在请求参数与绑定属性需要转换时会用到。
现在,processTaco() 方法没有对 Taco 对象进行任何处理。它其实什么都没做。目前,这样是可以的。在第 3 章,我们会添加一些持久化的逻辑,从而将提交的 Taco 保存到数据库中。
与 showDesignForm() 方法类似,processTaco() 最后也返回了一个 String 类型的值。同样与 showDesignForm() 相似,返回的这个值代表了一个要展现给用户的视图。但是,区别在于 processTaco() 返回的值带有 “redirect:” 前缀,表明这是一个重定向视图。更具体地讲,它表明在 processDesign() 完成之后,用户的浏览器将会重定向到相对路径 “/order/current”。
这里的想法是:在创建完 taco 后,用户将会被重定向到一个订单表单页面,在这里,用户可以创建一个订单,将他们所创建的 taco 快递过去。但是,我们现在还没有处理 “/orders/current” 请求的控制器。
根据已经学到的关于 @Controller、@RequestMapping 和 @GetMapping 的知识,我们可以很容易地创建这样的控制器。它应该如程序清单2.8所示。
package tacos.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.bind.support.SessionStatus;
import lombok.extern.slf4j.Slf4j;
import tacos.TacoOrder;
@Slf4j
@Controller
@RequestMapping("/orders")
@SessionAttributes("tacoOrder")
public class OrderController {
@GetMapping("/current")
public String orderForm() {
return "orderForm";
}
}
在这里,我们再次使用 Lombok @Slf4j 注解在编译期创建一个 SLF4J Logger 对象。稍后,我们将会使用这个 Logger 记录所提交订单的详细信息。
类级别的 @RequestMapping 指明这个控制器的请求处理方法都会处理路径以 “/orders” 开头的请求。当与方法级别的 @GetMapping 注解结合之后,它就能够指定 orderForm() 方法会处理针对 “/orders/current” 的 HTTP GET 请求。
orderForm() 方法本身非常简单,只返回了一个名为 orderForm 的逻辑视图名。在第 3 章学习完如何将所创建的 taco 保存到数据库之后,我们将会重新回到这个方法并对其进行修改,用一个 Taco 对象的列表来填充模型并将其放到订单中。
orderForm 视图是由名为 orderForm.html 的 Thymeleaf 模板来提供的,如程序清单2.9所示。
<!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>
<form method = "POST" th:action = "@{/orders}" th:object = "${tacoOrder}">
<h1>Order your taco creations!</h1>
<img th:src = "@{/images/TacoCloud.png}"/>
<h3>Your tacos in this order:</h3>
<a th:href = "@{/design}" id = "another">Design another taco</a><br/>
<ul>
<li th:each = "taco : ${tacoOrder.tacos}">
<span th:text = "${taco.name}">taco name</span></li>
</ul>
<h3>Deliver my taco masterpieces to...</h3>
<label for = "deliveryName">Name: </label>
<input type = "text" th:field = "*{deliveryName}"/>
<br/>
<label for = "deliveryStreet">Street address: </label>
<input type = "text" th:field = "*{deliveryStreet}"/>
<br/>
<label for = "deliveryCity">City: </label>
<input type = "text" th:field = "*{deliveryCity}"/>
<br/>
<label for = "deliveryState">State: </label>
<input type = "text" th:field = "*{deliveryState}"/>
<br/>
<label for = "deliveryZip">Zip code: </label>
<input type = "text" th:field = "*{deliveryZip}"/>
<br/>
<h3>Here's how I'll pay...</h3>
<label for = "ccNumber">Credit Card #: </label>
<input type = "text" th:field = "*{ccNumber}"/>
<br/>
<label for = "ccExpiration">Expiration: </label>
<input type = "text" th:field = "*{ccExpiration}"/>
<br/>
<label for = "ccCVV">CVV: </label>
<input type = "text" th:field = "*{ccCVV}"/>
<br/>
<input type = "submit" value = "Submit Order"/>
</form>
</body>
</html>
很大程度上,orderForm.html 就是典型的 HTML/Thymeleaf 内容,不需要过多关注。它首先列出了添加到订单中的 taco。这里,使用了 Thymeleaf 的 th:each 来遍历订单的 tacos 属性以创建列表。然后渲染了订单的表单。
但是,需要注意一点,那就是这里的 <form> 标签和程序清单2.5中的 <form> 标签不同,指定了一个表单的 action。如果不指定 action,表单将会以 HTTP POST 的形式提交到与展现该表单相同的 URL 上。在这里,我们明确指明表单要 POST 提交到 “/orders” 上(使用 Thymeleaf 的 @{}
操作符指定相对上下文的路径)。
因此,我们需要在 OrderController 中添加另外一个方法以便于处理针对 “/orders” 的 POST 请求。我们在第 3 章才会对订单进行持久化,在此之前,我们让它尽可能简单,如程序清单2.10所示。
@PostMapping
public String processOrder(TacoOrder order,
SessionStatus sessionStatus) {
log.info("Order submitted: {}", order);
sessionStatus.setComplete();
return "redirect:/";
}
调用 processOrder() 方法处理所提交的订单时,我们会得到一个 Order 对象,它的属性绑定了所提交的表单域。TacoOrder 与 Taco 非常相似,是一个非常简单的类,其中包含了订单的信息。
在这个 processOrder() 方法中,我们只是以日志的方式记录了 TacoOrder 对象。在第 3 章,我们将会看到如何将其持久化到数据库中。但是,processOrder() 方法在完成之前,还调用了 SessionStatus 对象的 setComplete() 方法,这个 SessionStatus 对象是以参数的形式传递进来的。当用户创建他们的第一个 taco 时,TacoOrder 对象会被初始创建并放到会话中。通过调用 setComplete(),我们能够确保会话被清理掉,从而为用户在下次创建 taco 时为新的订单做好准备。
现在,我们已经开发了 OrderController 和订单表单的视图,接下来可以尝试运行一下。打开浏览器并访问 http://localhost:8080/design ,为 taco 选择一些配料,并点击 Submit your taco 按钮,从而看到如图2.4所示的表单。

填充表单的一些输入域并点击 Submit order 按钮。在这个过程中,请关注应用的日志来查看你的订单信息。在我尝试运行的时候,日志条目如下所示(为了适应页面的宽度,重新进行了格式化):
Order submitted: TacoOrder(deliveryName = Craig Walls, deliveryStreet = 1234 7th
Street, deliveryCity = Somewhere, deliveryState = Who knows?,
deliveryZip = zipzap, ccNumber = Who can guess?, ccExpiration = Some day,
ccCVV = See-vee-vee, tacos = [Taco(name = Awesome Sauce, ingredients = [
Ingredient(id = FLTO, name = Flour Tortilla, type = WRAP), Ingredient(id = GRBF,
name = Ground Beef, type = PROTEIN), Ingredient(id = CHED, name = Cheddar,
type = CHEESE), Ingredient(id = TMTO, name = Diced Tomatoes, type = VEGGIES),
Ingredient(id = SLSA, name = Salsa, type = SAUCE), Ingredient(id = SRCR,
name = Sour Cream, type = SAUCE)]), Taco(name = Quesoriffic, ingredients =
[Ingredient(id = FLTO, name = Flour Tortilla, type = WRAP), Ingredient(id = CHED,
name = Cheddar, type = CHEESE), Ingredient(id = JACK, name = Monterrey Jack,
type = CHEESE), Ingredient(id = TMTO, name = Diced Tomatoes, type = VEGGIES),
Ingredient(id = SRCR,name = Sour Cream, type = SAUCE)])])
似乎 processOrder() 完成了它的任务,通过日志记录订单详情来完成表单提交的处理。但是,如果仔细查看上述测试订单的日志,会发现它让一些 “坏信息” 混了进来。表单中的大多数输入域包含的可能都是不正确的数据。我们接下来添加一些校验,确保所提交的数据至少与所需的信息比较相似。