Thymeleaf 表单
在接下来的课程中,我们逐步完成一个简易版的图书管理系统进而更进一步的学习和掌握 Spring 以及 Thymeleaf 框架
首先我们从添加图书开始
图书模型
抽象成java类
上面的UML写成java代码:
public class Book{
// 主键
private long id;
// 图书的名称
private String name;
// 图书的作者
private String author;
// 图书的描述
private String desc;
// 图书的编号
private String isbn;
// 图书的价格
private double price;
// 图书的封面图片
private String pictureUrl;
// 省略 getter、setter
}
有了这些信息我们就可以通过 Java 对象来描述一本书了
细心的你可能会发现在 Book 类中,我们把 id 主键的类型设置为long 了,这是因为 long 类型的 id 更易于搜索引擎。我们如果期望被搜索引擎能够关注到产品,那么就可以把id 设置为 long,否则还是用 String,因为 long 很容易被机器猜到,所以很容易被爬取数据(既是优点又是缺点,由产品特性来决定)
页面开发
首先,我们先创建一个名为 addBook.html
的 thymeleaf 模板
<form>
<div>
<label>书的名称:</label>
<input type="text" />
</div>
<div>
<label>书的作者:</label>
<input type="text" />
</div>
<div>
<label>书的描述:</label>
<textarea></textarea>
</div>
<div>
<label>书的编号:</label>
<input type="text" />
</div>
<div>
<label>书的价格:</label>
<input type="text" />
</div>
<div>
<label>书的封面:</label>
<input type="text" />
</div>
<div>
<button type="submit">注册</button>
</div>
</form>
为了能够访问到这个页面,我们还需要配置一下 Spring Control
package com.bookstore.control;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
@Controller
public class BookControl {
// 当页面访问 http://localhost:8080/book/add.html 时
// 渲染 addBook.html 模板
@GetMapping("/book/add.html")
public String addBookHtml(Model model){
return "addBook";
}
}
保存书籍
上面我们开发了一个简单的表单页面,现在我们在新增一个 control 来处理书籍保存的逻辑。
package com.bookstore.control;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import com.bookstore.model.*;
@Controller
public class BookControl {
//缓存所有书籍数据
private static List<Book> books = new ArrayList<>();
@GetMapping("/book/add.html")
public String addBookHtml(Model model){
return "addBook";
}
@PostMapping("/book/save")
public String saveBook(Book book){
books.add(book);
return "saveBookSuccess";
}
}
@PostMapping 和 @GetMapping 不同点在于只接收 http method 为 post 请求的数据,它的包路径和 GetMapping 注解类一样
我们在这个 saveBook 方法里做了一个简单的处理,接收 Book 对象数据并存储到 books 对象里
我们还新增一个 templates/saveBookSuccess.html 文件
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>添加书籍</title>
</head>
<body>
<h2>添加书籍成功</h2>
</body>
</html>
form 表单
我们还需要修改一下 html form,需要指定 form 的 action 属性值就是后端的请求路径,由于我们写的是/
开头的,浏览器会自动把请求地址识别为http://domain/user/reg
,如果本地开发这个 domain 可能是 localhost:8080
一般情况下,我们都会把 Html 表单的 method 设置为 post,这样可以保证数据传输安全,这样 Spring Mvc 就需要接收 Post 请求,现在我们完善这个后端代码
除了 form 属性调整,还需要修改 input 的 name 属性,属性和 Book 类的属性名要一致哦
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>添加书籍</title>
</head>
<body>
<h2>添加书籍</h2>
<form action="/book/save" method="POST">
<div>
<label>书的名称:</label>
<input type="text" name="name">
</div>
<div>
<label>书的作者:</label>
<input type="text" name="author">
</div>
<div>
<label>书的描述:</label>
<textarea name="desc"></textarea>
</div>
<div>
<label>书的编号:</label>
<input type="text" name="isbn">
</div>
<div>
<label>书的价格:</label>
<input type="text" name="price">
</div>
<div>
<label>书的封面:</label>
<input type="text" name="pictureUrl">
</div>
<div>
<button type="submit">注册</button>
</div>
</form>
</body>
</html>
Spring Validation
现在我们继续完善了书籍的添加逻辑,在实际的工作中对于数据的保存是离不开数据验证的,比如说 name 必须要输入,isbn 必须要输入等等校验规则,Spring 对于数据验证支持的也非常好,我们可以借助 Spring Validation 来处理表单数据的验证
JSR 380
JSR是Java Specification Requests的缩写,意思是Java 规范提案。是指向JCP(Java Community Process)提出新增一个标准化技术规范的正式请求。任何人都可以提交JSR,以向Java平台增添新的API和服务。JSR已成为Java界的一个重要标准。
基本上我们用到的很多 Java 框架都是某个 JSR 的实现者,大家现在有个概念就好。当然等你技术有一定深度的时候,你可以尝试去阅读、理解 JSR 规范
JSR 380 其实就是 Bean Validation 2.0 ,这个就是 Bean 验证的规范,这里的Bean 就是我们一直在说的实例化后的 POJO类,比如前面的 Book。JSR 380 提案的规范可以通过下面的依赖添加到你的工程里
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Spring Validation 也是 JSR 380 提案的一个实现方案。
随着 SpringBoot 版本的不断更新,有的版本自动加入了这两个 Spring Validation 的依赖包、不需要额外配置;而有的版本 需要手动 在项目的 pom.xml 里添加依赖。
所以不用纠结,如果项目缺少依赖(javax.validation.constraints 包找不到而报错)的时候,就自己手动加一下。
Validation 注解
JSR 380 定义了一些注解用于做数据校验,这些注解可以直接设置在 Bean 的属性上
-
@NotNull
不允许为 null 对象
-
@AssertTrue
是否为 true
-
@Size
约定字符串的长度
-
@Min
字符串的最小长度
-
@Max
字符串的最大长度
-
@Email
是否是邮箱格式
-
@NotEmpty
不允许为null或者为空,可以用于判断字符串、集合,比如 Map、数组、List
-
@NotBlank
不允许为 null 和 空格
我们用一个例子来举例用法
package com.bookstore.model;
import javax.validation.constraints.*;
public class User {
@NotEmpty(message = "名称不能为 null")
private String name;
@Min(value = 18, message = "你的年龄必须大于等于18岁")
@Max(value = 150, message = "你的年龄必须小于等于150岁")
private int age;
@NotEmpty(message = "邮箱必须输入")
@Email(message = "邮箱不正确")
private String email;
// standard setters and getters
}
大多数情况下,我们建议使用 NotEmpty 替代 NotNull、NotBlank
校验的注解是可以累加的,如上面的 @Min 和 @Max,系统会按顺序执行校验,任何一条校验触发就会抛出校验错误到上下文中
我们继续完善这个例子,步骤还是有点多的,需要仔细阅读哦
创建一个表单页
创建一个 user/addUser.html
模板文件,用于管理员添加用户
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>添加用户</title>
</head>
<body>
<h2>添加用户</h2>
<form action="/user/save" method="POST">
<div>
<label>用户名称:</label>
<input type="text" name="name">
</div>
<div>
<label>年龄:</label>
<input type="text" name="age">
</div>
<div>
<label>邮箱:</label>
<input type="text" name="email">
</div>
<div>
<button type="submit">保存</button>
</div>
</form>
</body>
</html>
mapping
我们还需要在 control
类里设置 mapping
import javax.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.validation.BindingResult;
import com.bookstore.model.*;
@Controller
public class UserControl {
@GetMapping("/user/add.html")
public String addUser() {
return "user/addUser";
}
}
如果你启动工程后,现在应该可以输入 http://localhost:8080/user/add.html
打开页面了,我们运行一下看看
执行校验
前面 addUser.html
页面中的 form action
值配置的是 /user/save
,所以我们增加一下这个请求的 Control
代码
就可以执行校验了,在 Spring MVC当中执行校验非常简单
package com.bookstore.control;
import com.bookstore.model.User;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import javax.validation.Valid;
@Controller
public class UserControl {
@GetMapping("/user/add.html")
public String addUser() {
return "user/addUser";
}
@PostMapping("/user/save")
public String saveUser(@Valid User user, BindingResult errors) {
if (errors.hasErrors()) {
// 如果校验不通过,返回用户编辑页面
return "user/addUser";
}
// 校验通过,返回成功页面
return "user/addUserSuccess";
}
}
大家仔细看 saveUser 这个方法的参数,在第一个参数 user 那,我们添加了参数注解 @Valid,然后我们新增了第二个参数 errors(它的类型是 BindingResult),顺序不要写错哦
- @Valid 完整的包路径是
javax.validation.Valid;
注意:需要jdk11才行- BindingResult 类的路径是
org.springframework.validation.BindingResult
BindingResult 对象的 hasErrors 方法可以用于判断校验成功还是失败
- 如果失败,回到添加用户的页面
- 如果成功,显示成功页面
addUserSuccess.html
创建一个 user/addUserSuccess.html
模板文件
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>添加用户</title>
</head>
<body>
<h2>添加用户成功</h2>
</body>
</html>
在上面我们成功的运用了 Spring Validation 进行数据的验证,但是大家有没有发现一个问题,就是如果出错了,你并不知道错误的原因是什么
这样的话,我们就有两个关键点要解决
- 如何传递数据?
- 如何显示具体的字段错误?
Control 改造
@GetMapping("/user/add.html")
public String addUser(Model model) {
User user = new User();
model.addAttribute("user",user);
return "user/addUser";
}
如上面的代码,我们需要先创建一个 User
实例并传递到模板中去。
user/add.html 改造
在 user/add.html
模板当中,我们得去处理错误的状态,增加错误的样式和文案
th:object
为了让表单验证状态生效,你还需要在 form
标签里增加一个 th:object="${user}"
属性
th:object
用于替换对象,使用了这个就不需要每次都编写user.xxx
,可以直接操作xxx
了
完整的如下
<form action="/user/save" th:object="${user}" method="POST">
...
</form>
th:classappend
关于错误提示这个信息,我们还需要再分解一下。如果想显示错误的状态,我们就得定义一个错误的 css class
,比如
.error {
color: red;
}
如果某个 html 标签拥有这个 error
class 那么就会出现红色的字体,说明错误啦
如果设计师有明确错误的样式,可以跟随设计稿来开发,这里只是举例
现在我们就需要动态的管理表单的样式,如果有错误就再该标签上增加这个 class
,否则不处理
你可以使用 th:classappend
这个语法,支持我们动态的管理样式
<div th:classappend="${#fields.hasErrors('name')} ? 'error' : ''">
</div>
如果错误信息里有 name 字段,上面的代码会生成
<div class="error">
</div>
注意 ${#fields.hasErrors('key')}
这个语法是专门为验证场景提供的,这里的 key
就是对象的属性名称,比如 User 对象的 name、age、email 等
th:errors
如果还想要显示出错误信息,你可以使用 th:errors="*{age}"
属性,这个会自动取出错误信息来,完整的代码如下
<p th:if="${#fields.hasErrors('age')}" th:errors="*{age}"></p>
注意这里的
*
由于我们在 form 中使用了th:object="${user}"
,所以我们就可以通过*{age}
来获取具体的属性值
th:field
一般错误的时候,我们还希望显示上一次输入的内容,所以你可以使用 th:field 语法
<div th:classappend="${#fields.hasErrors('age')} ? 'error' : ''">
<label>年龄:</label>
<input type="text" th:field="*{age}" />
<p th:if="${#fields.hasErrors('age')}" th:errors="*{age}"></p>
</div>
如果你想显示其他的字段,我们可以参考上面的代码
<div th:classappend="${#fields.hasErrors('name')} ? 'error' : ''">
<label>用户名称:</label>
<input type="text" th:field="*{name}">
<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></p>
</div>
Thymeleaf 布局(Layout)
大多数的网站都有导航、底部等公共的东西,在一个网站里访问页面总是会显示相同的导航、底部之类的内容
这个时候,就需要借助布局(layout)知识了,注意我们这里说的布局并不是指的 css 布局哦,而是说网站的页面架构
layout 解决的是模板复用的问题,比如常见的网站是下面这样的
layout.html
继续完善bookstore,创建layout.html
模板
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>布局</title>
<style>
.header {background-color: #f5f5f5;padding: 20px;}
.header a {padding: 0 20px;}
.container {padding: 20px;margin:20px auto;}
.footer {height: 40px;background-color: #f5f5f5;border-top: 1px solid #ddd;padding: 20px;}
</style>
</head>
<body>
<header class="header">
<div>
<a href="/book/list.html">图书管理</a>
<a href="/user/list.html">用户管理</a>
</div>
</header>
<div class="container" th:include="::content">页面正文内容</div>
<footer class="footer">
<div>
<p style="float: left">© oageux.com 2017</p>
<p style="float: right">
Powered by oageux
</p>
</div>
</footer>
</body>
如上的代码,这个layout页面中,我们添加了 header、container、footer
三个节点。重点在 container 这个节点上,我们使用了一个 th:include="::content"
语法
th:include=“::content”**
这个语法还是要理解一下,::content
指的是选择器,这个选择器指的就是加载当前页面的 th:fragment
的值。
当页面渲染的时候,布局会合并 content 这个 fragment 内容一起渲染,下面我们会配置 fragment
user/list.html
现在我们继续改造一下 user/list.html ,让这个页面支持布局,如下:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="layout">
<div th:fragment="content">
<h2>用户列表</h2>
<div>
<a href="/user/add.html">添加用户</a>
</div>
<table>
<thead>
<tr>
<th>
用户名称
</th>
<th>
用户年龄
</th>
<th>
用户邮箱
</th>
</tr>
</thead>
<tbody>
<tr th:each="user : ${users}">
<td th:text="${user.name}"></td>
<td th:text="${user.age}"></td>
<td th:text="${user.email}"></td>
</tr>
</tbody>
</table>
</div>
</html>
th:replace=“layout”
这里指定了布局的名称,这个一旦声明后,页面会被替换成 layout 的内容,记住不要指定布局名称错误哦,这个"layout"
指的是 templates/layout.html
th:fragment=“content”
<div th:fragment="content">
</div>
fragment是片段的意思,当页面渲染的时候,可以通过选择器指定使用这个片段。在上面 layout.html 文件的 th:include="::content"
指定的就是这个值
现在我们可以运行一下访问 /user/list.html
页面看看
代码演示
你会发现页面渲染成功啦,有头有尾,也有内容,如果你查看页面源码你会发现如下
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>布局</title>
<style>
.header {background-color: #f5f5f5;padding: 20px;}
.header a {padding: 0 20px;}
.container {padding: 20px;margin:20px auto;}
.footer {height: 40px;background-color: #f5f5f5;border-top: 1px solid #ddd;padding: 20px;}
</style>
</head>
<body>
<header class="header">
<div>
<a href="/book/list.html">图书管理</a>
<a href="/user/list.html">用户管理</a>
</div>
</header>
<div class="container">
<h2>用户列表</h2>
<div>
<a href="/user/add.html">添加用户</a>
</div>
<table>
<thead>
<tr>
<th>
用户名称
</th>
<th>
用户年龄
</th>
<th>
用户邮箱
</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<footer class="footer">
<div>
<p style="float: left">© youkeda.com 2017</p>
<p style="float: right">
Powered by oageux
</p>
</div>
</footer>
</body>
</html>
仔细对比一下,确实 layout.html 和 user/list.html 页面合并渲染了