仔细看一下视图中的<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()方法。

程序清单2.6 使用@PostMapping来处理POST请求

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

程序清单2.7 将String转换为Ingredient

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所示。

程序清单2.8 展现taco订单表单的控制器

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所示。

程序清单2.9  taco订单的表单视图

<!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所示。

程序清单2.10 处理taco订单的提交

@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所示的表单。

2-4

图2.4 taco订单的表单

填充表单的一些输入域并点击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()完成了它的任务,通过日志记录订单详情来完成表单提交的处理。但是,如果仔细查看上述测试订单的日志,会发现它让一些“坏信息”混了进来。表单中的大多数输入域包含的可能都是不正确的数据。我们接下来添加一些校验,确保所提交的数据至少与所需的信息比较相似。