Spring实战(第6版)
上QQ阅读APP看书,第一时间看更新

从根本上来讲,Taco Cloud是一个可以在线订购taco的地方。但是,除此之外,Taco Cloud允许客户展现其创意,能够让他们通过丰富的配料(ingredient)设计自己的taco。

因此,Taco Cloud需要有一个页面为taco艺术家展现可以选择的配料。可选的配料可能随时会发生变化,所以不能将它们硬编码到HTML页面中。我们应该从数据库中获取可用的配料并将其传递给页面,进而展现给客户。

在Spring Web应用中,获取和处理数据是控制器的任务,而将数据渲染到HTML中并在浏览器中展现是视图的任务。为了支撑taco的创建页面,我们需要构建如下的组件:

用来定义taco配料属性的领域类;

用来获取配料信息并将其传递至视图的Spring MVC控制器类;

用来在用户的浏览器中渲染配料列表的视图模板。

这些组件之间的关系如图2.1所示。

2-1

图2.1 典型的Spring MVC请求流

因为本章主要关注Spring的Web框架,所以我们会将数据库相关的内容放到第3章中进行讲解。现在的控制器只负责向视图提供配料。在第3章中,我们会重新改造这个控制器,让它能够与存储库协作,从数据库中获取配料数据。

在编写控制器和视图之前,我们首先确定用来表示配料的领域类型,它会为开发Web组件奠定基础。

应用的领域指的是它所要解决的主题范围,也就是会影响应用理解的理念和概念[1]。在Taco Cloud应用中,领域对象包括taco设计、组成这些设计的配料、顾客以及顾客所下的taco订单。图2.2展示了这些实体以及它们是如何关联到一起的。

2-2

图2.2 Taco Cloud的领域类

作为开始,我们首先关注taco的配料。在我们的领域中,taco配料是非常简单的对象。每种配料都有一个名称和类型,以便于对其进行可视化的分类(蛋白质、奶酪、酱汁等)。每种配料还有一个ID,这样的话对它的引用就能非常容易和明确。程序清单2.1所示的Ingredient类定义了我们所需的领域对象。

程序清单2.1 定义taco配料

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的时候,你必须在IDE中安装Lombok插件。否则,IDE将无从得知Lombok提供了getter、setter和其他方法,并且会因为缺失这些方法而报错。

许多流行的IDE都支持Lombok,包括Eclipse、Spring Tool Suite、IntelliJ IDEA和Visual Studio Code。请访问Project Lombok网站以了解如何在你的IDE中安装Lombok插件的更详细信息。

我相信你会发现Lombok非常有用,但你也需要知道,它是可选的。在开发Spring应用时,它并不是强制要使用的,所以你如果不想使用它,完全可以手动编写这些缺失的方法。你尽可以合上本书去这样做……我会在这里等你。

配料是taco的基本构成要素。为了解这些配料是如何组合在一起的,我们要定义Taco领域类,如程序清单2.2所示。

程序清单2.2 定义taco设计的领域对象

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

程序清单2.3  taco订单的领域对象

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类解决了这些需求。

程序清单2.4 初始的Spring控制器类

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中所有可用的请求映射注解。

表2.1  Spring MVC的请求映射注解

当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所示:

程序清单2.5 设计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>
      <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>引用[2]。在这两个场景中,都使用了Thymeleaf的@{}操作符,用来生成一个相对于上下文的路径,以便于引用我们需要的静态制品(artifact)。正如我们在第1章中所学到的,在Spring Boot应用中,静态内容要放到根类路径的“/static”目录下。

我们的控制器和视图已经完成了,现在我们可以将应用启动起来,看一下我们的劳动成果。运行Spring Boot应用有很多种方式。在第1章中,我为你展示了如何通过在Spring Boot Dashboard中点击Start按钮来运行应用。不管采用哪种方式启动Taco Cloud应用,在启动之后,都可以通过http://localhost:8080/design来进行访问。你将会看到类似于图2.3的页面。

2-3

图2.3 渲染之后的taco设计页面

这看上去非常不错!访问你站点的taco艺术家可以看到一个包含了各种taco配料的表单,他们可以使用这些配料创建自己的杰作。但是当他们点击Submit your taco按钮的时候会发生什么呢?

我们的DesignTacoController还没有为接收创建taco的请求做好准备。此时提交设计表单会遇到一个错误(具体来讲,是一个HTTP 405错误:Request Method “POST” Not Supported)。接下来,我们通过编写一些处理表单提交的控制器代码来修正这个错误。