目录
- MyBatis的进阶操作
- 动态SQL
- `<if>`标签
- `<trim>`标签
- `<where>`标签
- `<set>`标签
- `<foreach>`标签
- `<include>`标签
- 练习
- 表白墙
- 数据准备
- 引⼊MyBatis 和 MySQL驱动依赖
- 配置MySQL账号密码
- 编写后端代码
- 测试
- 图书管理系统
- 数据库表设计
- 引⼊MyBatis 和MySQL 驱动依赖
- 配置数据库&⽇志
- Model创建
- ⽤⼾登录
- 约定前后端交互接⼝
- 实现服务器代码
- 测试
- 添加图书
- 约定前后端交互接⼝
- 实现服务器代码
- 实现客⼾端代码
- 测试
- 图书列表
- 需求分析
- 约定前后端交互接口
- 实现服务器代码
- 实现客⼾端代码
- 修改后端代码
- 测试
- 修改图书
- 约定前后端交互接⼝
- 实现服务器代码
- 实现客⼾端代码
- 测试
- 删除图书
- 约定前后端交互接⼝
- 实现客⼾端代码
- 测试
- 批量删除
- 约定前后端交互接⼝
- 实现服务器代码
- 实现客⼾端代码
- 测试
- 强制登录
- 实现思路分析
- 实现服务器代码
- 实现客⼾端代码
- 测试
- 思考
- 总结
MyBatis的进阶操作
- 学习MyBatis的动态SQL查询
- 掌握MyBatis在项⽬中的应⽤, 可以使⽤Spring MVC完成⼀些基础的功能
动态SQL
动态 SQL 是Mybatis的强⼤特性之⼀,能够完成不同条件下不同的 sql 拼接,即根据需求动态拼接SQL语句。
可以参考官⽅⽂档:Mybatis动态sql
<if>
标签
在注册⽤⼾的时候,可能会有这样⼀个问题,如下图所⽰:
注册分为两种字段:必填字段和非必填字段,那如果在添加⽤⼾的时候有不确定的字段传⼊,程序应该如何实现呢?
比如,如果性别为空,则
insert into userinfo(username,password,age,phone) valuss(?,?,?,?)
如果性别不为空,则
insert into userinfo(username,password,age,gender,phone) valuss(?,?,?,?,?)
这个时候就需要使⽤动态标签来判断了,⽐如添加的时候性别 gender
为⾮必填字段,具体实现如下:
需求:根据用户的输入情况,进行插入
接⼝定义:
Integer insertUserByCondition(UserInfo userInfo);
Mapper.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.Hsu.demo.mapper.UserInfo2Mapper">
<insert id="insertByXML">
insert into userinfo(username,password,age,
<if test="gender!=null">
gender,
</if>
phone)
values(#{username},#{password},#{age},
<if test="gender!=null">
#{gender},
</if>
#{phone})
</insert>
</mapper>
测试
@Test
void insertByXML() {
UserInfo userInfo=new UserInfo();
userInfo.setUsername("2023");
userInfo.setPassword("2023pwd");
userInfo.setAge(2);
//userInfo.setGender(1);
userInfo.setPhone("12345678");
userInfo2Mapper.insertByXML(userInfo);
}
或者使⽤注解⽅式(不推荐,XML易读一些)
把上⾯SQL(包括标签), 使⽤ <script></script>
标签括起来就可以
@Insert("<script>" +
"insert into userinfo (username,password,age," +
"<if test='gender!=null'>gender,</if>" +
"phone)" +
" values(#{username},#{password},#{age}," +
"<if test='gender!=null'>#{gender},</if>" +
"#{phone})" +
"</script>")
Integer insert(UserInfo userInfo);
注解的方式:
- 把全部的sql语句放在
script
标签下- 使用if标签
注意 test 中的 gender,是传⼊对象中的属性,不是数据库字段
在
if test='gender!=null'
中,我们判断的是这个值是否为null,而数据库字段(也就是在这个语句后的gender)是没有值的,只能是根据参数判断,而参数是#{gender}
,对应的是Java属性Q: 可不可以不进⾏判断(即不使用动态SQL), 不想填的字段就直接把字段设置为null呢?
A: 不可以, 这种情况下, 如果你用的是动态SQL,数据库在gender字段设置有默认值, 就会设置为默认值。如果你用的是普通SQL,那就会强行置为null或者可能出错
这是普通SQL
@Insert("insert into userinfo (username,password,age,gender,phone) " + "values (#{username},#{password},#{age},#{gender},#{phone})") Integer insert(UserInfo userInfo);
这是动态SQL
@Insert("<script>" + "insert into userinfo (username,password,age," + "<if test='gender!=null'>gender,</if>" + "phone)" + " values(#{username},#{password},#{age}," + "<if test='gender!=null'>#{gender},</if>" + "#{phone})" + "</script>") Integer insert(UserInfo userInfo);
- 第一个语句是标准的SQL插入语句,它会将所有列(username, password, age, gender, phone)的值插入到userinfo表中。这要求调用时必须提供所有这些字段的值,否则可能会出错或插入空值。
- 第二个语句使用MyBatis的
<if>
标签来创建动态SQL。这允许根据传入的参数动态更改SQL语句的结构。在这个例子中,如果gender
字段的值不为null
,则gender
列和对应的值会被包含在插入语句中。这种方式使得SQL语句更加灵活,可以根据传入的参数值来适应不同的情况,只插入有用的数据。
什么时候用注解?什么时候用XML?
全凭个人爱好
进入公司后用什么?
看公司/团队
看同事用什么
同事用什么的都有,就随便
建议:简单SQL用注解,动态SQL用XML
<trim>
标签
这个标签可以帮我们去除多余的字符
之前的插⼊⽤⼾功能,只是有⼀个 gender 字段可能是选填项,如果有多个字段,⼀般考虑使⽤标签结合标签,对多个字段都采取动态⽣成的⽅式。
标签中有如下属性:
- prefix:表⽰整个语句块,添加以prefix的值作为前缀
- suffix:表⽰整个语句块,添加以suffix的值作为后缀
- prefixOverrides:表⽰整个语句块要去除掉的前缀
- suffixOverrides:表⽰整个语句块要去除掉的后缀
调整 Mapper.xml 的插⼊语句为:
<insert id="insertByXML">
insert into userinfo(
<!--去除后缀的逗号-->
<trim 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>
<if test="phone!=null">
phone
</if>
</trim>
)
values(
<trim 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>
<if test="phone!=null">
#{phone}
</if>
</trim>
)
</insert>
去除多余的后缀
,
或者使⽤注解⽅式(不推荐)
@Insert("<script>" +
"INSERT INTO userinfo " +
"<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>" +
"<if test='phone!=null'>phone,</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>" +
"<if test='phone!=null'>#{phone}</if>" +
"</trim>"+
"</script>")
Integer insertUserByCondition(UserInfo userInfo);
在以上 sql 动态解析时,会将第⼀个 部分做如下处理:
- 基于
prefix
配置,开始部分加上(
- 基于
suffix
配置,结束部分加上)
- 多个 组织的语句都以
,
结尾,在最后拼接好的字符串还会以,
结尾,会基于suffixOverrides
配置去掉最后⼀个,
- 注意
<if test="username !=null">
中的username
是传⼊对象的属性
<where>
标签
看下⾯这个场景, 系统会根据我们的筛选条件, 动态组装 where 条件
这种如何实现呢?
接下来我们看代码实现:
需求: 传⼊的⽤⼾对象,根据属性做 where 条件查询,⽤⼾对象中属性不为 null 的,都为查询条件. 如 username 为 “a”,则查询条件为 where username=“a”
原有SQL
select * from userinfo where username=XXX and age=XXX and gender=XXX
接⼝定义:
List<UserInfo> selectByCondition(UserInfo userInfo);
Mapper.xml实现
<select id="selectByCondition" resultType="com.Hsu.demo.model.UserInfo">
select * from userinfo
<where>
<if test="username!=null">
username =#{username}
</if>
<if test="age!=null">
and age =#{age}
</if>
<if test="gender!=null">
and gender =#{gender}
</if>
</where>
</select>
测试代码
@Test
void selectByCondition() {
UserInfo userInfo=new UserInfo();
userInfo.setUsername("2023");
userInfo.setGender(1);
userInfo.setAge(2);
List<UserInfo> userInfos=userInfo2Mapper.selectByCondition(userInfo);
log.info(userInfos.toString());
}
<where>
只会在⼦元素有内容的情况下才插⼊where⼦句,⽽且会⾃动去除⼦句的开头的AND或OR。如果查询条件都为空,where标签会自动去除where关键字,还会帮我们去除最前面的and,注意是前面的,后面的不行以上标签也可以使⽤
<trim prefix="where" prefixOverrides="and">
替换, 但是此种情况下, 当⼦元素都没有内容时, where关键字也会保留,这样就会出错了<select id="selectByCondition" resultType="com.Hsu.demo.model.UserInfo"> select * from userinfo where <!--去除前缀的and--> <trim prefixOverrides="and"> <if test="username!=null"> username =#{username} </if> <if test="age!=null"> and age =#{age} </if> <if test="gender!=null"> and gender =#{gender} </if> </trim> </select>
或者使⽤注解⽅式
@Select("<script>" +
"select * from userinfo" +
" <where>" +
" <if test='username!=null'>" +
" username =#{username}" +
" </if>" +
" <if test='age!=null'>" +
" and age =#{age}" +
" </if>" +
" <if test='gender!=null'>" +
" and gender =#{gender}" +
" </if>" +
" </where>" +
"</script>")
List<UserInfo> selectByCondition3(UserInfo userInfo);
里面改成了单引号
‘
优雅一点
<set>
标签
主要出现在update语句
update userinfo set username=XXX, age=XXX, gender=XXX where id=XXX
需求: 根据传⼊的⽤⼾对象属性来更新⽤⼾数据,可以使⽤标签来指定动态内容.
接⼝定义: 根据传⼊的⽤⼾ id 属性,修改其他不为 null 的属性
Integer updateByCondition2(UserInfo userInfo);
Mapper.xml
<update id="updateByCondition2">
update userinfo
<set>
<if test="username!=null">
username=#{username},
</if>
<if test="age!=null">
age=#{age},
</if>
<if test="gender!=null">
gender=#{gender}
</if>
</set>
where id=22
</update>
测试代码
@Test
void updateByCondition2() {
UserInfo userInfo=new UserInfo();
userInfo.setUsername("2025");
userInfo.setAge(20);
userInfo.setGender(2);
userInfo2Mapper.updateByCondition2(userInfo);
}
<set>
:动态的在SQL语句中插⼊set关键字,并会删掉额外的逗号. (⽤于update语句中)但set标签用的update语句如果这些更新条件都为空那么就出现报错了,如果测试代码部分的set语句都注释掉了就会出现以下报错
这个没有办法解决,因为这本身就不符合SQL语句的格式,也不符合常理
以上标签也可以使⽤
<trim prefixOverrides=",">
替换。<update id="updateByCondition"> update userinfo set <!--把后缀逗号去掉--> <trim prefixOverrides=","> <if test="username!=null"> username=#{username}, </if> <if test="age!=null"> age=#{age}, </if> <if test="gender!=null"> gender=#{gender} </if> </trim> where id=22 </update>
或者使⽤注解⽅式
@Update("<script>" +
"update userinfo " +
"<set>" +
"<if test='username!=null'>username=#{username},</if>" +
"<if test='age!=null'>age=#{age},</if>" +
"<if test='deleteFlag!=null'>delete_flag=#{deleteFlag},</if>" +
"</set>" +
"where id=#{id}" +
"</script>")
Integer updateUserByCondition(UserInfo userInfo);
<foreach>
标签
delete from userinfo where id in(XXX)
这样的语句,其实括号里面就是一个循环遍历
这个标签可以用于:批量删除、批量增加等等
对集合进⾏遍历时可以使⽤该标签。标签有如下属性:
- collection:绑定⽅法参数中的集合,如 List,Set,Map或数组对象
- item:遍历时的每⼀个对象
- open:语句块开头的字符串
- close:语句块结束的字符串
- separator:每次遍历之间间隔的字符串
需求: 根据多个userid, 删除⽤⼾数据
接⼝⽅法:
//批量删除
Integer batchDelete(List<Integer> ids);
Mapper.xml 中新增删除 sql:
<delete id="batchDelete">
delete from userinfo
where id in
<!--
集合名:ids
分隔符:,
名字:id
开始:(
结束:)
-->
<foreach collection="ids" separator="," item="id" open="(" close=")">
#{id}
</foreach>
</delete>
这个代码就相当于
List<Integer> ids; for(Integer id:ids){ }
这样子的
测试代码
@Test
void batchDelete() {
userInfo2Mapper.batchDelete(Arrays.asList(20,21,22));
}
或者使⽤注解⽅式:
@Delete("<script>" +
"delete from userinfo where id in" +
"<foreach collection='ids' item='id' separator=',' open='(' close=')
"#{id}" +
"</foreach>" +
"</script>")
Integer deleteUser(Integer id);
<include>
标签
问题分析:
- 在xml映射⽂件中配置的SQL,有时可能会存在很多重复的⽚段,此时就会存在很多冗余的代码
我们可以对重复的代码⽚段进⾏抽取,将其通过 <sql>
标签封装到⼀个SQL⽚段,然后再通过<include>
标签进⾏引⽤。
<sql>
:定义可重⽤的SQL⽚段<include>
:通过属性refid,指定包含的SQL⽚段
<sql id="selectTable">
select * from userinfo
</sql>
通过 <include>
标签在原来抽取的地⽅进⾏引⽤。操作如下:
<select id="selectByCondition" resultType="com.Hsu.demo.model.UserInfo">
<include refid="selectTable"></include>
<where>
<if test="username!=null">
username =#{username}
</if>
<if test="age!=null">
and age =#{age}
</if>
<if test="gender!=null">
and gender =#{gender}
</if>
</where>
</select>
把一些重复的SQL可以提取出来,使用<sql>
标签来标识,使用时使用<include>
标签
<include refid="selectTable"></include>
里refid
的值要和select * from userinfo</sql>
的id
的值相同
练习
表白墙
之前的表⽩墙(在demo这个project中), ⼀旦服务器重启, 数据仍然会丢失.
要想数据不丢失, 需要把数据存储在数据库中. 接下来咱们借助MyBatis来实现数据的操作
数据准备
创建数据库
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;
不用utf8是因为有些表情包可能存不进去
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都⽀持⾃动更新,且可以有多列
引⼊MyBatis 和 MySQL驱动依赖
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
或者使⽤插件EditStarters来引⼊依赖
在pom.xml中右键Generate->EditStarters
配置MySQL账号密码
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/message?characterEncoding=utf8&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 配置打印 MyBatis⽇志
map-underscore-to-camel-case: true # 配置驼峰⾃动转换
编写后端代码
Model
package com.example.demo.controller;
import lombok.Data;
import java.util.Date;
@Data
public class MessageInfo {
private Integer id;
private String from;
private String to;
private String message;
private Integer deleteFlag;
private Date createTime;
private Date updateTime;
}
MessageMapper
package com.example.demo.mapper;
import com.example.demo.model.MessageInfo;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
//这是来自MyBatis的
@Mapper
public interface MessageMapper {
@Insert("insert into message_info (`from`,`to`,`message`)" +
" values(#{from},#{to},#{message})")
public void insertMessage(MessageInfo messageInfo);
@Select("select * from message_info where delete_flag=0")
List<MessageInfo> selectAllMessage();
}
MessageService
package com.example.demo.service;
import com.example.demo.mapper.MessageMapper;
import com.example.demo.model.MessageInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
//交给Spring管理,属于Spring的注解
@Service
public class MessageService {
@Autowired
private MessageMapper messageMapper;
//根据前端输入的信息将其添加到数据库中
public void addMessage(MessageInfo messageInfo) {
messageMapper.insertMessage(messageInfo);
}
public List<MessageInfo> getMessageInfo() {
return messageMapper.selectAllMessage();
}
}
MessageController
package com.example.demo.controller;
import com.example.demo.model.MessageInfo;
import com.example.demo.service.MessageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/message")
public class MessageController {
//注入对象可以 属性@Autowired 注入,也可 构造方法 或者 setter
@Autowired
private MessageService messageService;
@RequestMapping("/publish")
public Boolean publishMessage(MessageInfo messageInfo){
//进行参数校验
//三个任意一个为空
if(!StringUtils.hasLength(messageInfo.getFrom())
|| !StringUtils.hasLength(messageInfo.getTo())
|| !StringUtils.hasLength(messageInfo.getMessage())){
return false;
}
//添加留言
messageService.addMessage(messageInfo);
return true;
}
@RequestMapping("/getMessageInfo")
public List<MessageInfo> getMessage(){
//直接返回留言
return messageService.getMessageInfo();
}
}
测试
部署程序, 验证服务器是否能正确响应: http://127.0.0.1:8080/messagewall.html
输⼊留⾔信息, 点击提交, 发现⻚⾯列表显⽰新的数据, 并且数据库中也添加了⼀条记录.
重启服务, ⻚⾯显⽰不变.
项目开发中,遇到问题解决思路
- 定位
- 解决
定位时,先定位问题发生在前端还是后端,还是前后端交互
按F12,看浏览器是否报错
如果没有报错,检查请求是否发到后端了,可以在后端打印日志看看
检查后端日志有没有报错
如果没有问题,那就是前端或者前后端交互出了问题
检查后端接口是否OK
不使用前端访问,直接使用Postman或者http请求测试后端接口
如果后端接口正常,那就是前端或者前后端交互出了问题
如果自查后代码确实没发生错误
考虑是不是缓存问题,前端缓存/后端缓存
后端缓存就点击Maven的clean
前端缓存就清空浏览器缓存
开发项目流程
- 需求确认阶段:需求分析、需求评审
- 开发阶段
- 方案设计
- 接口定义
- 开发业务代码
- 测试:自测、联调(和其他团队一起联合测试)
- 提测阶段:测试人员
- 上线发布阶段
图书管理系统
前面的图书管理系统, 只完成了⽤⼾登录和图书列表, 并且数据是Mock的. 接下来我们把其他功能进⾏完善.
功能列表:
- ⽤⼾登录
- 图书列表
- 图书的增删改查
- 翻⻚功能
数据库表设计
数据库表是应⽤程序开发中的⼀个重要环节, 数据库表的设计往往会决定我们的应⽤需求是否能顺利实现, 甚⾄决定我们的实现⽅式. 如何设计表以及这些表有哪些字段, 这些表存在哪些关系 也是⾮常重要的.
数据库表设计是依据业务需求来设计的. 如何设计出优秀的数据库表, 与经验有很⼤关系.
数据库表通常分两种: 实体表和关系表.
分析我们的需求, 图书管理系统相对来说⽐较简单, 只有两个实体: 用户和图书, 并且用户和图书之间没有关联关系
实体表:我们的图书管理系统就是用户表、图书表
关系表:实体表之间的关联关系,但我们这目前没有
表的具体字段设计, 也与需求有关.
用户表有用户名和密码即可(复杂的业务可能还涉及昵称, 年龄等资料)
图书表有哪些字段, 也是参考需求⻚⾯(通常不是⼀个⻚⾯决定的, ⽽是要对整个系统进⾏全⾯分析观察后定的)
创建数据库 book_test
-- 创建数据库
DROP DATABASE IF EXISTS book_test;
CREATE DATABASE book_test DEFAULT CHARACTER SET utf8mb4;
-- ⽤⼾表
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,'民主与建设出版社');
实体表
- 用户表 (
user_info
):
- 实体属性: 包括用户ID、用户名、密码等。
- 安全性: 密码应该通过哈希存储,而不是明文,以提高安全性。
- 可扩展性: 如果未来需要,可以容易地添加更多字段,如电子邮件、电话号码或用户角色。
- 图书表 (
book_info
):
- 实体属性: 包括书籍ID、书名、作者、库存数量、价格、出版社和状态等。
- 灵活性: 通过
status
字段可以处理不同的业务逻辑,如借阅状态或损坏状态。- 标准化: 出版社可以进一步标准化为另一个表,以避免冗余和保持数据一致性。
关系表
虽然你提供的脚本中没有明确的关系表,但在一个完整的图书管理系统中,你可能需要以下类型的关系表:
- 借阅关系表:
- 用途: 跟踪哪个用户借了哪本书,以及借阅和归还的日期。
- 字段: 包括用户ID、书籍ID、借阅日期和归还日期等。
- 书籍-作者关系表 (如果一本书可以有多个作者的话):
- 用途: 为书籍和作者之间的多对多关系提供映射。
- 字段: 书籍ID和作者ID。
引⼊MyBatis 和MySQL 驱动依赖
修改pom⽂件
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
配置数据库&⽇志
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/book_test?characterEncoding=utf8&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 配置打印 MyBatis⽇志
map-underscore-to-camel-case: true # 配置驼峰⾃动转换
# 设置⽇志⽂件的⽂件名
logging:
file:
name: /logger/spring-book.log
Model创建
@Data
public class UserInfo {
private Integer id;
private String userName;
private String password;
private Integer deleteFlag;
private Date createTime;
private Date updateTime;
}
@Data
public class BookInfo {
//图书ID
private Integer id;
//书名
private String bookName;
//作者
private String author;
//数量
private Integer count;
//价格
private BigDecimal price;
//出版社
private String publish;
//状态
private Integer status;//1-可借阅 2-不可借阅
private String statusCN;
private Date createTime;
private Date updateTime;
}
⽤⼾登录
约定前后端交互接⼝
[请求]
/user/login
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
[参数]
name=zhangsan&password=123456
[响应]
true //账号密码验证正确, 否则返回false
浏览器给服务器发送 /user/login
这样的 HTTP 请求, 服务器给浏览器返回了⼀个Boolean 类型的数据. 返回true, 表⽰ 账号密码验证正确
实现服务器代码
控制层:
从数据库中, 根据名称查询⽤⼾, 如果可以查到, 并且密码⼀致, 就认为登录成功
创建UserController
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("/login")
public Boolean login(String userName, String password, HttpSession session) {
//校验参数
if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {
return false;
}
//1.根据用户名去查找用户信息
UserInfo userInfo = userService.getUserInfoByName(userName);
if(userInfo==null||userInfo.getId()<=0){
return false;
}
//2.比对密码是否正确
//验证账号密码是否正确
//if(userName.equals("admin")这种写法,如果userName为null就会报空指针异常
//这是开发习惯,要养成
if(password.equals(userInfo.getPassword())){
//密码正确
//存session
userInfo.setPassword("");
session.setAttribute("userInfo", userInfo);
return true;
}
return false;
}
}
这里存session的时候,
userinfo.setPassword("")
,选择了把密码设置为空字符串,是因为session只有服务端才可见,避免有程序员出现了打印这个session的情况,以下是ChatGPT4.0的回答:
业务层:
创建UserService
@Service
public class UserService {
@Autowired
private UserInfoMapper userInfoMapper;
public UserInfo getUserInfoByName(String name){
return userInfoMapper.selectUserByName(name);
}
}
数据层:
创建UserInfoMapper
@Mapper
public interface UserInfoMapper {
/**
* 根据用户名称查询用户信息
* @param name
* @return
*/
@Select("select * from user_info where user_name = #{name}")
UserInfo selectUserByName(String name);
}
访问数据库, 使⽤MyBatis来实现, 所以把之前dao路径下的⽂件可以删掉, ⽤mapper⽬录来代替, 创建UserInfoMapper
当然, 继续使⽤dao⽬录也可以,此处为建议
dao和mapper通常都被认为是数据库层
测试
部署程序, 验证服务器是否能正确返回数据 (使⽤ URL http://127.0.0.1:8080/user/login?name=admin&password=admin 即可)
添加图书
约定前后端交互接⼝
[请求]
/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补充代码
先进⾏参数校验, 校验通过了进⾏图书添加
实际开发中, 后端开发⼈员不关注前端是否进⾏了参数校验, ⼀律进⾏校验
原因是: 后端接⼝可能会被⿊客攻击, 不通过前端来访问, 如果后端不进⾏校验, 会产⽣脏数据.
(但在学习阶段, 暂不涉及安全领域模块的开发, 防攻击⼀般是企业统⼀来做)
@Slf4j
@RestController
@RequestMapping("/book")
public class BookController {
@Autowired//告诉Spring,从容器中取出这个对象,赋值给当前对象的属性
private BookService bookService;
@RequestMapping("/addBook")
public String addBook(BookInfo bookInfo) {
log.info("接收到添加图书请求,bookInfo:{}",bookInfo);
//参数校验
if (!StringUtils.hasLength(bookInfo.getBookName())
|| !StringUtils.hasLength(bookInfo.getAuthor())
|| bookInfo.getCount() < 0
|| bookInfo.getPrice() == null
|| !StringUtils.hasLength(bookInfo.getPublish())) {
return "参数校验失败,请检查入参";
}
Integer result=bookService.addBook(bookInfo);
if(result<=0){
//这部分日志打印是方便调试的
log.error("添加图书出错:bookInfo:{}",bookInfo);
//因为Controller是对外服务的,返回时不要返回太专业的内容
return "添加图书出错,请联系管理员";
}
return "";
}
}
比较这两种情况的参数校验方式:
//参数校验 if (!StringUtils.hasLength(bookInfo.getBookName()) || !StringUtils.hasLength(bookInfo.getAuthor()) || bookInfo.getCount() < 0 || bookInfo.getPrice() == null || !StringUtils.hasLength(bookInfo.getPublish())) { return "参数校验失败,请检查入参"; } bookService.addBook(bookInfo);
//参数校验 if (StringUtils.hasLength(bookInfo.getBookName()) && StringUtils.hasLength(bookInfo.getAuthor()) && bookInfo.getCount() >=0 && bookInfo.getPrice() != null && StringUtils.hasLength(bookInfo.getPublish())) { //参数校验成功,进行数据插入 bookService.addBook(bookInfo); }
这两种写法内容是一样的
- 第一种参数校验失败时,直接就return结束了,业务逻辑是和这个判断条件平行在第一层的
- 第二种参数校验成功时,再执行后续,业务逻辑是在第二层了
- 开发规范:一个方法不推荐超过5层,更推荐第一种方式
业务层:
在BookService中补充代码
//通过注解告诉Spring帮我们把BookService存入容器中
@Slf4j
@Service
public class BookService {
@Autowired
private BookInfoMapper bookInfoMapper;
/**
* 添加图书
* @param bookInfo
* @return
*/
public Integer addBook(BookInfo bookInfo){
Integer result=0;
try {
result=bookInfoMapper.insertBook(bookInfo);
}catch (Exception e){
log.error("添加图书出错,e:{}",e);
}
return result;
}
}
数据层:
创建BookInfoMapper⽂件
@Mapper
public interface BookInfoMapper {
@Insert("insert into book_info(book_name,author,count,price,publish,status)" +
" values(#{bookName},#{author},#{count},#{price},#{publish},#{status})")
Integer insertBook(BookInfo bookInfo);
}
创建新的BookInfoMapper⽂件
实现客⼾端代码
提供的前端⻚⾯中, js已经提前留了空位
<button type="button" class="btn btn-info btn-lg" onclick="add()">确定</button>
点击确定按钮, 会执⾏ add()
⽅法
补全add()的⽅法
提交整个表单的数据: $(“#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==""){
//图书添加成功
location.href="book_list.html";
}else{
alert(result);
}
}
});
}
测试
先测试后端接口,通过Postman
添加图书前, 数据库内容:
select * from book_info;
点击添加图书按钮, 跳转到添加图书的⻚⾯, 填写图书信息
点击确定按钮, ⻚⾯跳转到图书列表⻚
图书列表还未实现, ⻚⾯上看不出来效果.
查看数据库数据:
select * from book_info;
数据插⼊成功
测试输⼊不合法的场景, ⽐如什么信息都不填, 直接点击确定
⻚⾯也得到正确响应
图书列表
可以看到, 添加图书之后, 跳转到图书列表⻚⾯, 并没有显⽰刚才添加的图书信息, 接下来我们来实现图书列表
需求分析
我们之前做的表⽩墙查询功能,是将数据库中所有的数据查询出来并展⽰到⻚⾯上,试想如果数据库中的数据有很多(假设有⼗⼏万条)的时候,将数据全部展⽰出来肯定不现实,那如何解决这个问题呢?
使⽤分⻚解决这个问题。每次只展⽰⼀⻚的数据,⽐如:⼀⻚展⽰10条数据,如果还想看其他的数据,可以通过点击⻚码进⾏查询。
翻页操作前端后端都可完成
前端完成的话需要一次性拿到所有数据:这样的话缺点是第一次请求响应时间长并且如果数据进行修改,前端是无法感知的;这样的话优点是后续响应快
后端完成的话优缺点就是和前端相反的
相对比肯定是推荐后端完成
分⻚时, 数据是如何展⽰的呢
第1⻚: 显⽰1-10 条的数据
第2⻚: 显⽰11-20 条的数据
第3⻚: 显⽰21-30 条的数据
以此类推…
要想实现这个功能, 从数据库中进⾏分⻚查询,我们要使⽤ LIMIT 关键字,格式为:limit 开始索引每⻚显⽰的条数(开始索引从0开始)
我们先伪造更多的数据:
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语句,发现: 开始索引⼀直在改变, 每⻚显⽰条数是固定的
SELECT * FROM book_info LIMIT #{offset},#{pageSize}
后端实现翻页功能:
limit
,offset
limit
就是每页要显示的条数开始索引的计算公式: 开始索引 = (当前⻚码 - 1) * 每⻚显⽰条数,即
(currentPage-1)*pageSize
select count(1) from book_info where status!=0
查询图书表有多少条有效数据
我们继续基于前端⻚⾯, 继续分析, 得出以下结论:
- 前端在发起查询请求时,需要向服务端传递的参数
currentPage
当前⻚码,默认值为1pageSize
每⻚显⽰条数,默认值为10(就是limit
)
为了项⽬更好的扩展性, 通常不设置固定值, ⽽是以参数的形式来进⾏传递
扩展性: 软件系统具备⾯对未来需求变化⽽进⾏扩展的能⼒
⽐如当前需求⼀⻚显⽰10条, 后期需求改为⼀⻚显⽰20条, 后端代码不需要任何修改
- 后端响应时, 需要响应给前端的数据
records
所查询到的数据列表,表示当前页的内容(存储到 List 集合中)total
总记录数 (⽤于告诉前端显⽰多少⻚(假如一共115条内容,每页10条,那下面的按键就要从1到12),显⽰⻚数为:(total + pageSize -1)/pageSize
,这个公式可以自己举例计算一下
显⽰⻚数
totalPage
计算公式为 :total % pagesize == 0 ? total / pagesize : (total /pagesize)+1
;pagesize - 1
是total / pageSize
的最⼤的余数,所以(total + pagesize -1) / pagesize
就得到总⻚数
翻⻚请求和响应部分, 我们通常封装在两个对象中
为了更好的拓展性,返回的结果最好是一个对象
翻⻚请求对象
@Data
public class PageRequest {
//当前页
private Integer currentPage=1;
//每页显示条数
private Integer pageSize=10;//这个就是limit
private Integer offset;
}
我们需要根据 currentPage
和 pageSize
,计算出来开始索引
PageRequest修改为:
@Data
public class PageRequest {
//当前页
private Integer currentPage=1;
//每页显示条数
private Integer pageSize=10;//这个就是limit
//表示要跳过的记录数,以便到达当前页的第一条记录
private Integer offset;
public Integer getOffset(){
return (currentPage-1) * pageSize;
}
}
这里给
currentPage
和pageSize
一些默认值也不是很合理。如果用户在第一页显示后在网页面选择将pageSize
改为20,那么按照我们上面的逻辑就会显示20-40条信息,按道理应该是10到30的数据
翻⻚列表结果类
@Data
//为了更好的拓展性,返回的结果最好是一个对象
public class PageResult<T> {
//当前页所有记录
private List<T> records;
//总记录数
private Integer total;
//分页请求的引用
//包含了分页请求的当前页码、每页显示条数、要跳过的记录数
private PageRequest pageRequest;
public PageResult(List<T> records, Integer total, PageRequest pageRequest){
this.records=records;
this.total=total;
this.pageRequest=pageRequest;
}
}
返回结果中, 使⽤泛型来定义记录的类型
图书类
@Data
public class BookInfo {
//图书ID
private Integer id;
//书名
private String bookName;
//作者
private String author;
//数量
private Integer count;
//价格
//前端展示精度
@JsonFormat(shape = JsonFormat.Shape.STRING)
private BigDecimal price;
//出版社
private String publish;
//状态
private Integer status;//1-可借阅 2-不可借阅
private String statusCN;
private Date createTime;
private Date updateTime;
}
基于以上分析, 我们来约定前后端交互接⼝
约定前后端交互接口
[请求]
/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
实现服务器代码
只有Controller是对外服务的,其他类都是对内服务的
控制层:
完善 BookController
@Slf4j
@RestController
@RequestMapping("/book")
public class BookController {
@Autowired//告诉Spring,从容器中取出这个对象,赋值给当前对象的属性
private BookService bookService;
//等价于
//private BookService bookService
//public BookController() {
// this.bookService = new BookService();
//}
@RequestMapping("/getBookListByPage")
//为了方便更好拓展,最好返回结果也是一个对象
public PageResult<BookInfo> getBookListByPage(PageRequest pageRequest){
log.info("查询翻页信息,pageRequest:{}",pageRequest);
if(pageRequest.getPageSize()<0||pageRequest.getCurrentPage()<1){
//每页显示条数为负或者当前页数不为正数则错误
return null;
}
PageResult<BookInfo> bookInfoPageResult=null;
try {
bookInfoPageResult=bookService.selectBookInfoByPage(pageRequest);
}catch (Exception e){
log.error("查询翻页信息错误,e:{}",e);
}
return bookInfoPageResult;
}
}
业务层:
BookService
@Service
public class BookService {
@Autowired
private BookInfoMapper bookInfoMapper;
//Service是数据库和Controller的桥梁
public PageResult<BookInfo> selectBookInfoByPage(PageRequest pageRequest){
if(pageRequest==null){
return null;
}
//获取总记录数
Integer count= bookInfoMapper.count();
//获取当前记录
List<BookInfo> bookInfos=bookInfoMapper.selectBookInfoByPage(pageRequest.getOffset(), pageRequest.getPageSize());
if(bookInfos!=null&&bookInfos.size()>0){
for(BookInfo bookInfo:bookInfos){
//根据status获取状态的定义
if(bookInfo.getStatus()==1){
bookInfo.setStatusCN("可借阅");
}else if(bookInfo.getStatus()==2) {
bookInfo.setStatusCN("不可节约");
}
}
}
return new PageResult<>(bookInfos,count,pageRequest);
}
}
- 翻⻚信息需要返回数据的总数和列表信息, 需要查两次SQL
- 图书状态: 图书状态和数据库存储的status有⼀定的对应关系
如果后续状态码有变动, 我们需要修改项⽬中所有涉及的代码, 这种情况, 通常采⽤枚举类来处理映射关系
创建enmus⽬录, 创建BookStatusEnum类:
public enum BookStatusEnum {
DELETED(0,"删除"),
NORMAL(1,"可借阅"),
FORBIDDEN(2,"不可借阅")
;
private int code;
private String name;
public static BookStatusEnum getNameByCode(int code){
switch (code){
case 0:return BookStatusEnum.DELETED;
case 1:return BookStatusEnum.NORMAL;
case 2:return BookStatusEnum.FORBIDDEN;
default:
return BookStatusEnum.FORBIDDEN;
}
}
BookStatusEnum(int code, String name) {
this.code = code;
this.name = name;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
getNameByCode: 通过code来获取对应的枚举, 以获取该枚举对应的中⽂名称
后续如果有状态变更, 只需要修改该枚举类即可
此时, BookService的代码, 可以修改如下:
//通过注解告诉Spring帮我们把BookService存入容器中
@Service
public class BookService {
@Autowired
private BookInfoMapper bookInfoMapper;
//Service是数据库和Controller的桥梁
public PageResult<BookInfo> selectBookInfoByPage(PageRequest pageRequest){
if(pageRequest==null){
return null;
}
//获取总记录数
Integer count= bookInfoMapper.count();
//获取当前记录
List<BookInfo> bookInfos=bookInfoMapper.selectBookInfoByPage(pageRequest.getOffset(), pageRequest.getPageSize());
if(bookInfos!=null&&bookInfos.size()>0){
for(BookInfo bookInfo:bookInfos){
//根据status获取状态的定义
bookInfo.setStatusCN(BookStatusEnum.getNameByCode(bookInfo.getStatus()).getName());
}
}
return new PageResult<>(bookInfos,count,pageRequest);
}
}
数据层:
翻⻚查询SQL
select * from book_info where status!=0 order by id desc limit #{offset},#{pageSize}
其中 offset 在PageRequest类中已经给赋值
BookInfoMapper
图书列表按id降序排列
@Mapper
public interface BookInfoMapper {
/**
* 获取当前页信息
* 选取状态不为0的记录,并且按照id降序排列
* @param offset 要跳过的记录数
* @param pageSize 每页显示的记录数
* @return
*/
@Select("select * from book_info where status!=0" +
" order by id desc limit #{offset},#{pageSize}")
List<BookInfo> selectBookInfoByPage(Integer offset,Integer pageSize);
/**
* 获取总记录数
* 选取状态不为0的记录
* @return
*/
@Select("select count(1) from book_info where status!=0")
Integer count();
}
启动服务, 访问后端程序:
http://127.0.0.1:8080/book/getListByPage 返回1-10条记录 (按id降序)
http://127.0.0.1:8080/book/getListByPage?currentPage=2 返回11-20条记录
实现客⼾端代码
我们定义:
访问第⼀⻚图书的前端url为: http://127.0.0.1:8080/book_list.html?currentPage=1
访问第⼆⻚列表的url为: http://127.0.0.1:8080/book_list.html?currentPage=2
浏览器访问 book_list.html ⻚⾯时, 就去请求后端, 把后端返回数据显⽰在⻚⾯上
调⽤后端请求: /book/getListByPage?currentPage=1
修改之前的js, 后端请求⽅法从 /book/getList
改为 /book/getListByPage?currentPage=1
修改后的js为
此时, url还未设置 currentPage
参数
我们直接使⽤ location.search
从url中获取参数信息即可
location.search : 获取url的查询字符串 (包含问号)
如:
url: http://127.0.0.1:8080/book_list.html?currentPage=1
location.search
:?currentPage=1
所以, 把上述url改为: “/book/getListByPage” + location.search
接下来处理分⻚信息
分⻚插件
本案例中, 分⻚代码采⽤了⼀个分⻚组件
分⻚组件⽂档介绍: jqPaginator分⻚组件
使⽤时, 只需要按照 [使⽤说明] 部分的⽂档, 把代码复制粘贴进来就可以了(提供的前端代码中, 已经包含该部分内容)简单介绍下使⽤
onPageChange :回调函数,当换⻚时触发(包括初始化第⼀⻚的时候),会传⼊两个参数:
1、"⽬标⻚"的⻚码,Number类型
2、触发类型,可能的值:“init”(初始化),“change”(点击分⻚)
我们在图书列表信息加载之后, 需要分⻚信息, 同步加载
分⻚组件需要提供⼀些信息: totalCounts:总记录数,pageSize:每⻚的个数,visiblePages:可视⻚数,currentPage:当前⻚码
这些信息中, pageSize 和 visiblePages 前端直接设置即可. totalCounts 后端已经提供, currentPage 也可以从参数中取到, 但太复杂了, 咱们直接由后端返回即可.
修改后端代码
- 为避免后续还需要其他请求处的信息, 我们直接在 PageResult 添加 PageRequest 属性
- 处理返回结果, 填充 PageRequest
PageResult.java
BookService.java
后端数据返回后, 我们加载⻚⾯信息, 把分⻚代码挪到getBookList⽅法中
完善⻚⾯点击代码:
当点击⻚码时: 跳转到⻚⾯: book_list.html?currentPage=?
修改上述代码代码:
测试
访问url: http://127.0.0.1:8080/book_list.html
点击⻚码, ⻚⾯信息得到正确的处理
修改图书
约定前后端交互接⼝
需要两个接口
进⼊修改⻚⾯, 需要显⽰当前图书的信息
[请求]
/book/queryBookById?bookId=25
[参数]
bookId
[响应]
{
"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"
}
根据图书ID, 获取当前图书的信息
点击修改按钮, 修改图书信息
[请求]
/book/updateBook
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
[参数]
BookInfo
id=1&bookName=图书1&author=作者1&count=23&price=36&publish=出版社1&status=1
[响应]
"" //失败信息, 成功时返回空字符串
我们约定, 浏览器给服务器发送⼀个 /book/updateBook
这样的 HTTP 请求, form表单的形式来提交数据
服务器返回处理结果, 返回""表⽰添加图书成功, 否则, 返回失败信息.
实现服务器代码
BookController:
@RequestMapping("/queryBookInfoById")
public BookInfo queryBookInfoById(Integer bookId){
log.info("根据ID查询图书,bookId:{}",bookId);
try {
BookInfo bookInfo=bookService.queryBookInfoById(bookId);
return bookInfo;
}catch (Exception e){
log.error("查询图书失败,e:{}",e);
}
return null;
}
@RequestMapping("/updateBook")
public String updateBook(BookInfo bookInfo){
log.info("接收到更新图书的请求,bookInfo:{}",bookInfo);
Integer result= bookService.updateBook(bookInfo);
if(result==0){
log.error("更新图书失败,bookInfo:{}",bookInfo);
return "更新图书失败,请联系管理员";
}
return "";
}
业务层:
BookService:
public BookInfo queryBookInfoById(Integer id){
return bookInfoMapper.queryBookInfoById(id);
}
/**
* 更新图书
* @param bookInfo
* @return
*/
public Integer updateBook(BookInfo bookInfo){
Integer result=0;
try {
result=bookInfoMapper.updateBook(bookInfo);
}catch (Exception e){
log.error("更新图书失败,e:{}",e);
}
return result;
}
数据层:
根据图书ID,查询图书信息
@Select("select * from book_info where id=#{id}")
BookInfo queryBookInfoById(Integer id);
更新逻辑相对较为复杂, 传递了哪些值, 咱们更新哪些值, 需要使⽤动态SQL
对于初学者⽽⾔, 注解的⽅式拼接动态SQL不太友好, 咱们采⽤xml的⽅式来实现
配置xml路径
mybatis:
mapper-locations: classpath:mapper/**Mapper.xml
记得在
resource
包下创建mapper
目录,在里面创建.xml文件
最终整体的yml配置⽂件为:
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/book_test?characterEncoding=utf8&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 配置打印 MyBatis⽇志
map-underscore-to-camel-case: true # 配置驼峰⾃动转换
mapper-locations: classpath:mapper/**Mapper.xml
# 设置⽇志⽂件的⽂件名
logging:
file:
name: /logger/spring-book.log
**定义Mapper接⼝: **
BookInfoMapper
Integer updateBook(BookInfo bookInfo);
xml实现:
创建BookInfoMapper.xml⽂件
数据持久成的实现,MyBatis 的固定 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.Hsu.book.mapper.BookInfoMapper">
</mapper>
注意这里
com.Hsu.book.mapper.BookInfoMapper
是在com.Hsu.book.mapper
包下的BookInfoMapper.java
接口,我们这里的.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.Hsu.book.mapper.BookInfoMapper">
<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>
实现客⼾端代码
我们观察, 在列表⻚时, 我们已经补充了[修改] 的链接
http://127.0.0.1:8080/book_update.html?bookId=25 (25为对应的图书ID)
finalHtml += '<a href="book_update.html?bookId=' + book.id + '">修改</a>';
点击[修改] 链接时, 就会⾃动跳转到 http://127.0.0.1:8080/book_update.html?bookId=25 (25为对应的图书ID)
进⼊[修改图书]⻚⾯时, 需要先从后端拿到当前图书的信息, 显⽰在⻚⾯上
$.ajax({
type:"get",
url:"/book/queryBookInfoById"+location.search,
success:function(book){
if(book!=null){
//页面输入框的填充
$("#bookId").val(book.id);
$("#bookName").val(book.bookName);
$("#bookAuthor").val(book.author);
$("#bookStock").val(book.count);
$("#bookPrice").val(book.price);
$("#bookPublisher").val(book.publish);
$("#bookStatus").val(book.status);
}else{
alert("图书不存在");
}
}
});
补全修改图书的⽅法:
function update() {
$.ajax({
type:"post",
url:"/book/updateBook",
data:$("#updateBook").serialize(),
success:function(result){
if(result==""){
location.href="book_list.html";
}else{
alert(result);
}
}
});
}
我们修改图书信息, 是根据图书ID来修改的, 所以需要前端传递的参数中, 包含图书ID.
有两种⽅式:
- 获取url中参数的值(⽐较复杂, 需要拆分url)
- 在form表单中, 再增加⼀个隐藏输⼊框, 存储图书ID, 随
$("#updateBook").serialize()
⼀起提交到后端
我们采⽤第⼆种⽅式
在form表单中,添加隐藏输⼊框
<form id="updateBook">
<input type="hidden" class="form-control" id="bookId" name="id">
hidden 类型的 元素
隐藏表单, ⽤⼾不可⻅、不可改的数据,在⽤⼾提交表单时,这些数据会⼀并发送出
使⽤场景: 正被请求或编辑的内容的 ID. 这些隐藏的 input 元素在渲染完成的⻚⾯中完全不可⻅,⽽且没有⽅法可以使它重新变为可⻅.
⻚⾯加载时, 给该hidden框赋值
//页面输入框的填充
$("#bookId").val(book.id);
此时前端js完整代码:
$.ajax({
type:"get",
url:"/book/queryBookInfoById"+location.search,
success:function(book){
if(book!=null){
//页面输入框的填充
$("#bookId").val(book.id);
$("#bookName").val(book.bookName);
$("#bookAuthor").val(book.author);
$("#bookStock").val(book.count);
$("#bookPrice").val(book.price);
$("#bookPublisher").val(book.publish);
$("#bookStatus").val(book.status);
}else{
alert("图书不存在");
}
}
});
function update() {
$.ajax({
type:"post",
url:"/book/updateBook",
data:$("#updateBook").serialize(),
success:function(result){
if(result==""){
location.href="book_list.html";
}else{
alert(result);
}
}
});
}
测试
点击[修改]链接
跳转到图书修改⻚⾯, ⻚⾯加载出该图书的信息
随机修改数据, 点击确定按钮, 观察数据是否被修改
删除图书
约定前后端交互接⼝
删除分为 逻辑删除 和物理删除
逻辑删除
逻辑删除也称为软删除、假删除、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 语句 | 菜⻦教程
插⼊和删除操作, 放在同⼀个事务中执⾏
delete语句通常是进行数据修复时才使用
测试人员进行测试,手动造一些数据,测试完成之后,这条数据就是脏数据了
这个脏数据(假数据)没有任何价值的,需要把数据删除,就使用delete语句
物理删除+归档的⽅式实现有些复杂, 咱们采⽤逻辑删除的⽅式
逻辑删除的话, 依然是更新逻辑, 我们可以直接使⽤修改图书的接⼝
/book/updateBook
[请求]
/book/updateBook
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
[参数]
bookId=?&&status=0
[响应]
"" //失败信息, 成功时返回空字符串
实现客⼾端代码
点击删除时, 调⽤delete()⽅法, 我们来完善delete⽅法
前端代码已经提供了
function deleteBook(bookId) {
var isDelete = confirm("确认删除?");
if (isDelete) {
//删除图书
$.ajax({
type:"post",
url:"/book/updateBook",
data:{
id:bookId,
status:0
},
success:function(result){
if(result==""){
//删除成功
location.href="book_list.html";
}else{
alert(result);
}
}
});
}
}
测试
点击[删除]
点击[取消], 观察数据依然存在
点击[确定], 数据删除
批量删除
批量删除, 其实就是批量修改数据
约定前后端交互接⼝
[请求]
/book/batchDeleteBook
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
[参数]
List<Integer> ids
[响应]
"" //失败信息, 成功时返回空字符串
点击[批量删除]按钮时, 只需要把复选框选中的图书的ID,发送到后端即可
多个id, 我们使⽤List的形式来传递参数
实现服务器代码
控制层:
BookController
@RequestMapping("/batchDelete")
public String batchDelete(@RequestParam List<Integer> ids){
log.info("接受批量删除的请求,图书id:{}",ids);
Integer result=bookService.batchDelete(ids);
if(result<=0){
log.error("批量删除失败,ids:{}",ids);
return "批量删除失败,请联系管理员";
}
return "";
}
这里传递一个集合记得加上
@RequestParam
业务层:
BookService
public Integer batchDelete(List<Integer> ids){
Integer result=0;
try {
result=bookInfoMapper.batchDelete(ids);
}catch (Exception e){
log.error("批量删除图书失败,ids:{}",ids);
}
return result;
}
数据层:
批量删除需要⽤到动态SQL, 初学者建议使⽤动态SQL的部分, 都⽤xml实现
BookInfoMapper
接⼝定义
Integer batchDelete(List<Integer> ids);
xml接⼝实现
<update id="batchDelete">
update book_info
<set>
status=0
</set>
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/batchDelete?ids="+ids,
success:function(result){
if(result==""){
//删除成功
location.href="book_list.html";
}else{
alert(result);
}
}
});
}
}
测试
选择删除的图书, 点击[批量删除]
强制登录
虽然我们做了⽤⼾登录, 但是我们发现, ⽤⼾不登录, 依然可以操作图书.
这是有极⼤⻛险的. 所以我们需要进⾏强制登录.
如果⽤⼾未登录就访问图书列表或者添加图书等⻚⾯, 强制跳转到登录⻚⾯.
实现思路分析
⽤⼾登录时, 我们已经把登录⽤⼾的信息存储在了Session中. 那就可以通过Session中的信息来判断⽤⼾是否登录.
- 如果Session中可以取到登录⽤⼾的信息, 说明⽤⼾已经登录了, 可以进⾏后续操作
- 如果Session中取不到登录⽤⼾的信息, 说明⽤⼾未登录, 则跳转到登录⻚⾯.
以图书列表为例
现在图书列表接⼝返回的内容如下:
{
"total": 25,
"records": [{
"id": 25,
"bookName": "图书21",
"author": "作者2",
"count": 29,
"price": 22.00,
"publish": "出版社1",
"status": 1,
"statusCN": "可借阅"
}, {
......
} ]
}
这个结果上, 前端没办法确认⽤⼾是否登录了. 并且后端返回数据为空时, 前端也⽆法确认是后端⽆数据, 还是后端出错了.
当前后端接⼝数据返回类:
@Data
//为了更好的拓展性,返回的结果最好是一个对象
public class PageResult<T> {
//当前页所有记录
private List<T> records;
//总记录数
private Integer total;
//分页请求的引用
//包含了分页请求的当前页码、每页显示条数、要跳过的记录数
private PageRequest pageRequest;
public PageResult(List<T> records, Integer total, PageRequest pageRequest){
this.records=records;
this.total=total;
this.pageRequest=pageRequest;
}
}
我们需要再增加⼀个属性告知后端的状态以及后端出错的原因. 修改如下:
@Data
//为了更好的拓展性,返回的结果最好是一个对象
public class PageResult<T> {
//当前页所有记录
private List<T> records;
//总记录数
private Integer total;
//分页请求的引用
//包含了分页请求的当前页码、每页显示条数、要跳过的记录数
private PageRequest pageRequest;
//业务状态码
private Integer code;//0 成功 -1 失败 -2 未登录
//错误信息
private String errMsg;
public PageResult(List<T> records, Integer total, PageRequest pageRequest){
this.records=records;
this.total=total;
this.pageRequest=pageRequest;
}
}
如果code=200,records为空,表示数据库为空
如果code=-1,records为空,表示后端出错
但是当前只是图书列表⽽已, 图书的增加, 修改, 删除接⼝都需要跟着修改, 添加两个字段. 这对我们的代码修改是巨⼤的.
我们不妨对所有后端返回的数据进⾏⼀个封装
@Data
public class Result {
//业务状态码
private ResultCode code;//0 成功 -1 失败 -2 未登录
//错误信息
private String errMsg;
//数据
private Object data;
}
data为之前接⼝返回的数据
code
为后端业务处理的状态码, 也可以使⽤枚举来表⽰
public enum ResultCode {
SUCCESS(0),
FAIL(-1),
UNLOGIN(-2)
;
//0 成功 -1 失败 -2 未登录
public int code;
ResultCode(int code) {
this.code=code;
}
public int getCode(){
return code;
}
public void setCode(int code) {
this.code = code;
}
}
修改Result, 并添加⼀些常⽤⽅法
@Data
public class Result {
//业务状态码
private ResultCode code;//0 成功 -1 失败 -2 未登录
//错误信息
private String errMsg;
//数据
private Object data;
public static Result success(Object data){
Result result=new Result();
result.setCode(ResultCode.SUCCESS);
result.setErrMsg("");
result.setData(data);
return result;
}
public static Result fail(String errMsg){
Result result=new Result();
result.setCode(ResultCode.FAIL);
result.setErrMsg(errMsg);
result.setData(null);
return result;
}
public static Result fail(String errMsg,Object data){
Result result=new Result();
result.setCode(ResultCode.FAIL);
result.setErrMsg(errMsg);
result.setData(data);
return result;
}
public static Result unLogin(){
Result result=new Result();
result.setCode(ResultCode.UNLOGIN);
result.setErrMsg("用户未登录");
result.setData(null);
return result;
}
}
接下来修改服务器和客⼾端代码
实现服务器代码
修改图书列表接⼝, 进⾏登录校验
@RequestMapping("/getBookListByPage")
//为了方便更好拓展,最好返回结果也是一个对象
public Result getBookListByPage(PageRequest pageRequest, HttpSession session) {
log.info("查询翻页信息,pageRequest:{}", pageRequest);
//用户登录校验
UserInfo userInfo= (UserInfo) session.getAttribute("session_user_key");
if(userInfo==null||userInfo.getId()<=0||"".equals(userInfo.getUserName())){
//用户未登录
return Result.unLogin();
}
//校验成功
if (pageRequest.getPageSize() < 0 || pageRequest.getCurrentPage() < 1) {
//每页显示条数为负或者当前页数不为正数则错误
return Result.fail("参数校验失败");
}
PageResult<BookInfo> bookInfoPageResult = null;
try {
bookInfoPageResult = bookService.selectBookInfoByPage(pageRequest);
return Result.success(bookInfoPageResult);
} catch (Exception e) {
log.error("查询翻页信息错误,e:{}", e);
return Result.fail(e.getMessage());
}
}
问题: 如果修改常量session的key, 就需要修改所有使⽤到这个key的地⽅, 出于⾼内聚低耦合的思想, 我们把常量集中在⼀个类⾥
创建类: Constants
public class Constants {
public static final String SESSION_USER_KEY="session_user_key";
}
常量名命名规则:
常量命名全部⼤写, 单词间⽤下划线隔开, ⼒求语义表达完整清楚, 不要在意名字⻓度.
正例: MAX_STOCK_COUNT / CACHE_EXPIRED_TIME
反例: COUNT / TIME
修改之前使⽤到 session_user_key
登录接⼝
@RequestMapping("/login")
public Boolean login(String userName, String password, HttpSession session) {
//校验参数
if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {
return false;
}
//1.根据用户名去查找用户信息
UserInfo userInfo = userService.getUserInfoByName(userName);
if(userInfo==null||userInfo.getId()<0){
return false;
}
//2.比对密码是否正确
//验证账号密码是否正确
//if(userName.equals("admin")这种写法,如果userName为null就会报空指针异常
//这是开发习惯,要养成
if(password.equals(userInfo.getPassword())){
//密码正确
//存session
userInfo.setPassword("");
session.setAttribute(Constants.SESSION_USER_KEY, userInfo);
return true;
}
return false;
}
实现客⼾端代码
由于后端接⼝发⽣变化, 所以前端接⼝也需要进⾏调整
这也就是为什么前后端交互接⼝⼀旦定义好, 尽量不要发⽣变化.
所以后端接⼝返回的数据类型⼀般不定义为基本类型, 包装类型或者集合类等, ⽽是定义为⾃定义对象. ⽅便后续做扩展
修改图书列表的⽅法(下⾯代码为修改部分):
function getBookList() {
//...
success: function (result) {
//真实的前端处理逻辑比后端复杂
if(result.code=="UNLOGIN"){
location.href="login.html";
return;
}
//...
var pageResult=result.data;
//翻页信息
$("#pageContainer").jqPaginator({
totalCounts: pageResult.total, //总记录数
pageSize: 10, //每页的个数
visiblePages: 5, //可视页数
currentPage: pageResult.pageRequest.currentPage, //当前页码
//...
});
}
}
完整代码如下:
function getBookList() {
$.ajax({
type: "get",
url: "/book/getBookListByPage"+location.search,
success: function (result) {
//真实的前端处理逻辑比后端复杂
if(result.code=="UNLOGIN"){
location.href="login.html";
return;
}
var finalHtml = "";
//加载列表
var pageResult=result.data;
for (var book of pageResult.records) {
//根据每一条记录拼接html,也就是一个<tr>
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)" οnclick="deleteBook(' + book.id + ')">删除</a>';
finalHtml += '</div>';
finalHtml += '</td>';
finalHtml += '</tr>';
}
$("tBody").html(finalHtml);
//翻页信息
$("#pageContainer").jqPaginator({
totalCounts: pageResult.total, //总记录数
pageSize: 10, //每页的个数
visiblePages: 5, //可视页数
currentPage: pageResult.pageRequest.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:8080/book_list.html
发现跳转到了登录⻚⾯
- 登录⽤⼾, 图书列表正常返回
思考
强制登录的模块, 我们只实现了⼀个图书列表, 上述还有图书修改, 图书删除等接⼝, 也需要一一实现.
如果应⽤程序功能更多的话, 这样写下来会⾮常浪费时间, 并且容易出错.
有没有更简单的处理办法呢?
接下来我们学习SpringBoot对于这种"统⼀问题"的处理办法.
总结
-
学习了MyBatis动态SQL的⼀些标签使⽤. 标签中, 使⽤的是Java对象的属性, ⽽⾮数据库字段.
-
动态SQL的实现, 注解和xml的实现⽅式相似, 区别是注解⽅式需要添加
<script></script>
.但是使⽤注解的⽅式时, Idea不会进⾏格式检测, 容易出错, 建议初学者⽤xml的⽅式 -
MyBatis的学习, 需要更多的练习才能掌握. 代码⼀定要⾃⼰写, 切忌眼⾼⼿低