实现过程

第二章其实并没有涉及到数据库,而是写了几个类,然后硬是new出来一些数据,写到页面中供选择。

主要的内容其实是Spring MVC的内容。

SIA5并没有区分Spring Framework和组件,而是直接就盯住Spring Boot讲起,从字面上不区分Spring 和Spring Boot,这是第四版相比第五版的一大改变。

构建数据库

表结构和简单的数据如下:

SIA5数据结构图

创建数据结构,然后录入一些基础的原料及类别如下:

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章自己实现的几个坑:

  1. Hibernate的解析列名大小写的配置
  2. MySQL数据库表名不要使用关键字
  3. @SessionAttributes与@ModelAttribute的机制

待深入看的内容:Thymeleaf的使用方法。

最后还要补充一下,本来觉得验证很简单,加上去就行了,结果又调试了一下午。一直报错,然后有的出错有的不出错,最后才发现每一个控制器方法,只要涉及@Valid验证,一定要在这个参数后边紧跟Errors或者BindingResults参数,之前的视频没有明确的说。结果在这里坑了一下午。

不过因为要搞清楚这个,所以又重新写了一遍SIA5 1-3章的实现,放在:https://github.com/minkolee/sia5上。和博客文章里的略有不同,@ModelAttribute和@SessionAttributes用的比刚开始写的时候更好了一些,将公共参数也设置上去了。

后边还想再加一个取消订单的方法,从当前Order里拿出来对应的Taco对象,然后删除掉,再清理session。看能不能做出来。