实现过程
第二章其实并没有涉及到数据库,而是写了几个类,然后硬是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的机制