1. 程式人生 > 實用技巧 >開發 Web 應用(一)

開發 Web 應用(一)

實驗介紹

第一印象是非常重要的: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>

可以看到,我們會為各種型別的配料重複定義

片段。另外,我們還包含了一個 Submit 按鈕,以及使用者用來定義其作品名稱的輸入域。

值得注意的是,完整的模板包含了一個 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 {
  width: 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)。如下圖所示。

接下來,我們通過編寫一些處理表單提交的控制器程式碼來修正這個錯誤。