实现过程
第二章其实并没有涉及到数据库,而是写了几个类,然后硬是new出来一些数据,写到页面中供选择。 主要的内容其实是Spring MVC的内容。 SIA5并没有区分Spring Framework和组件,而是直接就盯住Spring Boot讲起,从字面上不区分Spring 和Spring Boot,这是第四版相比第五版的一大改变。构建数据库
表结构和简单的数据如下:
创建数据结构,然后录入一些基础的原料及类别如下:
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for ingredient
-- ----------------------------
DROP TABLE IF EXISTS `ingredient`;
CREATE TABLE `ingredient` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL,
`type` varchar(50) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of ingredient
-- ----------------------------
INSERT INTO `ingredient` VALUES ('1', 'Flour Tortilla', 'WRAP');
INSERT INTO `ingredient` VALUES ('2', 'Corn Tortilla', 'WRAP');
INSERT INTO `ingredient` VALUES ('3', 'Ground Beef', 'PROTEIN');
INSERT INTO `ingredient` VALUES ('4', 'Carnitas', 'PROTEIN');
INSERT INTO `ingredient` VALUES ('5', 'Diced Tomatoes', 'VEGGIES');
INSERT INTO `ingredient` VALUES ('6', 'Lettuce', 'VEGGIES');
INSERT INTO `ingredient` VALUES ('7', 'Cheddar', 'CHEESE');
INSERT INTO `ingredient` VALUES ('8', 'Monterrey Jack', 'CHEESE');
INSERT INTO `ingredient` VALUES ('9', 'Salsa', 'SAUCE');
INSERT INTO `ingredient` VALUES ('10', 'Sour Cream', 'SAUCE');
-- ----------------------------
-- Table structure for order
-- ----------------------------
DROP TABLE IF EXISTS `orders`;
CREATE TABLE `orders` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`delivery_name` varchar(50) NOT NULL,
`delivery_street` varchar(50) NOT NULL,
`delivery_city` varchar(50) NOT NULL,
`delivery_state` varchar(2) NOT NULL,
`delivery_zip` varchar(10) NOT NULL,
`cc_number` varchar(16) NOT NULL,
`cc_expiration` varchar(5) NOT NULL,
`cc_cvv` varchar(3) NOT NULL,
`placed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of order
-- ----------------------------
-- ----------------------------
-- Table structure for taco
-- ----------------------------
DROP TABLE IF EXISTS `taco`;
CREATE TABLE `taco` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of taco
-- ----------------------------
-- ----------------------------
-- Table structure for taco_ingredient
-- ----------------------------
DROP TABLE IF EXISTS `taco_ingredient`;
CREATE TABLE `taco_ingredient` (
`taco_id` int(11) NOT NULL ,
`ingredient_id` int(11) NOT NULL,
KEY `taco_id_fk` (`taco_id`),
KEY `ingre_id_fk` (`ingredient_id`),
CONSTRAINT `ingre_id_fk` FOREIGN KEY (`ingredient_id`) REFERENCES `ingredient` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `taco_id_fk` FOREIGN KEY (`taco_id`) REFERENCES `taco` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of taco_ingredients
-- ----------------------------
-- ----------------------------
-- Table structure for taco_order
-- ----------------------------
DROP TABLE IF EXISTS `taco_order`;
CREATE TABLE `taco_order` (
`order_id` int(11) NOT NULL,
`taco_id` int(11) NOT NULL,
KEY `taco_order_fk` (`taco_id`),
KEY `taco_order_order_fk` (`order_id`),
CONSTRAINT `taco_order_fk` FOREIGN KEY (`taco_id`) REFERENCES `taco` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `taco_order_order_fk` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of taco_order
-- ----------------------------
这里没有像原书一样使用四个英文字母当做ID,还是使用了标准的ID。
创建Ingredient类和对应的展示页面
数据类都使用了lombok库,非常方便的库。这里是一个简略的说明。 之后写Entity,基本上加上@Data和@NoArgsConstructor就可以了,然后可以将变量设置为final,只设置一次值就OK了。 现在的第一个问题是,Ingredient类如果里边设置了ENUM类,能成功映射吗。经过试验,不能能直接映射String类型的Type到Enum类。 这里DAO直接通过JPARepository接口来实现,除了默认的findAll()方法之外,自行添加了一个返回List<String>的方法,用于返回所有Ingredient的类型。 Entity如下:package cc.conyli.sia5.entity;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import javax.persistence.*;
@Data
@RequiredArgsConstructor
@NoArgsConstructor(force = true, access = AccessLevel.PRIVATE)
@Entity
@Table(name = "ingredient")
public class Ingredient {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private final int id;
@Column(name = "name")
private final String name;
@Column(name = "type")
private final String type;
}
虽然使用了final,但是不影响取值,今后的Entity基本都这么写。
然后需要来编写模板文件。
这里需要注意的就是Thymeleaf中的@{/css/style.css}中的第一个/不能不写,否则会找不到地址。这个错误经常犯。
Thymeleaf模板的逻辑应该是先列出来所有的类型,再在所有的类型下边列出对应的内容。
为了达成这个目标,使用神奇的Spring Data 接口,编写了一个方法:
List<Ingredient> getIngredientsByType(String type);这中间IDE会自动提示,然后就可以使用了,会自动解析,超级神奇。 有了这个东西,外加取得的
List<String>类型的列表,就可以给Model传入对应的内容了。
在编写页面的时候,原书就直接写死了几种类型,这里利用Service层对数据进行了处理,封装成一个Map对象,传给页面,就实现了动态的生成原料页面。
Service的方法如下:
@Override
public Map<String, List<Ingredient>> getIngredientsAndTypeMap() {
Map<String, List<Ingredient>> ingredientsByTypeMap = new HashMap<>();
List<String> types = getTypes();
for (String type : types) {
ingredientsByTypeMap.put(type, getIngredientsByType(type));
}
return ingredientsByTypeMap;
}
页面里使用嵌套循环,传给页面的变量名字叫mapper:
<div class="container">
<h2 class="text-center">选择食材</h2>
<form th:action="@{/thyme/process}" action="#" method="post">
<div th:each="list: ${mapper}">
<h3 class="text-left" th:text="${list.key}"></h3>
<div class="form-check" th:each="ingredient: ${list.value}">
<input class="form-check-input" type="checkbox" th:value="${ingredient.id}" name="ingredients"
th:id="${ingredient.type}+${ingredient.id}">
<label class="form-check-label" th:text="${ingredient.name}"
th:for="${ingredient.type}+${ingredient.id}"></label>
</div>
<hr>
</div>
<input type="text" placeholder="mytaco">
<button type="submit">提交</button>
</form>
</div>
创建Taco类和处理表单
原书这一块的业务逻辑是,表单POST到一个地址,生成一个Taco对象,里边包含着所有的原料,ingredient属性里是一个id的列表,用一个List取出来。 为此需要创建Taco对象,要对应数据库,同时和Ingredient是多对多关系。这里就直接使用JPA和Hibernate相关的技术了。 一般我们从有多对多字段的地方查询比较好,由于原料是比较基础的,仅作为提供数据只用,所以把多对多字段放在Taco类里。这里自己发现了一个坑。一开始将taco表的创建时间列名设置为 createdAt,在taco类里也写了@Column(name = "createdAt"),结果Hibernate自动去找叫做created_at的列,找不到。
深入一下研究,发现这是Spring JPA解析列名的设置,配置文件里可以设置解析方式,有两种:spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl spring.jpa.hibernate.naming.physical-strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy上边一种是不对列名进行任何处理,而下边就是Spring的,会自动将@Column中的大写部分拆开成两个单词以下划线拼接。 如果不进行任何修改,配置成上边这样,就OK了,然而以后还是得注意mysql的命名规范。 创建好的Taco类如下:
package cc.conyli.sia5.entity;
import lombok.Data;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@Data
@Entity
@Table(name = "taco")
public class Taco {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(name = "name")
private String name;
@Column(name = "created_at")
private Date createdAt;
@ManyToMany(cascade = {CascadeType.DETACH, CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH})
@JoinTable(name = "taco_ingredient", joinColumns = @JoinColumn(name = "taco_id"), inverseJoinColumns = @JoinColumn(name = "ingredient_id"))
private List<Ingredient> ingredients;
@PrePersist
void createdAt() {
this.createdAt = new Date();
}
//添加一个方法用于给自己的外键添加关联对象,这个实际上用不到
//在控制器绑定了表单的时候,Taco对象的外键属性里会直接由Spring Data JPA装上查询好以后的对象,可以直接保存。
public void addIngredient(Ingredient ingredient) {
if (ingredients == null) {
ingredients = new ArrayList<>();
}
ingredients.add(ingredient);
}
}
之后是要创建控制器,service和dao,这里的DAO依然直接继承神奇接口,service现在只需要编写一个save方法,控制器现在只需要编写一个处理表单然后保存的方法。service层和DAO层的代码就省略了。
看一下Taco控制器,就一个方法,处理表单然后保存。
package cc.conyli.sia5.controller;
import cc.conyli.sia5.entity.Taco;
import cc.conyli.sia5.service.TacoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/taco")
public class TacoController {
private TacoService tacoService;
@Autowired
public TacoController(TacoService tacoService) {
this.tacoService = tacoService;
}
@PostMapping("/process")
public String saveTaco(@ModelAttribute("taco") Taco taco) {
System.out.println(taco);
tacoService.save(taco);
System.out.println(taco);
return "redirect:/";
}
}
之后还需要做一步,就是给表单绑定对象,由于之前展示表单的时候还没有绑定Taco对象,这里需要绑定,还要一并修改下边的name对应的input:
<form th:action="@{/taco/process}" action="#" method="post" th:object="${taco}">
<input type="text" th:field="*{name}" placeholder="mytaco">
这样我们就完成了选择食材--提交表单--保存Taco与食材的对应关系的功能。
Order相关处理
然后可以接下来实现下一步功能了,就是生成一个订单,里边装着这个Taco,提交订单之后保存到数据库,在订单页面可以选择再添加一个Taco,最后把订单对应的taco全部保存到其中去。 原书的实现方法,是在生成原料列表的控制器里使用了一个@SessionAttributes("order"),然后有一个方法:
@ModelAttribute(name = "order")
public Order order() {
return new Order();
}
不管如何,先把Order类,还有提交食材的时候自动跳转填写Order类的工作准备好。
Order类如下,这里一开始犯了一个错误,就是把表名起成了MySQL的保留字order,导致一直出错,后来改成了orders就可以了:
package cc.conyli.sia5.entity;
import lombok.Data;
import javax.persistence.*;
import java.util.Date;
import java.util.List;
@Data
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(name = "delivery_name")
private String delivery_name;
@Column(name = "delivery_street")
private String delivery_street;
@Column(name = "delivery_city")
private String delivery_city;
@Column(name = "delivery_state")
private String delivery_state;
@Column(name = "delivery_zip")
private String delivery_zip;
@Column(name = "cc_number")
private String cc_number;
@Column(name = "cc_expiration")
private String cc_expiration;
@Column(name = "cc_cvv")
private String cc_cvv;
@Column(name = "placed_at")
private Date placed_at;
@ManyToMany(cascade = {CascadeType.DETACH, CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH})
@JoinTable(name = "taco_order", joinColumns = @JoinColumn(name = "order_id"), inverseJoinColumns = @JoinColumn(name = "taco_id"))
private List<Taco> tacos;
@PrePersist
private void placedAt() {
this.placed_at = new Date();
}
}
然后是表单页面,写得比较死,绑定由model传进来的order空白order对象:
<div class="container">
<form action="#" th:action="@{/order/process}" th:object="${order}" method="post">
<h3 class="text-center">提交订单</h3>
<div class="form-group">
<label>delivery_name
<input class="form-control" type="text" th:field="*{delivery_name}" placeholder="...">
</label>
</div>
<div class="form-group">
<label>delivery_street
<input class="form-control" type="text" th:field="*{delivery_street}" placeholder="...">
</label>
</div>
<div class="form-group">
<label>delivery_city
<input class="form-control" type="text" th:field="*{delivery_city}" placeholder="...">
</label>
</div>
<div class="form-group">
<label>delivery_state
<input class="form-control" type="text" th:field="*{delivery_state}" placeholder="...">
</label>
</div>
<div class="form-group">
<label>delivery_zip
<input class="form-control" type="text" th:field="*{delivery_zip}" placeholder="...">
</label>
</div>
<div class="form-group">
<label>卡号
<input class="form-control" type="text" th:field="*{cc_number}" placeholder="...">
</label>
</div>
<div class="form-group">
<label>过期日
<input class="form-control" type="text" th:field="*{cc_expiration}" placeholder="...">
</label>
</div>
<div class="form-group">
<label>CVV2码
<input class="form-control" type="text" th:field="*{cc_cvv}" placeholder="...">
</label>
</div>
<a th:href="@{/ingredients}" class="btn btn-primary">给订单添加新Taco</a>
<button type="submit" class="btn btn-primary">提交</button>
</form>
</div>
最后是简单的控制器:
package cc.conyli.sia5.controller;
import cc.conyli.sia5.dao.OrderRepository;
import cc.conyli.sia5.entity.Order;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
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;
@Slf4j
@Controller
@RequestMapping("/order")
public class OrderController {
private OrderRepository orderRepository;
@Autowired
OrderController(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@GetMapping("/form")
public String showOrderForm(Model model) {
model.addAttribute("order", new Order());
return "order";
}
@PostMapping("/process")
public String processOrder(@ModelAttribute("order") Order order) {
orderRepository.save(order);
log.info("保存的order是:" + order);
return "redirect:/";
}
}
现在我们的order和taco没有任何关联,需要实现SIA5里边让taco和order关联起来,由于已经跨越了一个request,显然不能使用model来传递数据,就需要使用@SessionAttributes注解了
如果要查看H2数据的内存数据库,使用jdbc:h2:mem:testdb
这里还需要注意的是,如果Order多对多列表里的内容已经被先存储,而不是取出一个已经存在的数据,或者是尚未存储的数据,需要去掉多对多字段的:CascadeType.PERSIST。
@SessionAttributes 与 @ModelAttributes
经过一晚上的实验和查找资料,加上找到了这篇文章讲的Spring MVC的几个注解(是不是Spring in action 4也要看看),还有这里,终于搞清楚了SIA5的机制,然后自己也可以实现了。 SIA5的核心在于类上的注解:@SessionAttributes("order")
和这个方法:
@ModelAttribute("order")
public Order newOrder() {
return new Order();
}
如果@ModelAttribute标注在方法上,如果没有返回值,这个方法会在这个控制器所有的RequestMapping系列方法前被调用,可以用这个方法来做一些事情。
如果这个方法有返回值,实际上就是将这个方法的返回值设置到model里。如果没有给出属性的名字,那么会自动使用类名的小写来做名字。最关键的是,这个方法如果发现在@SessionAttributes中已经有了相同的属性名,则不会再重复覆盖。这与在控制器方法中手动放一个new Order()完全不同。
此时@SessionAttributes("order")的作用就是,如果model里有叫"order"的属性,会自动将其装载到session中。
所以只后就算跳转回来,也没有问题。
在这里更牛逼的是,可以完全不设置这个方法,只保留这行:
@SessionAttributes("order")
然后在这个方法里加上参数:
@GetMapping
public String getIngredientList(Model model, Order order) {
model.addAttribute("mapper", ingredientService.getIngredientsAndTypeMap());
return "ingredients";
}
这样也行,似乎是利用了默认创建然后绑定到参数名称。如果显示指定参数@ModelAttribute("order") Order order,就会报错找不到值,这是因为这篇文章里提到的顺序:
在 implicitModel(即map) 中查找 key 对应的对象:
1:若 @ModelAttribute 标记的方法在 Map 中保存过这样一个键值对, 其 key 和 2.1 确定的 key 一致, 则会获取到key对应的键值对的value值;
2:若 implicitModel 中不存在 key 对应的对象,则检查当前的控制器类是否被@SessionAttributes注解修饰,如果使用了@SessionAttributes注解修饰,且@SessionAttributes注解的value值中包含了key,则尝试从HttpSession中获取key所对应的value值,如果value值存在则获取到,如果value值不存在则抛出异常。
3:如果没有使用@SessionAttributes注解修饰该控制器类,或者使用了,但是@SessionAttributes注解中的value值不包含key,则SpringMVC会通过反射来创建一个POJO类型的对象。
所以看来SIA5中就是标准的做法,即用@ModelAttribute来修饰需要添加进@SessionAttributes的方法,指定好属性名,这样可以保证只添加一次,不重复添加。
在其他控制器中也加入@SessionAttributes,然后直接显式使用@ModelAttribute取值,待完成所有操作之后,使用SessionStatus.setComplete()清空数据。
总结一下SIA1-3章自己实现的几个坑:
- Hibernate的解析列名大小写的配置
- MySQL数据库表名不要使用关键字
- @SessionAttributes与@ModelAttribute的机制