Bootstrap

MyBatis 操作数据库(进阶)

1. 动态 SQL

动态 SQL 是 MyBatis 的强大特性之一,能够完成不同条件下不同的 SQL 拼接

官方文档:动态 SQL_MyBatis中文网

1.1 <if> 标签

在注册用户的场景中,注册可能会分为两种字段:必填字段和非必填字段

如:性别 gender 为非必传字段,代码实现如下:

接口定义:

Integer insertUserByCondition(UserInfo userInfo);

UserInfoMapper.xml 实现:

    <insert id="insertUserByCondition">
        insert into user_info (username, `password`, age,
        <if test="gender != null">
            gender
        </if>
        )
        values (#{username}, #{password}, #{age},
        <if test="gender != null">
            #{gender}
        </if>
        )
    </insert>

注意:上面代码,若添加对象时有 gender 输入,会添加成功,如下:

但若是没有 gender 输入则会报错,如下:

是因为其多个一个 , 将这个 , 也包含在 <if> 标签里面就好了

若是数据库中其他字段也是 可为空的字段,如下:

将所有字段都加上 <if> 标签,如下:

    <insert id="insertUserByCondition">
        insert into user_info (
        <if test="username != null">
            username,
        </if>
        <if test="username != null">
            `password`,
        </if>
        <if test="username != null">
            age,
        </if>
        <if test="gender != null">
            gender
        </if>
        )
        values (
        <if test="gender != null">
            #{username},
        </if>
        <if test="gender != null">
            #{password},
        </if>
        <if test="gender != null">
            #{age},
        </if>
        <if test="gender != null">
            #{gender}
        </if>
        )
    </insert>

如果这样写,少任何一个字段都可能会多余一个 , 因此需要 <trim> 标签来处理

1.2 <trim> 标签

标签中有如下属性:

  • prefix:表示整个语句块,以 prefix 的值作为前缀
  • suffix:表示整个语句块,以 suffix 的值作为后缀
  • prefixOverrides:表示整个语句块要除掉的前缀
  • suffixOverrides:表示整个语句块要除掉的后缀

调整后 UserInfoMapper.xml:

    <insert id="insertUserByCondition">
        insert into user_info
        <trim prefix="(" suffix=")" suffixOverrides=",">
            <if test="username != null">
                username,
            </if>
            <if test="username != null">
                `password`,
            </if>
            <if test="username != null">
                age,
            </if>
            <if test="gender != null">
                gender
            </if>
        </trim>
        values
        <trim prefix="(" suffix=")" suffixOverrides=",">
            <if test="gender != null">
                #{username},
            </if>
            <if test="gender != null">
                #{password},
            </if>
            <if test="gender != null">
                #{age},
            </if>
            <if test="gender != null">
                #{gender}
            </if>
        </trim>
    </insert>

在以上 SQL 动态解析时,会将第一个部分做如下处理:

  • 基于 prefix 配置,开始部分加上 (
  • 基于 suffix 配置,结束部分加上 )
  • 多个组织的语句都以 结尾,在最后拼接好的字符串还会以 结尾,会基于 suffixOverrides 配置去掉最后一个
  • 注意 <if test = "username != null"> 中的 username 是传入对象的属性

1.3 <where> 标签

看下面这个场景,系统会根据我们的筛选条件,动态组装 where 条件

下面看示例:

需求:查询 age = 18 and delete_flag = 0 的同学

运行结果:

但是当传递一个空对象时:

就会报错,这种情况有两种解决方案:

1. 在 where 后面加上 1=1

再次运行:

 2. 使用 <where> 标签

    <select id="selectUserByCondition" resultType="com.example.mybatisdemo.model.UserInfo">
        select * from user_info
        <where>
            <if test="age != null">
                age = #{age} and
            </if>
            <if test="deleteFlag != null">
                delete_flag = #{deleteFlag}
            </if>
        </where>
    </select>

 运行结果:

<where> 只会在子元素有内容的情况下才插入 where 子句,而且会自动去除子句开头的 AND 或 OR

以上标签也可以使用 <trim prefix="where" prefixOverrides="and"> 替换,但是此种情况下,当子元素都没有内容时,where 关键字也会保留

1.4 <set> 标签

先看不使用 <set> 标签的情况,若所需参数都传过来了,可以正常修改

当所传参数少的情况下:

也是可以运行成功的,但是当一个参数都没有时就会报错了:

这种情况加 <set> 标签是没有用的

<set> 标签的作用仅仅是动态的在 SQL 语句中插入 seet 关键字,并会删掉额外的逗号(用于 update语句中)

<set> 标签 等价于 <trim prefix="set" suffixOverrides=",">

示例:

1.5 <foreach> 标签

对集合进行遍历时可以使用该标签。标签有如下属性:

  • collection:绑定方法参数中的集合,如List,Set,Map或数组对象
  • item:遍历时的每一个对象
  • open:语句块开头的字符串
  • close:语句块结束的字符串
  • separator:每次遍历之间间隔的字符串

1.6 <include> 标签

在 xml 映射文件中配置的 SQL,有时可能会存在很多重复的片段,此时就会存在很多冗余的代码,如下:

我们可以对重复的代码片段进行抽取,将其通过 <sql> 标签封装成一个 SQL 片段,然后再通过 <include> 标签进行引用

  • <sql>:定义可重用的 SQL 片段
  • <include>:通过属性 refid,指定包含的 SQL 片段

如下:

1.7 使用注解的方式 insert 数据

    @Insert("<script>" +
            "insert into user_info" +
            " <trim prefix=\"(\" suffix=\")\" suffixOverrides=\",\">" +
            " <if test=\"username!=null\"> username ,</if>" +
            " <if test=\"password!=null\"> password ,</if>" +
            " <if test=\"age!=null\"> age ,</if>" +
            " <if test=\"gender!=null\"> gender ,</if>" +
            " </trim> values" +
            " <trim prefix=\"(\" suffix=\")\" suffixOverrides=\",\">" +
            " <if test=\"username!=null\"> #{username},</if>" +
            " <if test=\"password!=null\"> #{password},</if>" +
            " <if test=\"age!=null\"> #{age},</if>" +
            " <if test=\"gender!=null\"> #{gender},</if>" +
            " </trim>"+
            "</script>")
    Integer insertUserByCondition(UserInfo userInfo);

删、改、查的写法类似,不写了

2. 案例练习

2.1 留言板

在前面案例中,我们写了表白墙,当服务器重启后,数据会丢失

要想数据不丢失,需要把数据存储在数据库中,下面借助 MyBatis 来实现数据的操作

2.1.1 数据准备

DROP TABLE IF EXISTS message_info;
CREATE TABLE `message_info` (
        `id` INT ( 11 ) NOT NULL AUTO_INCREMENT,
        `from` VARCHAR ( 127 ) NOT NULL,
        `to` VARCHAR ( 127 ) NOT NULL,
        `message` VARCHAR ( 256 ) NOT NULL,
        `delete_flag` TINYINT ( 4 ) DEFAULT 0 COMMENT '0-正常, 1-删除',
        `create_time` DATETIME DEFAULT now(),
        `update_time` DATETIME DEFAULT now() ON UPDATE now(),
PRIMARY KEY ( `id` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;

ON UPDATE now(): 当数据发生更新操作时,自动把该列的值设置为 now()

now() 可以替换成其他获取时间的标识符,比如:CURRENT_TIMESTAMP(),LOCALTIME() 等

MySQL < 5.6.5

  • 只有 TIMESTAMP 支持自动更新
  • 一个表只能有一列设置自动更新
  • 不允许同时存在两列,其中一列设置了 DEFAULT CURRENT_TIMESTAMP,另一列设置了 ON UPDATE CURRENT_TIMESTAMP

MySQL >= 5.6.5

  • TIMESTAMP 和 DATETIME 都支持自动更新,且可以有多列

2.1.2 引入 MyBatis 和 MySQL 驱动依赖

修改 pom 文件

        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.3</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

或者在 pom 文件中右键使用插件 EditStarters 来引入依赖

2.1.3 配置 MySQL 账号密码

spring:
  application:
    name: SprignMVC_Demo_20241021
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/mybatis_test?characterEncoding=utf8&useSSL=false
    username: root
    password: "111111"
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
  configuration: # ???? MyBatis ??
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
    map-underscore-to-camel-case: true # ????????

2.1.4 编写后端代码

Model

import lombok.Data;

import java.util.Date;

@Data
public class MessageInfo {
    private Integer id;
    private String from;
    private String to;
    private String message;
    private Date createTime;
    private Date updateTime;
}

MessageInfoMapper

import com.example.sprignmvc_demo_20241021.model.MessageInfo;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

import java.util.List;

@Mapper
public interface MessageInfoMapper {
    // 查询所有留言信息
    @Select("select * from message_info where delete_flag=0")
    List<MessageInfo> queryMessage();

    // 插入留言信息
    @Insert("insert into message_info (`from`, `to`, `message`) values (#{from}, #{to}, #{message})")
    Integer insertMessage(MessageInfo messageInfo);
}

MessageInfoMapperTest

import com.example.sprignmvc_demo_20241021.model.MessageInfo;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class MessageInfoMapperTest {
    @Autowired
    private MessageInfoMapper messageInfoMapper;

    @Test
    void queryMessage() {
        System.out.println(messageInfoMapper.queryMessage());
    }

    @Test
    void insertMessage() {
        MessageInfo messageInfo = new MessageInfo();
        messageInfo.setFrom("zhangsan");
        messageInfo.setTo("lisi");
        messageInfo.setMessage("帮我买个饭");
        messageInfoMapper.insertMessage(messageInfo);
    }
}

测试运行:成功在数据库中添加了数据

MessageService

import com.example.sprignmvc_demo_20241021.mapper.MessageInfoMapper;
import com.example.sprignmvc_demo_20241021.model.MessageInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class MessageService {
    @Autowired
    private MessageInfoMapper messageInfoMapper;

    public List<MessageInfo> queryMessage() {
        return messageInfoMapper.queryMessage();
    }

    public void insertMessage(MessageInfo messageInfo) {
        messageInfoMapper.insertMessage(messageInfo);
    }
}

MessageController

import com.example.sprignmvc_demo_20241021.model.MessageInfo;
import com.example.sprignmvc_demo_20241021.service.MessageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.List;

@RequestMapping("/message")
@RestController
public class MessageController {
    @Autowired
    private MessageService messageService;

    // 获取接口
    @RequestMapping("/getList")
    public List<MessageInfo> getList() {
        return messageService.queryMessage();
    }

    // 发布接口
    @RequestMapping(value = "/publish", produces = "aplication/json")
//    @RequestMapping(value = "/publish")
    public String publish(@RequestBody MessageInfo messageInfo) { // 请求是 JSON 格式,参数需要加上 @RequestBody
        // 参数都不为空,将对象添加到 list 中
        if (StringUtils.hasLength(messageInfo.getFrom())
            && StringUtils.hasLength(messageInfo.getTo())
            && StringUtils.hasLength(messageInfo.getMessage())) {
                messageService.insertMessage(messageInfo);
                return "{\"ok\": 1}";
        }
        return "{\"ok\": 0}";
    }
}

2.1.5 测试

部署程序,验证服务器是否能正确响应:http://127.0.0.1:8080/messagewall.html

可以看到上面测试用的数据,已经能显示到界面上

当再次输入留言信息,点击提交,发现页面列表显示新的数据,并且数据库中也添加了一条记录

即使重启服务,数据也不会丢失了

2.2 图书管理系统

前面实现的图书管理系统,只完成了用户登录和图书列表,并且数据都是 Mock 的,下面来将其他功能进行完善

功能列表:

  1. 用户登录
  2. 图书列表
  3. 图书的增删改查
  4. 翻页功能

2.2.1 数据库表设计

数据库表通常分两种:实体表和关系表

分析我们的需求,图书管理系统只有两个实体:用户和图书,并且用户和图书之间没有关联关系

表的具体字段设计与需求有关:

用户表:用户名和密码

图书表:参考需求页面(通常不是一个页面决定的,而是对整个系统进行全面分析观察后定的)

创建数据库 book_test

-- 创建数据库
DROP DATABASE IF EXISTS book_test;

CREATE DATABASE book_test DEFAULT CHARACTER SET utf8mb4;

use book_test;

-- 用户表
DROP TABLE IF EXISTS user_info;
CREATE TABLE user_info (
        `id` INT NOT NULL AUTO_INCREMENT,
        `user_name` VARCHAR ( 128 ) NOT NULL,
        `password` VARCHAR ( 128 ) NOT NULL,
        `delete_flag` TINYINT ( 4 ) NULL DEFAULT 0,
        `create_time` DATETIME DEFAULT now(),
        `update_time` DATETIME DEFAULT now() ON UPDATE now(),
        PRIMARY KEY ( `id` ),
UNIQUE INDEX `user_name_UNIQUE` ( `user_name` ASC )) ENGINE = INNODB DEFAULT CHARACTER 
SET = utf8mb4 COMMENT = '用户表';

-- 图书表
DROP TABLE IF EXISTS book_info;
CREATE TABLE `book_info` (
        `id` INT ( 11 ) NOT NULL AUTO_INCREMENT,
        `book_name` VARCHAR ( 127 ) NOT NULL,
        `author` VARCHAR ( 127 ) NOT NULL,
        `count` INT ( 11 ) NOT NULL,
        `price` DECIMAL (7,2 ) NOT NULL,
        `publish` VARCHAR ( 256 ) NOT NULL,
        `status` TINYINT ( 4 ) DEFAULT 1 COMMENT '0-无效, 1-正常, 2-不允许借阅',
        `create_time` DATETIME DEFAULT now(),
        `update_time` DATETIME DEFAULT now() ON UPDATE now(),
PRIMARY KEY ( `id` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8mb4;

-- 初始化数据
INSERT INTO user_info ( user_name, PASSWORD ) VALUES ( "admin", "admin" );
INSERT INTO user_info ( user_name, PASSWORD ) VALUES ( "zhangsan", "123456" );

-- 初始化图书数据
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('活着', '余华', 29, 22.00, '北京文艺出版社');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('平凡的世界', '路遥', 5, 98.56, '北京十月文艺出版社');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('三体', '刘慈欣', 9, 102.67, '重庆出版社');
INSERT INTO `book_info` (book_name,author,count, price, publish) VALUES ('金字塔原理', '麦肯锡', 16, 178.00, '民主与建设出版社');

2.2.2 引入 MyBatis 和 MySQL 驱动依赖

修改 pom 文件

        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.3</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

2.2.3 配置数据库 & 日志

server:
  port: 9090
spring:
  application:
    name: spring-book
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/book_test?characterEncoding=utf8&useSSL=false
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
  configuration:
    map-underscore-to-camel-case: true # 配置驼峰自动转换
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印 SQL 日志
  mapper-locations: classpath:mapper/**Mapper.xml
# 设置日志文件的名称
logging:
  file:
    name: spring-book.log

2.2.4 Model 创建

import lombok.Data;

import java.util.Date;

@Data
public class UserInfo {
    private Integer id;
    private String userName;
    private String password;
    private Date createTime;
    private Date updateTime;
}
import lombok.Data;

import java.math.BigDecimal;
import java.util.Date;

// lombok
@Data
public class BookInfo {
    private Integer id; // 图书ID
    private String bookName; // 图书名称
    private String author; // 作者
    private Integer count; // 库存
    private BigDecimal price; // (定价)不要用 float 和 double 类型,可以用 long 或 BigDecimal 类型(精度问题)
    private String publish; // 图书出版社
    private Integer status; // (图书状态)不建议用 Boolean 类型,若后续要增加状态很麻烦
    private String statusCN; // 图书状态的中文表示
    private Date createTime; // 创建时间
    private Date updateTime; // 更新时间
}

2.2.5 用户登录

约定前后端交互接口

[请求]

/user/login

Content-Type: application/x-www-form-urlencoded; charset=UTF-8

[参数]

name=zhangsan&password=123456

[响应]

true  // 账号密码验证正确返回 true,否则返回 false

流程:浏览器给服务器发送 /user/login 这样的 HTTP 请求,服务器给浏览器返回了一个 Boolean 类型的数据

实现服务器代码

控制层:

从数据库中,根据名称查询用户,如果可以查到,并且密码一致,就认为登录成功

UserController

import com.example.springbook.service.UserService;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RequestMapping("/user")
@RestController
public class UserController {

    @Autowired
    private UserService userService;

    @RequestMapping("/login")
    public Boolean login(String name, String password, HttpSession session) {
        log.info("接收到参数:" + name);
        // 校验参数格式
        Boolean result = userService.checkUserAndPassword(name, password, session);
        log.info("用户登录结果,name:{},结果:{}", name, result); // {} 占位符
        return result;
    }
}

业务层:UserService

import com.example.springbook.mapper.UserMapper;
import com.example.springbook.model.UserInfo;
import jakarta.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

@Service
public class UserService {
    @Autowired
    private UserMapper userMapper;

    public Boolean checkUserAndPassword(String userName, String password, HttpSession session) {
        if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {
            return false;
        }
        UserInfo userInfo = userMapper.selectUser(userName);
        if (userInfo == null) {
            return false;
        }
        // 2. 从数据库中校验账号和密码是否正确
        if (password.equals(userInfo.getPassword())) {
            // 3. 如果正确,存储 Session,返回 true
            // session 的内容,取决于后面需要从 session 中获取什么
            session.setAttribute("uesrName", userName);
            return true;
        }
        return false;
    }
}

数据层:UserMapper

import com.example.springbook.model.UserInfo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

@Mapper
public interface UserMapper {
    @Select("select * from user_info where delete_flag = 0 and user_name = #{name}")
    UserInfo selectUser(String name);
}
测试

输入错误的用户名和密码,页面弹窗警告

输入正确的用户名和密码,页面正常跳转

2.2.6 添加图书

约定前后端接口

[请求]

/book/addBook

Content-Type: application/x-www-form-urlencoded; charset=UTF-8

[参数]

bookName=图书1&author= 作者1&count=23&price=36&publish=出版社1&status=1

[响应]

""  // 成功时返回空字符串,失败时返回失败信息 

流程:浏览器给服务器发送一个 /book/addBook 这样的 HTTP 请求,form 表单的形式来提交数据,服务器返回处理结果,返回 "" 表示添加图书成功,否则返回失败信息

实现服务器代码

控制层:在 BookController 补充代码

    @RequestMapping("/addBook")
    public String addBook(BookInfo bookInfo) {
        log.info("添加图书,bookInfo:{}", bookInfo);
        // 1. 参数校验
        if (!StringUtils.hasLength(bookInfo.getBookName())
                || !StringUtils.hasLength(bookInfo.getAuthor())
                || bookInfo.getCount() == null
                || bookInfo.getPrice() == null
                || !StringUtils.hasLength(bookInfo.getPublish())) {
            return "参数不合法!请重新检查";
        }
        try {
            // 2. 插入数据
            Integer result = bookService.insertBook(bookInfo);
            if (result == 1) {
                return "";
            }
        } catch (Exception e) {
            log.error("数据插入发生异常,e:", e);
        }
        return "数据插入失败,请联系管理员";
    }

业务层:在 BookService 中补充代码

    public Integer insertBook(BookInfo bookInfo) {
        return bookMapper.insertBook(bookInfo);
    }

数据层:创建 BookMapper

import com.example.springbook.model.BookInfo;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface BookMapper {

    @Insert("insert into book_info (book_name, author, count, price, publish, `status`)" +
            "values  (#{bookName}, #{author}, #{count}, #{price}, #{publish}, #{status})")
    Integer insertBook(BookInfo bookInfo);
}

使用 postman 测试:

实现客户端代码

在目前前端代码中,js 已经提前留了空位

<button type="button" class="btn btn-info btn-lg" onclick="add()">确定</button>

点击确定按钮,会执行 add() 方法

补全 add() 方法:

提交整个表单的数据:data: $("#addBook").serialize()

提交的内容格式:bookName=图书1&author=作者1&count=23&price=36&publish=出版社1&status=1

被 form 标签包括的所有输入表单(input, select)内容都会被提交

        function add() {
            $.ajax({
                type: "post",
                url: "/book/addBook",
                data: $("#addBook").serialize(), // 对 form 表单的序列化
                success: function (result) {
                    if (result==="") {
                        // 添加成功
                        alert("添加成功");
                        location.href = "book_list.html";
                    } else {
                        alert(result);
                    }
                }
            });
        }
测试

添加图书前数据库内容:

点击添加图书按钮,跳转到添加图书的页面,填写图书信息

点击确定按钮,弹出添加成功的提示框,并且自动跳转到图书列表页

查看数据库可以看到已经添加成功

若是输入不合法的数据,如什么信息也不填,直接点击确定:

页面也能得到正确响应

2.2.7 图书列表

上面添加图书后会跳转到图书列表页,但是没有显示,下面来实现图书列表

需求分析

在前面做的留言墙查询功能,是将数据库中所有的数据查询出来并展示到页面上,试想如果数据库中的数据有很多(假设有十几万条)的时候,将数据全部展示出来肯定不现实,因此后面需要实现分页功能

每次只展示一页的数据,比如:一页展示 10 条数据,若想查看其他数据,可通过页码查询

分页时的数据展示:

第1页:显示 1-10 条数据

第2页:显示 11-20 条数据

第3页:显示 21-30 条数据

以此类推...

要想实现这个功能,需要从数据库中进行分页查询,要使用 limit 关键字,格式为:

limit offset(开始索引), limit(每页显示的条数)

在数据库中伪造更多的数据:

INSERT INTO `book_info` (book_name, author, count, price, publish)
VALUES
    ('图书2', '作者2', 29, 22.00, '出版社2'), ('图书3', '作者2', 29, 22.00, '出版社3'),
    ('图书4', '作者2', 29, 22.00, '出版社1'), ('图书5', '作者2', 29, 22.00, '出版社1'),
    ('图书6', '作者2', 29, 22.00, '出版社1'), ('图书7', '作者2', 29, 22.00, '出版社1'),
    ('图书8', '作者2', 29, 22.00, '出版社1'), ('图书9', '作者2', 29, 22.00, '出版社1'),
    ('图书10', '作者2', 29, 22.00, '出版社1'), ('图书11', '作者2', 29, 22.00, '出版社1'),
    ('图书12', '作者2', 29, 22.00, '出版社1'), ('图书13', '作者2', 29, 22.00, '出版社1'),
    ('图书14', '作者2', 29, 22.00, '出版社1'), ('图书15', '作者2', 29, 22.00, '出版社1'),
    ('图书16', '作者2', 29, 22.00, '出版社1'), ('图书17', '作者2', 29, 22.00, '出版社1'),
    ('图书18', '作者2', 29, 22.00, '出版社1'), ('图书19', '作者2', 29, 22.00, '出版社1'),
    ('图书20', '作者2', 29, 22.00, '出版社1'), ('图书21', '作者2', 29, 22.00, '出版社1');

查询第 1 页的 SQL 语句:

select * from book_info limit 0, 10

查询第 2 页的 SQL 语句:

select * from book_info limit 10, 10

查询第 3 页的 SQL 语句:

select * from book_info limit 20, 10

观察上述 SQL 语句,发现开始索引一直在改变,每页显示条数是固定的

开始索引的计算公式:开始索引 = (当前页码 - 1)* 每页显示条数

基于前端页面继续分析,可得出以下结论:

1. 前端在发起查询请求时,需要像服务端传递的参数

  • currentPage:当前页码,默认值为 1
  • pageSize:每页显示条数,默认值为 10

为了项目更好的扩展性,通常不设置固定值,而是以参数的形式来进行传递

扩展性:软件系统具备面对未来需求变化而进行扩展的能力

比如当前需求一页显示 10 条,后期需求改为一页显示 20 条,后端代码不需任何修改

2. 后端响应时,需要响应给前端的数据

  • records:所查询到的数据列表(存储到 List 集合中)
  • total:总记录数(用于告诉前端显示多少页,显示页数为:(total + pageSize - 1)/ pageSize)

显示页数 totalPage 计算公式为:total % pageSize == 0 ? total / pageSize : (total / pageSize) + 1

pageSize - 1 是 total / pageSize 的最大的余数,所以 (total + pageSize - 1)/ pageSize 就能得到总页数

翻页请求和响应部分,封装到两个对象中:

翻页请求对象:

package com.example.springbook.model;

import lombok.Data;

@Data
public class PageRequest {
    private Integer currentPage = 1; // 当前页码,默认值为 1
    private Integer pageSize = 10; // 每页数据条数,默认值为 10
}

需要根据 currentPage 和 pageSize 计算出开始索引,上述代码修改为:

package com.example.springbook.model;

import lombok.Data;

@Data
public class PageRequest {
    private Integer currentPage = 1; // 当前页码,默认值为 1
    private Integer pageSize = 10; // 每页数据条数,默认值为 10

    private Integer offset;

    public Integer getOffset() {
        return (currentPage - 1) * pageSize;
    }
}

翻页列表结果类:

package com.example.springbook.model;

import lombok.Data;

import java.util.List;

@Data
public class PageResponse<T> {
    private Integer total;
    private List<T> records;
}
约定前后端交互接口

[请求]

/book/getListByPage?currentPage=1&pageSize=10

Content - Type: application/x - www - form - urlencoded; charset=UTF - 8

[参数]

[响应]

Content - Type: application/json

{

        "total": 25,

        "records": [ {

                        "id": 25,

                        "bookName": "图书21",

                        "author": "作者2",

                        "count": 29,

                        "price": 22.00,

                        "publish": "出版社1",

                        "status": 1,

                        "statusCN": "可借阅"

        }, {

                        ...

        } ]

}

流程:浏览器给服务器发送一个 /book/getListByPage 这样的 HTTP 请求,通过 currentPage 参数告诉服务器,当前请求为第几页的数据,后端根据请求参数,返回对应页的数据

第一页可以不传参数,currentPage 默认值为 1

实现服务器代码

控制层:BookController

    // 查询图书信息(翻页使用)
    @RequestMapping("/getListByPage")
    public PageResponse<BookInfo> getListByPage(PageRequest pageRequest) {
        log.info("获取图书列表,pageRequest:{}", pageRequest);
        // 参数校验还没写,包括不能为空,不等于负数等等

        PageResponse<BookInfo> bookInfoPageResponse = bookService.getListByPage(pageRequest);
        return bookInfoPageResponse;
    }

业务层:BookService

    public PageResponse<BookInfo> getListByPage(PageRequest pageRequest) {
        // 1. 总记录数
        Integer count = bookMapper.count();

        // 2. 当前页的记录
        // 第一种方法
//        bookMapper.queryBookPage(pageRequest.getOffset(), pageRequest.getPageSize());
        // 第二种方法
        List<BookInfo> bookInfos =  bookMapper.queryBookPage(pageRequest);

        for (BookInfo bookInfo : bookInfos) {
            if (bookInfo.getStatus() == 0) {
                bookInfo.setStatusCN("删除");
            } else if (bookInfo.getStatus() == 1){
                bookInfo.setStatusCN("可借阅");
            } else {
                bookInfo.setStatusCN("不可借阅");
            }
        }
        PageResponse<BookInfo> response = new PageResponse<>();
        response.setTotal(count);
        response.setRecords(bookInfos);
        return  response;
    }

优化:将 PageResponse 中加上无参构造函数和全参构造函数的注解,上述代码简化如下:

1. 翻页信息需要返回数据的总数和列表信息,需要查两次 SQL

2. 图书状态:图书状态和数据库存储的 status 有对应关系

如果后续状态码有变动,我们需要修改项目中所有涉及的代码,这种情况通常采用枚举类来处理映射关系

创建 enmus 目录,创建 BookStatusEnum 类

package com.example.springbook.enums;

import lombok.Getter;

import java.util.Arrays;

@Getter
public enum BookStatusEnum {
    DELETED(0, "删除"),
    NORMAL(1, "可借阅"),
    FORBIDDEN(2, "不可借阅");

    private Integer code;
    private String desc;

    BookStatusEnum(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public static BookStatusEnum getStatusByCode(Integer code) {
        // 3 种方法
        // 第一种:if else
//        if (code == 0) {
//            return BookStatusEnum.DELETED;
//        } else if {
//            ...
//        }
        // 第二种:switch case
//        switch (code) {
//            case 0: ...
//        }
        // 第三种:lambda 表达式
        return Arrays.stream(BookStatusEnum.values()).filter(x -> x.getCode() == code).findFirst().get();
    }
}

getStatusByCode:通过 code 来获取对应的枚举,以获取该枚举对应的中文名称

后续如果有状态变更,仅需修改该枚举类即可

有了枚举后,BookService 代码优化如下:

package com.example.springbook.service;

import com.example.springbook.dao.BookDao;
import com.example.springbook.enums.BookStatusEnum;
import com.example.springbook.mapper.BookMapper;
import com.example.springbook.model.BookInfo;
import com.example.springbook.model.PageRequest;
import com.example.springbook.model.PageResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class BookService {

    @Autowired
    private BookDao bookDao;

    @Autowired
    private BookMapper bookMapper;

    public List<BookInfo> getList() {
//        BookDao bookDao = new BookDao(); 不使用 @Component 时创建对象的方法
        // 从数据库中查询
        List<BookInfo> bookInfos = bookDao.mockData();
        // 在企业开发中,此处不该返回字符串,就应该返回状态码,但是为了方便后续展开,返回字符串
        for (BookInfo bookInfo : bookInfos) {
            if (bookInfo.getStatus() == 1) {
                bookInfo.setStatusCN("可借阅");
            } else {
                bookInfo.setStatusCN("不可借阅");
            }
        }
        return bookInfos;
    }

    public Integer insertBook(BookInfo bookInfo) {
        return bookMapper.insertBook(bookInfo);
    }

    public PageResponse<BookInfo> getListByPage(PageRequest pageRequest) {
        // 1. 总记录数
        Integer count = bookMapper.count();

        // 2. 当前页的记录
        // 第一种方法
//        bookMapper.queryBookPage(pageRequest.getOffset(), pageRequest.getPageSize());
        // 第二种方法
        List<BookInfo> bookInfos =  bookMapper.queryBookPage(pageRequest);

        for (BookInfo bookInfo : bookInfos) {
            // 未优化
//            if (bookInfo.getStatus() == 0) {
//                bookInfo.setStatusCN("删除");
//            } else if (bookInfo.getStatus() == 1){
//                bookInfo.setStatusCN("可借阅");
//            } else {
//                bookInfo.setStatusCN("不可借阅");
//            }
            // 优化后
            bookInfo.setStatusCN(BookStatusEnum.getStatusByCode(bookInfo.getStatus()).getDesc());
        }
        // 未优化
//        PageResponse<BookInfo> response = new PageResponse<>();
//        response.setTotal(count);
//        response.setRecords(bookInfos);
//        return  response;
        // 优化后
        return new PageResponse<BookInfo>(count, bookInfos);
    }
}

数据层:BookMapper

    // 查询数据总条数
    @Select("select count(1) from book_info where status != 0")
    Integer count();

    // 根据传入的当前页和每页数据条数进行查询
    // 第一种方法
//    @Select("select * from book_info where status != 0 order by id limit #{offset}, #{limit}")
//    List<BookInfo> queryBookPage(Integer offset, Integer limit);

    // 第二种方法
    @Select("select * from book_info where status != 0 order by id limit #{offset}, #{pageSize}")
    List<BookInfo> queryBookPage(PageRequest pageRequest);

使用 postman 测试:

实现客户端代码

我们定义:

访问第一页图书列表的前端 url:http://127.0.0.1:9090/book_list.html?currentPage=1

访问第二页图书列表的前端 url:http://127.0.0.1:9090/book_list.html?currentPage=2

浏览器访问 book_list 页面时,就去请求后端,把后端返回的数据显示在页面上

调用后端请求:/book/getListByPage?currentPage=1

修改 js 代码:

此时,url 还没有设置 currentPage 参数,这里直接使用 location.search 从 url 中获取参数信息即可

location.search:获取 url 的查询字符串(包含问号)

如:

url:http://127.0.0.1:9090/book_list.html?currentPage=1

location.search:?currentPage=1

所以把上述 url 改为:"/book/getListByPage" + location.search

访问 url:http://127.0.0.1:9090/book_list.html,已经可以正常显示,但是还没有分页操作

接下来处理分页信息

分页插件

本案例种,分页代码采用了一个分页组件

分页组件文档介绍:jqPaginator分页组件

使用时,只需要按照 [使用说明] 部分的文档,把代码复制粘贴进来就可以了(提供的前端代码中,已经包含该部分内容)简单介绍下使用

onPageChange:回调函数,当换页时触发(包括初始化第一页的时候),会传入两个参数: 1、"目标页" 的页码,Number类型

2、触发类型,可能的值: "init"(初始化),"change"(点击分页)

我们在图书列表信息加载之后,需要分页信息,同步加载

分页组件需要提供一些信息:(具体参考 jqPaginator分页组件

totalCounts:总记录数

pageSize:每页的个数

visiblePages:可视页数

currentPage:当前页码

这些信息中,pageSize 和 visiblePages 前端直接设置即可。totalCounts 后端已经提供,currentPage 也可以从参数中取到,但太复杂了,咱们直接由后端返回即可。

修改后端代码

1. 为避免后续还需要其他请求处的信息,我们直接在 PageResponse 添加 PageRequest 属性

2. 处理返回结果,填充 pageRequest

PageResponse

BookService

js 中将分页代码挪到 getBookList 方法中,并且完善页面点击代码:

function getBookList() {
            $.ajax({
                type: "get",
                url: "/book/getListByPage" + location.search,
                success: function (result) {
                    // 打印 result,看后端是否正确响应
                    console.log(result);

                    var books = result.records;
                    var finalHtml = "";
                    for (var book of books) {
                        finalHtml += '<tr>';
                        finalHtml += '<td><input type="checkbox" name="selectBook" value="' + book.id + '" id="selectBook" class="book-select"></td>';
                        finalHtml += '<td>' + book.id + '</td>';
                        finalHtml += '<td>' + book.bookName + '</td>';
                        finalHtml += '<td>' + book.author + '</td>';
                        finalHtml += '<td>' + book.count + '</td>';
                        finalHtml += '<td>' + book.price + '</td>';
                        finalHtml += '<td>' + book.publish + '</td>';
                        finalHtml += '<td>' + book.statusCN + '</td>';
                        finalHtml += '<td>';
                        finalHtml += '<div class="op">';
                        finalHtml += '<a href="book_update.html?bookId=' + book.id + '">修改</a>';
                        finalHtml += '<a href="javascript:void(0)" onclick="deleteBook(' + book.id + ')">删除</a>';
                        finalHtml += '</div>';
                        finalHtml += '</td>';
                        finalHtml += '</tr>';
                    }
                    $("#bookList").html(finalHtml);

                    // 从后端获取当前页码
                    // 非空检验省略...
                    var currentPage = result.request.currentPage;

                    //翻页信息
                    $("#pageContainer").jqPaginator({
                        totalCounts: result.total, //总记录数
                        pageSize: 10,    //每页的个数
                        visiblePages: 5, //可视页数
                        currentPage: currentPage,  //当前页码
                        first: '<li class="page-item"><a class="page-link">首页</a></li>',
                        prev: '<li class="page-item"><a class="page-link" href="javascript:void(0);">上一页<\/a><\/li>',
                        next: '<li class="page-item"><a class="page-link" href="javascript:void(0);">下一页<\/a><\/li>',
                        last: '<li class="page-item"><a class="page-link" href="javascript:void(0);">最后一页<\/a><\/li>',
                        page: '<li class="page-item"><a class="page-link" href="javascript:void(0);">{{page}}<\/a><\/li>',
                        //页面初始化和页码点击时都会执行
                        onPageChange: function (page, type) {
                            console.log("第" + page + "页, 类型:" + type);
                            if(type == "change") {
                                location.href = "book_list.html?currentPage=" + page;
                            }
                        }
                    });
                }
            });
        }
测试:

访问 http://127.0.0.1:9090/book_list.html

点击页码,页面信息得到正确的处理:

2.2.8 修改图书

约定前后端交互接口

进入修改页面,就需要显示当前图书的信息

[请求]

/book/queryBookById?bookId=5

[参数]

[响应]

{

        "id": 25,

        "bookName": " 图书 21",

        "author": " 作者 2",

        "count": 999,

        "price": 222.00,

        "publish": " 出版社 1",

        "status": 2,

        "statusCN": null,

        "createTime": "2023-09-04T04:01:27.000+00:00",

        "updateTime": "2023-09-05T03:37:03.000+00:00"

}

tip:根据图书 ID,获取当前图书的信息

点击修改按钮,修改图书信息

[请求]

/book/updateBook

Content-Type: application/x-www-form-urlencoded; charset=UTF-8

[参数]

id=1&bookName=图书1&author=作者1&count=23&price=36&publish=出版社1&status=1

[响应]

""   // 成功返回空字符串

tip:约定浏览器给服务器发送一个 /book/updateBook 这样的 HTTP 请求,form 表单的形式来提交数据

服务器返回处理结果,返回 "" 表示添加图书成功,否则返回失败信息

实现服务器代码

BookController

    // 当进入图书修改页面后,将图书信息展示到页面上
    @RequestMapping("/queryBookById")
    public BookInfo queryBookById(Integer bookId) {
        log.info("获取图书信息,bookId:" + bookId);
        // 参数校验,不能为 null,不能 <= 0... 省略
        // 最好加上 try catch,防止查询失败
        return bookService.queryBookById(bookId);
    }

    // 更新图书
    @RequestMapping("/updateBook")
    public String updateBook(BookInfo bookInfo) {
        log.info("更新图书,bookInfo:{}" + bookInfo);
        try {
            Integer result = bookService.updateBook(bookInfo);
            if (result > 0) {
                return "";
            }
            return "图书更新失败";
        } catch (Exception e){
            log.info("更新图书失败,e:", e);
            return "数据库操作失败";
        }
    }

业务层:BookService

    public BookInfo queryBookById(Integer bookId) {
        return bookMapper.queryBookById(bookId);
    }

    public Integer updateBook(BookInfo bookInfo) {
        return bookMapper.updateBook(bookInfo);
    }

数据层:

根据图书 ID,查询图书信息

    @Select("select id, book_name, author, count, price, publish, `status`, create_time, update_time from book_info where id = #{bookId}")
    BookInfo queryBookById(Integer bookId);

更新逻辑相对比较复杂,这里使用 xml 方式实现

配置 xml 路径:

 mybatis:
   mapper-locations: classpath:mapper/**Mapper.xml

整体 yml 配置文件:

server:
  port: 9090
spring:
  application:
    name: spring-book
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/book_test?characterEncoding=utf8&useSSL=false
    username: root
    password: "111111"
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
  configuration:
    map-underscore-to-camel-case: true # 配置驼峰自动转换
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印 SQL 日志
  mapper-locations: classpath:mapper/**Mapper.xml

# 日志
logging:
  file:
    name: spring-book.log

定义 Mapper 接口:BookInfoMapper

Integer updateBook(BookInfo bookInfo);

xml 实现:

创建 BookInfoMapper.xml 文件

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.springbook.mapper.BookMapper">
    <update id="updateBook">
        update book_info
        <set>
            <if test="bookName != null">
                book_name = #{bookName},
            </if>
            <if test="author != null">
                author = #{author},
            </if>
            <if test="count != null">
                count = #{count},
            </if>
            <if test="price != null">
                price = #{price},
            </if>
            <if test="publish != null">
                publish = #{publish},
            </if>
            <if test="status != null">
                status = #{status},
            </if>
        </set>
        where id = #{id}
    </update>
</mapper>
实现客户端代码

在 book_list.html 中已经存在了 修改 的链接:

finalHtml += '<a href="book_update.html?bookId=' + book.id + '">修改</a>';

点击 修改 链接时,会自动跳转到 book_update.html(修改图书界面)

进入 修改图书界面后,需要先从后端拿到当前图书的信息,并显示在页面上

        $.ajax({
            type: "get",
            url: "/book/queryBookById" + location.search,
            success: function (result) {
                if (result != null) {
                    $("#bookId").val(result.id);
                    $("#bookName").val(result.bookName);
                    $("#bookAuthor").val(result.author);
                    $("#bookStock").val(result.count);
                    $("#bookPrice").val(result.price);
                    $("#bookPublisher").val(result.publish);
                    $("#bookStatus").val(result.status);
                }
            }
        });

修改图书的方法:

        function update() {
            $.ajax({
               type: "post",
               url: "/book/updateBook",
               data: $("#updateBook").serialize(),
                success: function (result) {
                   if (result == "") {
                       alert("更新成功");
                       location.href = "book_list.html";
                   } else {
                       alert(result);
                   }
                }
            });
        }

我们修改图书信息是根据图书 ID 来修改的,所以需要前端传递的参数中包含图书 ID

有两种方式:

  1. 获取 url 中参数的值(比较复杂,需要分割 url)
  2. 在 form 表单中,增加一个隐藏输入框存储图书 ID,跟 $("#updateBook").serialize() 一起提交到后端

下面采用第二种方式

在 form 表单中,添加隐藏输入框

hidden 类型的 <input> 元素

隐藏表单,用户不可见、不可改的数据,在用户提交表单时,这些数据会一并发出

使用场景:正被请求或编辑的内容的 ID,这些隐藏的 input 元素在渲染完成的页面中完全不可见,而且没有方法可以使它重新变为可见

页面加载时,给该 hidden 赋值

测试

点击 修改 链接

跳转到图书修改页面,页面加载出该图书的信息

随机修改数据,点击确定按钮,观察数据是否被修改:

2.2.9 删除图书

约定前后端交互接口

删除分为 逻辑删除 和 物理删除

逻辑删除

逻辑删除也称为软删除、假删除、Soft Delete,即不真正删除数据,而在某行数据上增加类型is_deleted的删除标识,一般使用UPDATE语句

物理删除

物理删除也称为硬删除,从数据库表中删除某一行或某一集合数据,一般使用DELETE语句

删除图书的两种实现方式

逻辑删除

 update book_info set status=0 where id = 1

物理删除

delete from book_info where id=25

数据是公司的重要财产,通常情况下,我们采用逻辑删除的方式,当然也可以采用【物理删除 + 归档】的方式

物理删除并归档

创建一个与原表差不多的结构,记录删除时间,实现 insert...select 即可,参考:SQL INSERT INTO SELECT 语句 | 菜鸟教程

插入和删除操作放在同一个事务中执行

物理删除+归档的方式实现有些复杂,这里采用逻辑删除的方式

逻辑删除的话,依然是更新逻辑,我们可以直接使用修改图书的接口 /book/updateBook,也可以将 updateBook 封装一层成为 deleteBook,依然是调用的 bookService.updateBook 方法,如下:

    // 更新图书
    @RequestMapping("/updateBook")
    public String updateBook(BookInfo bookInfo) {
        log.info("更新图书,bookInfo:{}" + bookInfo);
        try {
            Integer result = bookService.updateBook(bookInfo);
            if (result > 0) {
                return "";
            }
            return "图书更新失败";
        } catch (Exception e){
            log.info("更新图书失败,e:", e);
            return "数据库操作失败";
        }
    }

    // 删除图书,依然调用的 bookService.updateBook
    @RequestMapping("/deleteBook")
    public String deleteBook(Integer bookId) {
        log.info("删除图书,bookId:{}" + bookId);
        try {
            BookInfo bookInfo = new BookInfo();
            bookInfo.setId(bookId);
            bookInfo.setStatus(BookStatusEnum.DELETED.getCode());
            Integer result = bookService.updateBook(bookInfo);
            if (result > 0) {
                return "";
            }
            return "图书删除失败";
        } catch (Exception e){
            log.info("删除图书失败,e:", e);
            return "数据库操作失败";
        }
    }
前端代码:

测试:

2.2.10 批量删除

批量删除,其实就是批量修改数据

约定前后端交互接口

[请求]

/book/batchDeleteBook

Content-Type: application/x-www-form-urlencoded; charset=UTF-8

[参数]

[响应]

""   // 成功时返回空字符串,失败时返回失败信息

点击 [批量删除] 按钮时,只需要把复选框选中的图书 ID 发送给后端即可

多个 id,使用 List 的形式来传递参数

实现服务器代码:

控制层:BookController

    // 批量删除图书
    @RequestMapping("/batchDeleteBook")
    public Boolean batchDeleteBook(@RequestParam List<Integer> ids) {
        log.info("批量删除图书,ids:{}", ids);
        try {
            // 执行 SQL
            bookService.batchDelete(ids);
            return true;
        } catch (Exception e) {
            log.error("批量删除图书失败,ids:{}", ids);
        }
        return false;
    }

业务层:BookService

    public void batchDelete(List<Integer> ids) {
        bookMapper.batchDelete(ids);
    }

数据层:批量删除需要用到动态 SQL,这里使用 XML 实现

BookMapper 接口定义:

void batchDelete(List<Integer> ids);

XML 接口实现:

<!--    批量删除 xml -->
<!--    <foreach collection="ids" item="id" open="(" close=")" separator=","> 动态 sql,从 ids 中遍历,每一个元素名为 id,从 ( 开始,) 结束,元素之间使用 , 分割-->
    <update id="batchDelete">
        update book_info
        set status = 0
        where id in
        <foreach collection="ids" item="id" open="(" close=")" separator=",">
            #{id}
        </foreach>
    </update>
实现客户端代码

点击 [批量删除] 按钮时,需要获取到所有选中的复选框的值

        function batchDelete() {
            var isDelete = confirm("确认批量删除?");
            if (isDelete) {
                //获取复选框的id
                var ids = [];
                $("input:checkbox[name='selectBook']:checked").each(function () {
                    ids.push($(this).val());
                });
                // console.log(ids);
                $.ajax({
                    type: "post",
                    url: "/book/batchDeleteBook?ids=" + ids,
                    success: function (result) {
                        if (result) {
                            alert("批量删除成功");
                            location.href = "book_list.html";
                        } else {
                            alert("删除失败,请练习管理员");
                        }
                    }
                });
            }
        }
测试

2.2.11 强制登录

虽然我们做了用户登录,但是却发现用户不登录依然可以操作图书

这是有风险的,所以需要进行强制登录,若用户未登录就访问图书列表或添加图书等页面,强制跳转到登录页面

实现思路分析:

用户登录时,我们已经把登录用户的信息存储在了 Session 中,那就可以通过 Session 中的信息来判断用户是否登录

  1. 如果 Session 中可以获取到登录用户的信息,说明用户已经登录了,可以进行后续操作
  2. 如果 Session 中获取不到登录用户的信息,说明用户未登录,则跳转登录页面

 tip:修改常量 session 的 key,就需要修改所有使用到 key 的地方,基于高内聚低耦合的思想,我们把常量几种在一个类里

创建 Constants

package com.example.springbook.constant;

public class Constants {
    // 成功
    public static final int SUCCESS_CODE = 200;
    // 程序报错
    public static final int FAIL_CODE = -2;
    // 未登录
    public static final int UNLOGIN_CODE = -1;

    public static final String SESSION_USER_KEY = "session_user_key";
}

常量命名规则

常量命名全部大写,单词间用下划线隔开,力求语义表达完整清楚,不要在意名字长度

正例:MAX_STOCK_COUNT / CACHE_EXPIRED_TIME

反例:COUNT / TIME

对所有后端返回的数据进行封装,其中 data 为之前接口返回的数据

package com.example.springbook.model;

import com.example.springbook.constant.Constants;
import lombok.Data;

@Data
public class Result<T> {
    private int code; // 200 用户登录成功;-1 用户未登录;-2 用户登录成功,但程序出错 (业务状态码,非 http 状态码)
    private String errMsg; // 错误信息
    private T data;

    // 成功时
    public static <T> Result success(T data) {
        Result result = new Result();
        result.setCode(Constants.SUCCESS_CODE);
        result.setErrMsg("");
        result.setData(data);
        return result;
    }

    // 用户未登录
    public static <T> Result unlogin() {
        Result result = new Result();
        result.setCode(Constants.UNLOGIN_CODE);
        result.setErrMsg("用户未登录");
        return result;
    }

    // 程序出错
    public static <T> Result fail(T data) {
        Result result = new Result();
        result.setCode(Constants.FAIL_CODE);
        result.setErrMsg("程序发生错误!");
        return result;
    }

    public static <T> Result fail() {
        Result result = new Result();
        result.setCode(Constants.FAIL_CODE);
        result.setErrMsg("程序发生错误!");
        return result;
    }
}

BookController 修改代码

    // 查询图书信息(翻页使用)
    @RequestMapping("/getListByPage")
    public Result<PageResponse<BookInfo>> getListByPage(PageRequest pageRequest, HttpSession session) {
        log.info("获取图书列表,pageRequest:{}", pageRequest);
        if (session.getAttribute(Constants.SESSION_USER_KEY) == null ||
                !StringUtils.hasLength((String)session.getAttribute(Constants.SESSION_USER_KEY))) {
            // 用户未登录
            return Result.unlogin();
        }
        // 参数校验还没写,包括不能为空,不等于负数等等
        PageResponse<BookInfo> bookInfoPageResponse = new PageResponse<>();
        try {
            bookInfoPageResponse = bookService.getListByPage(pageRequest);
        } catch (Exception e) {
            log.error("获取图书列表失败");
            return Result.fail();
        }
        return Result.success(bookInfoPageResponse);
    }

前端代码:book_list.html

测试

1. 用户未登录情况,直接访问 http://127.0.0.1:9090/book_list.html

其跳转到了登录页面 

2. 登录用户,图书列表正常返回

2.2.12 思考

强制登录的模块,我们只实现了一个图书列表,上述还有图书修改,图书删除等接口,也需要逐个实现。 如果应用程序功能更多的话,这样写下来会非常浪费时间,并且容易出错。

有没有更简单的处理办法呢?

接下来看一下 SpringBoot 对于这种 "统一问题" 的处理办法。

3. MyBatis Generator

MyBatis Generator 是一个为 MyBatis 框架设计的代码生成工具,它可以根据数据库表结构自动生成相应的 Java Model、Mapper 接口以及 SQL 映射文件,简化数据访问层的编码工作,使得开发者可以更专注于业务逻辑的实现。

接下来我们看下,如何使用 MyBatis Generator 来生成代码。

3.1 引入插件

            <plugin>
                <groupId>org.mybatis.generator</groupId>
                <artifactId>mybatis-generator-maven-plugin</artifactId>
                <version>1.3.6</version>
                <executions>
                    <execution>
                        <id>Generate MyBatis Artifacts</id>
                        <phase>deploy</phase>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                    </execution>
                </executions>
                <configuration>
                    <!--generator配置文件所在位置-->
                    <configurationFile>src/main/resources/generator/generator.xml</configurationFile>
                    <!-- 允许覆盖生成的文件, mapxml不会覆盖, 采用追加的方式-->
                    <overwrite>true</overwrite>
                    <verbose>true</verbose>
                    <!--将当前pom的依赖项添加到生成器的类路径中-->
                    <includeCompileDependencies>true</includeCompileDependencies>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>com.mysql</groupId>
                        <artifactId>mysql-connector-j</artifactId>
                        <version>8.0.33</version>
                    </dependency>
                </dependencies>
            </plugin>

3.2 添加 generatorConfig.xml 并修改

文件路径和上述配置保持一致:

完善文件内容

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
        PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
        "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<!-- 配置生成器 -->
<generatorConfiguration>

    <!-- 一个数据库一个context -->
    <context id="MysqlTables" targetRuntime="MyBatis3Simple" defaultModelType="flat">
        <!--去除注释-->
        <commentGenerator>
            <property name="suppressDate" value="true"/>
            <property name="suppressAllComments" value="true" />
        </commentGenerator>

        <!--数据库链接信息-->
        <jdbcConnection driverClass="com.mysql.jdbc.Driver"
                        connectionURL="jdbc:mysql://127.0.0.1:3306/mybatis_test?serverTimezone=Asia/Shanghai&amp;nullCatalogMeansCurrent=true"
                        userId="root"
                        password="111111">
        </jdbcConnection>

        <!-- 生成实体类 -->
        <javaModelGenerator targetPackage="com.example.demo.model" targetProject="src/main/java" >
            <property name="enableSubPackages" value="false"/>
            <property name="trimStrings" value="true"/>
        </javaModelGenerator>
        <!-- 生成mapxml文件 -->
        <sqlMapGenerator targetPackage="mapper" targetProject="src/main/resources" >
            <property name="enableSubPackages" value="false" />
        </sqlMapGenerator>
        <!-- 生成mapxml对应client,也就是接口dao -->
        <javaClientGenerator targetPackage="com.example.demo.mapper" targetProject="src/main/java" type="XMLMAPPER" >
            <property name="enableSubPackages" value="false" />
        </javaClientGenerator>
        <!-- table可以有多个,每个数据库中的表都可以写一个table,tableName表示要匹配的数据库表,也可以在tableName属性中通过使用%通配符来匹配所有数据库表,只有匹配的表才会自动生成文件 -->
        <table tableName="user">
            <property name="useActualColumnNames" value="false" />
            <!-- 数据库表主键 -->
            <generatedKey column="id" sqlStatement="Mysql" identity="true" />
        </table>
    </context>
</generatorConfiguration>

3.3 生成文件

双击运行即可

;