06-Web后端基础(java操作数据库)
1. 前言
在前面我们学习MySQL数据库时,都是利用图形化客户端工具(如:idea、datagrip),来操作数据库的。
我们做为后端程序开发人员,通常会使用Java程序来完成对数据库的操作。Java程序操作数据库的技术呢,有很多啊,而最为底层、最为基础的就是JDBC。

JDBC:(Java DataBase Connectivity),就是使用Java语言操作关系型数据库的一套API。 【是操作数据库最为基础、底层的技术】
但是使用JDBC来操作数据库,会比较繁琐,所以现在在企业项目开发中呢,一般都会使用基于JDBC的封装的高级框架,比如:Mybatis、MybatisPlus、Hibernate、SpringDataJPA。
而这些技术,目前的市场占有份额如下图所示:

从上图中,我们也可以看到,目前最为主流的就是Mybatis,其次是MybatisPlus。
所以,在我们的课程体系中呢,这两种主流的操作数据库的框架我们都要学习。 而我们在学习这两个主流的框架之前,还需要学习一下操作数据库的基础基础 JDBC。 然后接下来,再来学习Mybatis。 而在我们后面的课程中,我们还要学习MybatisPlus框架。 那么今天呢,我们就先来学习 JDBC 和 Mybatis。
今天课程安排:
- JDBC
- Mybatis
- SpringBoot配置文件
2. JDBC
(快速过jdbc)
2.1 介绍
JDBC:(Java DataBase Connectivity),就是使用Java语言操作关系型数据库的一套API。

本质:
- sun公司官方定义的一套操作所有关系型数据库的规范,即接口。
- 各个数据库厂商去实现这套接口,提供数据库驱动jar包。
- 我们可以使用这套接口(JDBC)编程,真正执行的代码是驱动jar包中的实现类。
那有了JDBC之后,我们就可以直接在java代码中来操作数据库了,只需要编写这样一段java代码,就可以来操作数据库中的数据。 示例代码如下:

2.2 查询数据
2.2.1 需求

需求:基于JDBC实现用户登录功能。
本质:其本质呢,其实就是基于JDBC程序,执行如下select语句,并将查询的结果输出到控制台。SQL语句:
sql
select * from user where username = 'linchong' and password = '123456';
2.2.2 准备工作
1). 创建一个maven项目

2). 创建一个数据库 web,并在该数据库中创建user表
sql
create table user(
id int unsigned primary key auto_increment comment 'ID,主键',
username varchar(20) comment '用户名',
password varchar(32) comment '密码',
name varchar(10) comment '姓名',
age tinyint unsigned comment '年龄'
) comment '用户表';
insert into user(id, username, password, name, age) values (1, 'daqiao', '123456', '大乔', 22),
(2, 'xiaoqiao', '123456', '小乔', 18),
(3, 'diaochan', '123456', '貂蝉', 24),
(4, 'lvbu', '123456', '吕布', 28),
(5, 'zhaoyun', '12345678', '赵云', 27);
2.2.3 代码实现
🤖
AI提示词(prompt): 你是一名java开发工程师,帮我基于JDBC程序来操作数据库,执行如下SQL语句: select * from
user where username = 'daqiao' and password = '123456'
具体的代码为:
1). 在 pom.xml 文件中引入依赖
xml
<dependencies>
<!-- MySQL JDBC driver -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.3</version>
<scope>test</scope>
</dependency>
</dependencies>
2). 在 src/main/test/java 目录下编写测试类,定义测试方法
java
public class JDBCTest {
/**
* 编写JDBC程序, 查询数据
*/
@Test
public void testJdbc() throws Exception {
// 获取连接
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/web", "root", "1234");
// 创建预编译的PreparedStatement对象
PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM user WHERE username = ? AND password = ?");
// 设置参数
pstmt.setString(1, "daqiao"); // 第一个问号对应的参数
pstmt.setString(2, "123456"); // 第二个问号对应的参数
// 执行查询
ResultSet rs = pstmt.executeQuery();
// 处理结果集
while (rs.next()) {
int id = rs.getInt("id");
String uName = rs.getString("username");
String pwd = rs.getString("password");
String name = rs.getString("name");
int age = rs.getInt("age");
System.out.println("ID: " + id + ", Username: " + uName + ", Password: " + pwd + ", Name: " + name + ", Age: " + age);
}
// 关闭资源
rs.close();
pstmt.close();
conn.close();
}
}
📚
1、注册驱动,获取连接
2、获取获取sql语句执行对象
3、执行sql语句
4、释放资源
而上述的单元测试中,我们在SQL语句中,将将 用户名 和密码的值都写死了,而这两个值应该是动态的,是将来页面传递到服务端的。 那么,我们可以基于前面所讲解的JUnit中的参数化测试进行单元测试,代码改造如下:
java
public class JDBCTest {
/**
* 编写JDBC程序, 查询数据
*/
@ParameterizedTest
@CsvSource({"daqiao,123456"})
public void testJdbc(String _username, String _password) throws Exception {
// 获取连接
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/web", "root", "1234");
// 创建预编译的PreparedStatement对象
PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM user WHERE username = ? AND password = ?");
// 设置参数
pstmt.setString(1, _username); // 第一个问号对应的参数
pstmt.setString(2, _password); // 第二个问号对应的参数
// 执行查询
ResultSet rs = pstmt.executeQuery();
// 处理结果集
while (rs.next()) {
int id = rs.getInt("id");
String uName = rs.getString("username");
String pwd = rs.getString("password");
String name = rs.getString("name");
int age = rs.getInt("age");
System.out.println("ID: " + id + ", Username: " + uName + ", Password: " + pwd + ", Name: " + name + ", Age: " + age);
}
// 关闭资源
rs.close();
pstmt.close();
conn.close();
}
}
如果在测试时,需要传递一组参数,可以使用 @CsvSource 注解。
📚

☀️小结

2.2.4 代码剖析
2.2.4.1 ResultSet
ResultSet(结果集对象):封装了DQL查询语句查询的结果。
- next() :将光标从当前位置向前移动一行,并判断当前行是否为有效行,返回值为boolean。(默认一开始是第一行的前一行)
- true:有效行,当前行有数据
- false:无效行,当前行没有数据
- getXxx(...):获取数据,可以根据列的编号获取,也可以根据列名获取(推荐)。
结果解析步骤:

🤖

☀️小结

2.2.4.2 预编译SQL
其实我们在编写SQL语句的时候,有两种风格:
- 静态SQL(参数硬编码)
sql
conn.prepareStatement("SELECT * FROM user WHERE username = 'daqiao' AND password = '123456'");
ResultSet resultSet = pstmt.executeQuery();
这种呢,就是参数值,直接拼接在SQL语句中,参数值是写死的。
- 预编译SQL(参数动态传递)
sql
conn.prepareStatement("SELECT * FROM user WHERE username = ? AND password = ?");
pstmt.setString(1, "daqiao");
pstmt.setString(2, "123456");
ResultSet resultSet = pstmt.executeQuery();
这种呢,并未将参数值在SQL语句中写死,而是使用 ? 进行占位,然后再指定每一个占位符对应的值是多少,而最终在执行SQL语句的时候,程序会将SQL语句(SELECT * FROM user WHERE username = ? AND password = ?),以及参数值("daqiao", "123456")都发送给数据库,然后在执行的时候,会使用参数值,将?占位符替换掉。
那这种预编译的SQL,也是在项目开发中推荐使用的SQL语句。主要的作用有两个:
- 防止SQL注入
- 性能更高
那接下来,我们就来介绍一下这两点。
2.2.4.2.1 SQL注入
- SQL注入:通过控制输入来修改事先定义好的SQL语句,以达到执行代码对服务器进行攻击的方法。
SQL注入最典型的场景,就是用户登录功能。

注入演示:
1). 打开课程资料中的文件夹 资料/02. SQL注入演示,运行其中的jar包 sql_Injection_demo-0.0.1-SNAPSHOT.jar,进入该目录后,执行命令:
java
java -jar sql_Injection_demo-0.0.1-SNAPSHOT.jar

2). 打开浏览器访问 http://localhost:9090/ ,必须登录后才能访问到系统。我们先测试正常的用户名和密码


3). 接下来,我们再来测试一下错误的用户名和密码 。

我们看到,如果用户名密码错误,是不能进入到系统中进行访问的,会提示 用户名和密码错误。
4). 那接下来,我们就要演示一下SQL注入现象,我们可以通过控制表单输入,来修改事先定义好的SQL语句的含义。 从而来攻击服务器。

点击登录后,我们看到居然可以成功进入到系统中。

为什么会出现这种现象呢?
在进行登录操作时,怎么样才算登录成功呢? 如果我们查询到了数据,就说明用户名密码是对的。 如果没有查询到数据,就说明用户名或密码错误。
而出现上述现象,原因就是因为,我们我们编写的SQL语句是基于字符串进行拼接的 。 我们输入的用户名无所谓,比如:shfhsjfhja ,而密码呢,就是我们精心设计的,如:' or '1' = '1 。
那最终拼接的SQL语句,如下所示:

我们知道,or 连接的条件,是或的关系,两者满足其一就可以。 所以,虽然用户名密码输入错误,也是可以查询返回结果的,而只要查询到了数据,就说明用户名和密码是正确的。
📚
密码等于空串或者
1=1,当然所有的账号都满足,所以登录成功。
2.2.4.2.2 SQL注入解决
而通过预编译SQL(select * from user where username = ? and password = ?),就可以直接解决上述SQL注入的问题。 接下来,我们再来演示一下,通过预编译SQL是否能够解决SQL注入问题。
1). 打开课程资料中的文件夹 资料/02. SQL注入演示,运行其中的jar包 sql_prepared_demo-0.0.1-SNAPSHOT.jar,进入该目录后,执行命令:
java
java -jar sql_prepared_demo-0.0.1-SNAPSHOT.jar

2). 打开浏览器访问 http://localhost:9090/ ,必须登录后才能访问到系统 。我们先测试正常的用户名和密码


3). 那接下来,我们就要演示一下是否可以基于上述的密码 ' or '1' = '1,来完成SQL注入 。

通过控制台,可以看到输入的SQL语句,是预编译SQL语句。

而在预编译SQL语句中,当我们执行的时候,会把整个' or '1'='1作为一个完整的参数,赋值给第2个问号(' or '1'='1进行了转义,只当做字符串使用)
那么此时再查询时,就查询不到对应的数据了,登录失败。
💡注意:在以后的项目开发中,我们使用的基本全部都是预编译SQL语句。
2.2.4.2.3 性能更高

📚预编译 SQL 会将 SQL 模板 发送数据库编译,编译后的执行计划会被缓存。后续执行只需传入参数,复用缓存,避免重复编译,提升性能
3. Mybatis
3.1 介绍
什么是MyBatis?
- MyBatis是一款优秀的 持久层 框架,用于简化JDBC的开发。
- MyBatis本是 Apache的一个开源项目iBatis,2010年这个项目由apache迁移到了google code,并且改名为MyBatis 。2013年11月迁移到Github。
- 官网:https://mybatis.org/mybatis-3/zh/index.html
在上面我们提到了两个词:一个是持久层,另一个是框架。
- 持久层:指的是就是数据访问层(dao),是用来操作数据库的。

- 框架:是一个半成品软件,是一套可重用的、通用的、软件基础代码模型。在框架的基础上进行软件开发更加高效、规范、通用、可拓展。
通过Mybatis就可以大大简化原生的JDBC程序的代码编写,比如 通过 select * from user 查询所有的用户数据,通过JDBC程序操作呢,需要大量的代码实现,而如果通过Mybatis实现相同的功能,只需要简单的三四行就可以搞定。

3.1.1 快速入门
需求:使用Mybatis查询所有用户数据 。
1). 创建springboot工程,并导入
- mybatis的起步依赖
- mysql的驱动包
- lombok。


项目工程创建完成后,自动在pom.xml文件中,导入Mybatis依赖和MySQL驱动依赖。如下所示:

2). 数据准备:创建用户表user,并创建对应的实体类User。
- 用户表 user(如果已经存在,就不用创建了)
sql
create table user(
id int unsigned primary key auto_increment comment 'ID,主键',
username varchar(20) comment '用户名',
password varchar(32) comment '密码',
name varchar(10) comment '姓名',
age tinyint unsigned comment '年龄'
) comment '用户表';
insert into user(id, username, password, name, age) values (1, 'daqiao', '123456', '大乔', 22),
(2, 'xiaoqiao', '123456', '小乔', 18),
(3, 'diaochan', '123456', '貂蝉', 24),
(4, 'lvbu', '123456', '吕布', 28),
(5, 'zhaoyun', '12345678', '赵云', 27);
- 实体类:实体类的属性名与表中的字段名一一对应。 实体类放在 com.itheima.pojo 包下。
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
private Integer id; //ID
private String username; //用户名
private String password; //密码
private String name; //姓名
private Integer age; //年龄
}

3). 配置Mybatis
在 application.properties 中配置数据库的连接信息。
java
#数据库访问的url地址
spring.datasource.url=jdbc:mysql://localhost:3306/web
#数据库驱动类类名
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
#访问数据库-用户名
spring.datasource.username=root
#访问数据库-密码
spring.datasource.password=root@1234
上述的配置,可以直接复制过去,不要敲错了。 全部都是
spring.datasource.xxxx开头。
4). 编写Mybatis程序:编写Mybatis的持久层接口,定义SQL语句(注解)
在创建出来的springboot工程中,在引导类所在包下,在创建一个包 mapper 。在 mapper 包下创建一个接口 UserMapper ,这是一个持久层接口(Mybatis的持久层接口规范一般都叫 XxxMapper)。
UserMapper接口的内容如下:
java
import com.itheima.pojo.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface UserMapper {
/**
* 查询全部
*/
@Select("select * from user")
public List<User> findAll();
}
💡注解说明:
- @Mapper注解:表示是mybatis中的Mapper接口
- 程序运行时,框架会自动生成接口的实现类对象(代理对象),并给交Spring的IOC容器管理
- @Select注解:代表的就是select查询,用于书写select查询语句
5). 单元测试
在创建出来的SpringBoot工程中,在src下的test目录下,已经自动帮我们创建好了测试类 ,并且在测试类上已经添加了注解 @SpringBootTest,代表该测试类已经与SpringBoot整合。
- 该测试类在运行时,会自动通过引导类 加载Spring的环境(IOC容器)。
- 我们要测试那个bean对象,就可以直接通过 @Autowired注解直接将其注入进行,然后就可以测试了。
测试类代码如下:
java
@SpringBootTest
class SpringbootMybatisQuickstartApplicationTests {
@Autowired
private UserMapper userMapper;
@Test
public void testFindAll(){
List<User> userList = userMapper.findAll();
for (User user : userList) {
System.out.println(user);
}
}
}
运行结果:

注意:测试类所在包,需要与引导类所在包相同。
📖
刚创建spring项目有两个问题:
1、application.porperties文件前面图标不是绿叶
https://blog.csdn.net/m0_45025658/article/details/103445578
2、application.porperties文件乱码
https://blog.csdn.net/qq_38530648/article/details/121672442
成功解决乱码问题。
数据库连接四要素,

结果:

3.1.2 辅助配置
3.1.2.1 配置SQL提示
默认我们在UserMapper接口上加的 @Select 注解中编写SQL语句是没有提示的。 如果想让idea给我们提示对应的SQL语句,我们需要在IDEA中配置与MySQL数据库的链接。
默认我们在UserMapper接口上的 @Select 注解中编写SQL语句是没有提示的。如果想让idea给出提示,可以做如下配置:

📖跟视频不太一样
![]()
配置完成之后,发现SQL语句中的关键字有提示了,但还存在不识别表名(列名)的情况:

- 产生原因:Idea和数据库没有建立连接,不识别表信息
- 解决方案:在Idea中配置MySQL数据库连接
按照如下方如下方式,来配置当前IDEA关联的MySQL数据库(必须要指定连接的是哪个数据库 )。


在配置的时候指定连接那个数据库,如上图所示连接的就是mybatis数据库(自己的数据库名是什么就指定什么)。
注意:该配置的目的,仅仅是为了在编写SQL语句时,有语法提示(写错了会报错),不会影响运行,即使不配置也是可以的。
3.1.2.2 配置Mybatis日志输出
默认情况下,在Mybatis中,SQL语句执行时,我们并看不到SQL语句的执行日志。 在application.properties加入如下配置,即可查看日志:
java
#mybatis的配置
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
打开上述开关之后,再次运行单元测试,就可以看到控制台输出的SQL语句是什么样子的。

📖
日志:
3.1.3 JDBC VS Mybatis
JDBC程序的缺点:
- url、username、password 等相关参数全部硬编码在java代码中。
- 查询结果的解析、封装比较繁琐。
- 每一次操作数据库之前,先获取连接,操作完毕之后,关闭连接。 频繁的获取连接、释放连接造成资源浪费。
分析了JDBC的缺点之后,我们再来看一下在mybatis中,是如何解决这些问题的:
- 数据库连接四要素 (驱动、链接、用户名、密码),都配置在springboot默认的配置文件
application.properties中 - 查询结果 的解析及封装,由mybatis自动完成映射封装,我们无需关注
- 在mybatis中使用了数据库连接池技术 ,从而避免了频繁的创建连接、销毁连接而带来的资源浪费。

📚数据库连接池:程序从连接池中取出一个数据库连接对象,使用该连接执行 SQL 语句;执行完毕不是关闭连接,而是把连接归还到连接池中,连接可以被后续请求重复复用。
而对于Mybatis来说,我们在开发持久层程序操作数据库时,需要重点关注以下两个方面:
- application.properties
java
#驱动类名称
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
#数据库连接的url
spring.datasource.url=jdbc:mysql://localhost:3306/web01
#连接数据库的用户名
spring.datasource.username=root
#连接数据库的密码
spring.datasource.password=1234
- Mapper接口(编写SQL语句)
sql
@Mapper
public interface UserMapper {
@Select("select * from user")
public List<User> list();
}
3.1.4 数据库连接池
在前面我们所讲解的mybatis中,使用了数据库连接池技术,避免频繁的创建连接、销毁连接而带来的资源浪费。
下面我们就具体的了解下数据库连接池。
3.1.4.1 介绍
1). 没有数据库连接池的情况

客户端执行SQL语句:要先创建一个新的连接对象,然后执行SQL语句,SQL语句执行后又需要关闭连接对象从而释放资源,每次执行SQL时都需要创建连接、销毁链接 ,这种频繁的重复创建销毁的过程是比较耗费计算机的性能。
2). 有数据库连接池的情况

数据库连接池是个容器,负责分配、管理数据库连接(Connection)
- 程序在启动时,会在数据库连接池(容器)中,创建一定数量的Connection对象
允许应用程序重复使用一个现有的数据库连接,而不是再重新建立一个
- 客户端在执行SQL时,先从连接池中获取一个Connection对象,然后在执行SQL语句,SQL语句执行完之后,释放Connection时就会把Connection对象归还给连接池(Connection对象可以复用)
释放空闲时间超过最大空闲时间的连接,来避免因为没有释放连接而引起的数据库连接遗漏
- 客户端获取到Connection对象了,但是Connection对象并没有去访问数据库 (处于空闲),数据库连接池发现Connection对象的
空闲时间 > 连接池中预设的最大空闲时间,此时数据库连接池就会自动释放掉这个连接对象
数据库连接池的好处:
- 资源重用
- 提升系统响应速度
- 避免数据库连接遗漏
问AI Connection 连接:
- 单个线程同一时刻确实只会执行一条 SQL。
- 框架不会执行完单条 SQL 就归还连接;以Mapper 方法 /事务作为最小单位。
- 无事务:Mapper 方法全部执行完归还;哪怕内部只有 1 条 SQL,也是方法结束归还。
- 有事务:整个事务全程占用同一个连接,提交 / 回滚之后才归还,这是事务的硬性要求。
- 多线程并发访问,才体现连接池价值。
3.1.4.2 产品
要怎么样实现数据库连接池呢?
- 官方(sun)提供了数据库连接池标准(javax.sql.DataSource接口)
- 功能:获取连接
java
public Connection getConnection() throws SQLException;
- 第三方组织必须按照DataSource接口实现
⭐️常见的数据库连接池:C3P0 、DBCP 、Druid 、Hikari (springboot默认)
现在使用更多的是:Hikari、Druid (性能更优越)
1). Hikari(追光者) 默认的连接池

从控制台输出的日志,我们也可以看出,springboot底层默认使用的数据库连接池就是 Hikari。
2). Druid(德鲁伊)
- Druid连接池是阿里巴巴开源的数据库连接池项目
- 功能强大,性能优秀,是Java语言最好的数据库连接池之一
如果我们想把默认的数据库连接池切换为Druid数据库连接池,只需要完成以下两步操作即可:
参考官方地址:https://github.com/alibaba/druid/tree/master/druid-spring-boot-starter
①. 在pom.xml文件中引入依赖
xml
<dependency>
<!-- Druid连接池依赖 -->
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.19</version>
</dependency>
②. 在application.properties中引入数据库连接配置
java
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.druid.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.druid.url=jdbc:mysql://localhost:3306/web
spring.datasource.druid.username=root
spring.datasource.druid.password=1234
配置完毕之后,我们再次运行单元测试,大家会看到控制台输出的日志中,已经将连接池切换为了 Druid连接池。

3.1.5 增删改查操作
3.1.5.1 删除
- 需求:根据ID删除用户信息
- SQL:delete from user where id = 5;
- Mapper接口方法:
- 方式一:
sql
/**
* 根据id删除
*/
@Delete("delete from user where id = 5")
public void deleteById();
这种方式执行删除操作,调用deleteById方法只能删除id为5的用户信息,因为将id直接写死在代码中了,不可取。
- 方式二:
sql
/**
* 根据id删除
*/
@Delete("delete from user where id = #{id}")
public void deleteById(Integer id);
在Mybatis中,我们可以通过参数占位符号#{...}来占位,在调用deleteById方法时,传递的参数值,最终会替换占位符。
- 编写单元测试方法进行测试
在单元测试类中,增加如下测试方法.
sql
@Test
public void testDeleteById(){
userMapper.deleteById(36);
}
运行单元测试,结果如下:

运行之后,我们发现,#{...} 占位符,其实最终被替换成了 ?占位符,生成的是预编译的SQL语句。【推荐】
- DML语句执行完毕,是有返回值的,我们可以为Mapper接口方法定义返回值来接收,如下:
sql
/**
* 根据id删除
*/
@Delete("delete from user where id = #{id}")
public Integer deleteById(Integer id);
Integer类型的返回值,表示DML语句执行完毕影响的记录数。
- Mybatis的提供的符号,有两个,一个是 #{...},另一个是 ${...},区别如下:

那在企业项目开发中,强烈建议使用 #{...} 。
📚用${...}的情况:表名、字段名
✋动手:

3.1.5.2 新增
- 需求:添加一个用户
- SQL:insert into user(username,password,name,age) values('zhouyu','123456','周瑜',20);
- Mapper接口:
sql
/**
* 添加用户
*/
@Insert("insert into user(username,password,name,age) values(#{username},#{password},#{name},#{age})")
public void insert(User user);
如果在SQL语句中,我们需要传递多个参数,我们可以把多个参数封装到一个对象中。然后在SQL语句中,我们可以通过#{对象属性名}的方式,获取到对象中封装的属性值。
- 单元测试:
在测试类中添加测试方法,代码如下:
java
@Test
public void testInsert(){
User user = new User();
user.setUsername("admin");
user.setPassword("123456");
user.setName("管理员");
user.setAge(30);
userMapper.insert(user);
}
运行结果如下:

✋

3.1.5.3 修改
- 需求:根据ID更新用户信息
- SQL:
update user set username = 'zhouyu', password = '123456', name = '周瑜', age = 20 where id = 1; - Mapper接口方法:
sql
/**
* 根据id更新用户信息
*/
@Update("update user set username = #{username},password = #{password},name = #{name},age = #{age} where id = #{id}")
public void update(User user);
- 单元测试:
在测试类中添加测试方法,代码如下:
java
@Test
public void testUpdate(){
User user = new User();
user.setId(6);
user.setUsername("admin666");
user.setPassword("123456");
user.setName("管理员");
user.setAge(30);
userMapper.update(user);
}
运行结果如下:

✋

3.1.5.4 查询
- 需求:根据用户名和密码查询用户信息
- SQL:
select* fromuser whereusername = 'zhouyu' and password = '123456' - Mapper接口方法:
sql
/**
* 根据用户名和密码查询用户信息
*/
@Select("select * from user where username = #{username} and password = #{password}")
public User findByUsernameAndPassword(@Param("username") String username, @Param("password") String password);
@param注解的作用是为接口的方法形参起名字的。(由于用户名唯一的,所以查询返回的结果最多只有一个,可以直接封装到一个对象中)
📚
Java 接口编译成字节码之后,接口的形参名字丢失了。所以要手动打上标记。
接口有多个参数,必须加@Param
如果只有一个参数,不管叫什么,#{随便写} 都能拿到这个唯一参数。
📚基于阿里云骨架和springboot原生骨架的区别:阿里云骨架:没有下面这个配置,字节码丢失参数名,多参数直接报错。
可以看见编译成字节码文件之后,username属性名丢失变成了var1,sql语句识别不了。
springboot原生骨架:
spring‑boot‑starter‑parent已经内置开启了<parameters>true</parameters>编译时会把参数名保存进 class 字节码。
- 单元测试:
在测试类中添加测试方法,代码如下:
java
@Test
public void testFindByUsernameAndPassword(){
User user = userMapper.findByUsernameAndPassword("admin666", "123456");
System.out.println(user);
}
运行结果如下:

✋

☀️

3.1.6 XML映射配置
Mybatis的开发有两种方式:
- 注解
- XML
3.1.6.1 XML配置文件规范
使用Mybatis的注解方式,主要是来完成一些简单的增删改查功能。如果需要实现复杂的SQL功能,建议使用XML来配置映射语句,也就是将SQL语句写在XML配置文件中。
💡在Mybatis中使用XML映射文件方式开发,需要符合一定的规范:
- XML映射文件的名称与Mapper接口名称一致,并且将XML映射文件和Mapper接口放置在相同包下(
同包同名)- XML映射文件的namespace属性为Mapper接口
全限定名一致
(例如:com.itheima.mapper.UserMapper)- XML映射文件中sql语句的
id与Mapper接口中的方法名一致,并保持返回类型一致。

- <select>标签:就是用于编写select查询语句的。
- resultType属性 ,指的是查询返回的单条记录
所封装的类型。
3.1.6.2 XML配置文件实现
第1步: 创建XML映射文件


用
/才是三级目录
第2步:编写XML映射文件
xml映射文件中的dtd约束,
- 直接从mybatis官网复制即可https://mybatis.net.cn/getting-started.html

- 或者直接AI生成。
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="">
</mapper>
第3步:配置
a. XML映射文件的namespace属性为Mapper接口全限定名
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.UserMapper">
</mapper>
b. XML映射文件中sql语句的id与Mapper接口中的方法名一致,并保持返回类型一致
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.EmpMapper">
<!--查询操作-->
<select id="findAll" resultType="com.itheima.pojo.User">
select * from user
</select>
</mapper>
resultType 属性的值,与查询返回的单条记录封装的类型一致。
运行测试类,执行结果:

📍注意:一个接口方法对应的SQL语句,要么使用注解配置,要么使用XML配置,切不可同时配置。
✋

☀️

感觉总结就是:
- namespace:找到是哪个xml文件
- id:找到是哪个语句
3.1.6.3 MybatisX的使用
MybatisX是一款基于IDEA的快速开发Mybatis的插件,为效率而生。
MybatisX的安装:

可以通过MybatisX快速定位:

MybatisX的使用在后续学习中会继续分享。
👀
学习了Mybatis中XML配置文件的开发方式了,大家可能会存在一个疑问:到底是使用注解方式开发还是使用XML方式开发?
官方说明:https://mybatis.net.cn/getting-started.html。下面是官方说明:

结论:使用Mybatis的注解,主要是来完成一些简单的增删改查功能。如果需要实现复杂的SQL功能,建议使用XML来配置映射语句。
📚如果没有按照3点规范,自定义了一个包存放配置文件,需要
指定xml配置文件的位置
4. SpringBoot配置文件
4.1 介绍
前面我们一直使用springboot项目创建完毕后自带的application.properties进行属性的配置,而如果在项目中,我们需要配置大量的属性,采用properties配置文件这种 key=value 的配置形式,就会显得配置文件的层级结构不清晰,也比较臃肿。

那其实呢,在springboot项目当中是支持多种配置方式的,除了支持properties配置文件以外,还支持另外一种类型的配置文件,就是我们接下来要讲解的yml格式的配置文件。yml格式配置文件名字为:application.yaml , application.yml 这两个配置文件的后缀名虽然不一样,但是里面配置的内容形式都是一模一样的。
我们可以来对比一下,采用 application.properties 和 application.yml 来配置同一段信息(数据库连接信息),两者之间的配置对比:


✅在项目开发中,我们推荐使用
application.yml配置文件来配置信息,简洁、明了、以数据为中心。
4.2 语法
简单的了解过springboot所支持的配置文件,以及不同类型配置文件之间的优缺点之后,接下来我们就来了解下yml配置文件的基本语法:
- 大小写敏感
- 数值前边
必须有空格,作为分隔符 - 使用
缩进表示层级关系,缩进时,不允许使用Tab键,只能用空格(idea中会自动将Tab转换为空格) - 缩进的空格数目不重要,只要相同层级的元素左侧对齐即可
#表示注释,从这个字符一直到行尾,都会被解析器忽略

了解完yml格式配置文件的基本语法之后,接下来我们再来看下yml文件中常见的数据格式。在这里我们主要介绍最为常见的两类:
- 定义对象或Map集合
- 定义数组、list或set集合
- 对象/Map集合
yml
user:
name: zhangsan
age: 18
password: 123456
- 数组/List/Set集合
yml
hobby:
- java
- game
- sport
📍在yml格式的配置文件中,如果配置项的值是以 0 开头的,值需要使用
''引起来,因为以0开头在yml中表示8进制的数据。
4.3 案例
熟悉完了yml文件的基本语法后,我们修改下之前案例中使用的配置文件,变更为application.yml配置方式:
- 修改application.properties名字为:_application.properties(名字随便更换,只要加载不到即可)
- 创建新的配置文件:
application.yml
- 原有的 application.properties 配置文件

- 新建的 application.yml 配置文件

配置文件的内容如下:
yaml
#数据源配置
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/web01
username: root
password: root@1234
#mybatis配置
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
✋


07-后端Web实战(部门管理)
前言
前面的课程中,我们已经学习了Web开发的基础知识,包括像Maven、HTTP协议、SpringBootWeb基础、IOC、DI、MySQL、JDBC、Mybatis等。
接下来呢,我们就要进入到后端Web实战篇的学习,在实战篇中,就要将前面学习的基础知识用起来,来完成一个大的综合案例 - Tlias智能学习辅助系统。
那这个案例里面呢,包括以下功能:
-
部门管理

-
员工管理
-
员工信息统计
-
学员信息统计
-
班级、学员管理
-
班级、学员管理
在整个实战篇中,我们需要完成如下功能:
- 部门管理:查询、新增、修改、删除
- 员工管理:
- 查询、新增、修改、删除
- 文件上传
- 报表统计
- 登录认证
- 日志管理
- 班级管理(自己实战内容)
- 学员管理(自己实战内容)
那今天呢,我们就先来完成第一个模块:部门管理。
1. 准备工作
1.1 开发规范
1.1.1 前后端分离开发
在之前的课程中,我们介绍过,现在的企业项目开发有2种开发模式:前后台混合开发和前后台分离开发。
前后台混合开发,顾名思义就是前台后台代码混在一起开发,如下图所示:

这种开发模式有如下缺点:
- 沟通成本高:后台人员发现前端有问题,需要找前端人员修改,前端修改成功,再交给后台人员使用
- 分工不明确:后台开发人员需要开发后台代码,也需要开发部分前端代码。很难培养专业人才
- 不便管理:所有的代码都在一个工程中
- 难以维护:前端代码更新,和后台无关,但是需要整个工程包括后台一起重新打包部署。
所以我们目前基本都是采用的前后台分离 开发方式,如下图所示:

我们将原先的工程分为前端工程和后端工程这2个工程,然后前端工程交给专业的前端人员开发,后端工程交给专业的后端人员开发。
前端页面需要数据,可以通过发送异步请求,从后台工程获取。但是,我们前后台是分开来开发的,那么前端人员怎么知道后台返回数据的格式呢?后端人员开发,怎么知道前端人员需要的数据格式呢?
所以针对这个问题,我们前后台统一制定一套规范!我们前后台开发人员都需要遵循这套规范开发,这就是我们的接口文档。
接口文档有离线版和在线版本,接口文档示可以查询今天提供资料/接口文档里面的资料。
那么接口文档的内容怎么来的呢?是我们后台开发者根据产品经理提供的产品原型和需求文档所撰写出来的,产品原型示例可以参考今天提供资料/页面原型里面的资料。
那么基于前后台分离开发的模式下,我们后台开发者开发一个功能的具体流程如何呢?如下图所示:

- 需求分析:首先我们需要阅读需求文档,分析需求,理解需求。
- 接口定义:查询接口文档中关于需求的接口的定义,包括地址,参数,响应数据类型等等
- 前后台并行开发:各自按照接口文档进行开发,实现需求
- 测试:前后台开发完了,各自按照接口文档进行测试
- 前后段联调测试:前段工程请求后端工程,测试功能
1.1.2 Restful风格
我们的案例是基于当前最为主流的前后端分离模式进行开发。

在前后端分离的开发模式中,前后端开发人员都需要根据提前定义好的接口文档,来进行前后端功能的开发。
后端开发人员:必须严格遵守提供的接口文档进行后端功能开发(保障开发的功能可以和前端对接)
而在前后端进行交互的时候,我们需要基于当前主流的REST风格的API接口进行交互。
📚接口文档
什么是REST风格呢?
- REST(Representational State Transfer),表述性状态转换,它是一种软件架构风格。
传统URL风格如下:
http://localhost:8080/user/getById?id=1GET:查询id为1的用户http://localhost:8080/user/saveUserPOST:新增用户http://localhost:8080/user/updateUserPOST:修改用户http://localhost:8080/user/deleteUser?id=1GET:删除id为1的用户
我们看到,原始的传统URL呢,定义比较复杂,而且将资源的访问行为对外暴露出来了。而且,对于开发人员来说,每一个开发人员都有自己的命名习惯,就拿根据id查询用户信息来说的,不同的开发人员定义的路径可能是这样的:getById,selectById,queryById,loadById... 。 每一个人都有自己的命名习惯,如果都按照各自的习惯来,一个项目组,几十号或上百号人,那最终开发出来的项目,将会变得难以维护,没有一个统一的标准。
基于REST风格URL如下:
http://localhost:8080/users/1GET:查询id为1的用户http://localhost:8080/usersPOST:新增用户http://localhost:8080/usersPUT:修改用户http://localhost:8080/users/1DELETE:删除id为1的用户
⭐️其中总结起来,就一句话:通过URL定位要操作的资源,通过HTTP动词(请求方式)来描述具体的操作。
用REST的话可以统一路径
在REST风格的URL中,通过四种请求方式,来操作数据的增删改查。
- GET : 查询
- POST :新增
- PUT : 修改
- DELETE :删除
我们看到如果是基于REST风格,定义URL,URL将会更加简洁、更加规范、更加优雅。
📍注意事项:
- REST是风格,是约定方式,约定不是规定,可以打破
- 描述模块的功能通常使用复数,也就是加s的格式来描述,表示此类资源,而非单个资源。如:users、emps、books...
1.1.3 Apifox
我们上面讲到,在这个案例中,我们将会基于Restful风格的接口进行交互,那么其中就涉及到常见的4中请求方式,包括:POST、DELETE、PUT、GET。

因为在浏览器地中所发起的所有的请求,都是GET方式的请求。那大家就需要思考两个问题:
- 前后端都在并行开发,后端开发完对应的接口之后,如何对接口进行请求测试呢?
在网址当中只能发送GET请求
- 前后端都在并行开发,前端开发过程中,如何获取到数据,测试页面的渲染展示呢?
那这里我们就可以借助一些接口测试工具 ,比如项:Postman、Apipost、Apifox等。

那这些工具的使用基本类似,只不过Apifox工具的功能更强强大、更加完善,所以在课程中,我们会采用功能更为强大的Apifox工具。
1.1.3.1 介绍
介绍:Apifox是一款集成了Api文档、Api调试、Api Mock、Api测试的一体化协作平台。
作用:接口文档管理、接口请求测试、Mock服务。

1.1.3.2 安装
下载Apifox的安装包(资料中已经提供),直接双击安装 。

安装完成之后,就可以扫码登录Apifox,就可以使用啦。。。


☀️

1.2 工程搭建
首先创建一个空项目,指定jdk版本,指定maven路径,指定字符集
1). 创建SpringBoot工程,并引入web开发起步依赖、mybatis、mysql驱动、lombok。
- 创建项目

✋

2). 创建数据库及对应的表结构,并在application.yml中配置数据库的基本信息。
- 创建tlias数据库,并准备dept部门表。
sql
CREATE TABLE dept (
id int unsigned PRIMARY KEY AUTO_INCREMENT COMMENT 'ID, 主键',
name varchar(10) NOT NULL UNIQUE COMMENT '部门名称',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间'
) COMMENT '部门表';
INSERT INTO dept VALUES (1,'学工部','2023-09-25 09:47:40','2024-07-25 09:47:40'),
(2,'教研部','2023-09-25 09:47:40','2024-08-09 15:17:04'),
(3,'咨询部','2023-09-25 09:47:40','2024-07-30 21:26:24'),
(4,'就业部','2023-09-25 09:47:40','2024-07-25 09:47:40'),
(5,'人事部','2023-09-25 09:47:40','2024-07-25 09:47:40'),
(6,'行政部','2023-11-30 20:56:37','2024-07-30 20:56:37');
🐛执行sql前要先
sql
use tlias
- 在 application.yml 配置文件中配置数据库的连接信息。
yml
spring:
application:
name: tlias-web-management
#mysql连接配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/tlias
username: root
password: 1234
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
3). 准备基础包结构,并引入实体类Dept及统一的响应结果封装类Result
- 准备基础包结构

- 实体类Dept
java
package com.itheima.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Dept {
private Integer id;
private String name;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
- 统一响应结果Result
java
package com.itheima.pojo;
import lombok.Data;
import java.io.Serializable;
/**
* 后端统一返回结果
*/
@Data
public class Result {
private Integer code; //编码:1成功,0为失败
private String msg; //错误信息
private Object data; //数据
public static Result success() {
Result result = new Result();
result.code = 1;
result.msg = "success";
return result;
}
public static Result success(Object object) {
Result result = new Result();
result.data = object;
result.code = 1;
result.msg = "success";
return result;
}
public static Result error(String msg) {
Result result = new Result();
result.msg = msg;
result.code = 0;
return result;
}
}
📚不统一的话不便于项目的管理和维护
- 基础代码结构

- DeptMapper
java
package com.itheima.mapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DeptMapper {
}
- DeptService
java
package com.itheima.service;
public interface DeptService {
}
- DeptServiceImpl
java
package com.itheima.service.impl;
import com.itheima.service.DeptService;
import org.springframework.stereotype.Service;
@Service
public class DeptServiceImpl implements DeptService {
}
- DeptController
java
package com.itheima.controller;
import org.springframework.web.bind.annotation.RestController;
/**
* 部门管理控制器
*/
@RestController
public class DeptController {
}
2. 查询部门
2.1 基本实现
2.1.1 需求
查询所有的部门数据,查询出来展示在部门管理的页面中。页面原型效果如下:

2.1.2 接口描述
参照课程资料中提供的接口文档。 部门管理 -> 部门列表查询
2.1.3 实现思路
明确了删除部门的需求之后,再来梳理一下实现该功能时,三层架构每一层的职责:

- Controller层,负责接收前端发起的请求,并调用service查询部门数据,然后响应结果。
- Service层,负责调用Mapper接口方法,查询所有部门数据。
- Mapper层,执行查询所有部门数据的操作。
2.1.4 代码实现
1). Controller层
在 DeptController 中,增加 list 方法,代码如下:
java
/**
* 部门管理控制器
*/
@RestController
public class DeptController {
@Autowired
private DeptService deptService;
/**
* 查询部门列表
*/
@RequestMapping("/depts")
public Result list(){
List<Dept> deptList = deptService.findAll();
return Result.success(deptList);
}
}
Result 工具类的 success 是重载方法。
- 查询操作会查询出业务对象,调用携带 data 参数的 success方法,把查询结果封装;
- 新增修改删除不需要返回业务数据,调用无参的 success 方法。
@Autowired 做的事 :Spring 从 IOC 容器拿到一个
DeptService 类型的对象,赋值给DeptController类的成员变量deptService。
2). Service层
在 DeptService 中,增加 findAll方法,代码如下:
java
public interface DeptService {
/**
* 查询所有部门
*/
public List<Dept> findAll();
}
在 DeptServiceImpl 中,增加 findAll方法,代码如下:
java
@Service
public class DeptServiceImpl implements DeptService {
@Autowired
private DeptMapper deptMapper;
public List<Dept> findAll() {
return deptMapper.findAll();
}
}
3). Mapper层
在 DeptMapper 中,增加 findAll方法,代码如下:
java
@Mapper
public interface DeptMapper {
/**
* 查询所有部门
*/
@Select("select * from dept")
public List<Dept> findAll();
}
2.1.5 接口测试
启动项目,然后我们就可以打开Apifox进行测试了。

我们发现,已经查询出了所有的部门数据,并且响应回来的就是json格式的数据,与接口文档一致。 那接下来,我们再来测试一下,这个查询操作,我们使用post、put、delete方式来请求,是否可以获取到数据。

经过测试,我们发现,现在我们其实是可以通过任何方式的请求来访问查询部门的这个接口的。 而在接口文档中,明确要求该接口的请求方式为GET,那么如何限制请求方式呢?
- 方式一:在controller方法的@RequestMapping注解中通过method属性来限定。
java
@RestController
public class DeptController {
@Autowired
private DeptService deptService;
/**
* 查询部门列表
*/
@RequestMapping(value = "/depts", method = RequestMethod.GET)
public Result list(){
List<Dept> deptList = deptService.findAll();
return Result.success(deptList);
}
}
- 方式二:在controller方法上使用,
java
@RequestMapping的衍生注解 @GetMapping。 该注解就是标识当前方法,必须以GET方式请求。
@RestController
public class DeptController {
@Autowired
private DeptService deptService;
/**
* 查询部门列表
*/
@GetMapping("/depts")
public Result list(){
List<Dept> deptList = deptService.findAll();
return Result.success(deptList);
}
}
上述两种方式,在项目开发中,推荐使用第二种方式,简洁、优雅。
- GET方式:
@GetMapping - POST方式:
@PostMapping - PUT方式:
@PutMapping - DELETE方式:
@DeleteMapping
✋


2.1.6 数据封装
在上述测试中,我们发现部门的数据中,id、name两个属性是有值的,但是createTime、updateTime两个字段值并未成功封装,而数据库中是有对应的字段值的,这是为什么呢?
原因如下:
- 实体类属性名和数据库表查询返回的字段名一致,mybatis会自动封装。
- 如果实体类属性名和数据库表查询返回的字段名不一致,不能自动封装。
解决方案:
- 手动结果映射
- 起别名
- 开启驼峰命名
1). 手动结果映射
在DeptMapper接口方法上,通过 @Results及@Result 进行手动结果映射。
⭐️原因如下:
- 实体类属性名和数据库表查询返回的字段名一致,mybatis会自动封装。
- 如果实体类属性名和数据库表查询返回的字段名不一致,不能自动封装。
解决方案:
- 手动结果映射
- 起别名
- 开启驼峰命名
1). 手动结果映射
在DeptMapper接口方法上,通过 @Results及@Result 进行手动结果映射。
java
@Results({@Result(column = "create_time", property = "createTime"),
@Result(column = "update_time", property = "updateTime")})
@Select("select id, name, create_time, update_time from dept")
public List<Dept> findAll();
说明:
- @Results 注解源码:

- @Result源代码:

2). 起别名
在SQL语句中,对不一样的列名起别名,别名和实体类属性名一样。
sql
@Select("select id, name, create_time createTime, update_time updateTime from dept")
public List<Dept> findAll();
3). 开启驼峰命名(推荐)
如果字段名与属性名符合驼峰命名规则,mybatis会自动通过驼峰命名规则映射。驼峰命名规则: abc_xyz => abcXyz
- 表中字段名:abc_xyz
- 类中属性名:abcXyz
⭐️在application.yml中做如下配置,开启开关。
yaml
mybatis:
configuration:
map-underscore-to-camel-case: true
只需要记一个单词"开启驼峰":map-underscore
📍要使用驼峰命名前提是 实体类的属性 与 数据库表中的字段名严格遵守驼峰命名。
✋
使用第三种方法

2.2 前后端联调
2.2.1 联调测试
完成了查询部门的功能,我们也通过 Apifox 工具测试通过了,下面我们再基于前后端分离的方式进行接口联调。具体操作如下:
1). 将资料中提供的 "前端环境" 文件夹中的压缩包,拷贝到一个没有中文不带空格的目录下。

2). 解压(解压到当前目录)

介绍一下三个目录:
1、conf:存放的是nginx的配置文件
2、html:放要部署的前端项目
3、logs:存放的是运行日志
3). 双击 nginx.exe 启动Nginx,一闪而过,就说明nginx已启动完成。

如果在任务管理器中,能看到上述两个进程,就说明nginx已经启动成功。
4). 打开浏览器,访问:http://localhost:90

✋
数据展示成功了

2.2.2 请求访问流程
前端工程请求服务器的地址为 http://localhost:90/api/depts,是如何访问到后端的tomcat服务器的?

其实这里,是通过前端服务Nginx中提供的反向代理功能实现的。

1). 浏览器发起请求,请求的是localhost:90 ,那其实请求的是nginx服务器。
2). 在nginx服务器中呢,并没有对请求直接进行处理,而是将请求转发给了后端的tomcat服务器,最终由tomcat服务器来处理该请求。
这个过程就是通过nginx的反向代理实现的。 那为什么浏览器不直接请求后端的tomcat服务器,而是直接请求nginx服务器呢,主要有以下几点原因:

📚nginx就是代理服务器
⭐️
1). 安全 :由于后端的tomcat服务器一般都会搭建集群,会有很多的服务器,把所有的tomcat暴露给前端,让前端直接请求tomcat,对于后端服务器是比较危险的。
2). 灵活 :基于nginx的反向代理实现,更加灵活,后端想增加、减少服务器,对于前端来说是无感知的,只需要在nginx中配置即可。
3). 负载均衡:基于nginx的反向代理,可以很方便的实现后端tomcat的负载均衡操作。
📚负载均衡:
后端不止一台服务器,有多台一模一样的 Java 服务,Nginx 把用户请求分摊分给多台后端机器,不要全部压在某一台上面,这就是负载均衡。
具体的请求访问流程如下:

- location:用于定义匹配特定uri请求的规则。
^~ /api/:表示精确匹配,即只匹配以/api/开头的路径。- rewrite:该指令用于重写匹配到的uri路径。
(简单说就是,例如:/api/depts变成/depts)- proxy_pass:该指令用于代理转发,它将匹配到的请求转发给位于后端的指令服务器。
☀️

3. 删除部门
3.1 需求
删除部门数据。在点击 "删除" 按钮,会根据ID删除部门数据。

了解了需求之后,我们再看看接口文档中,关于删除部门的接口的描述,然后根据接口文档进行服务端接口的开发。
3.2 接口描述
参照课程资料中提供的接口文档。 部门管理 -> 删除部门

3.3 思路分析
明确了删除部门的需求之后,再来梳理一下实现该功能时,三层架构每一层的职责:

3.4 简单参数接收
我们看到,在controller中,需要接收前端传递的请求参数。 那接下来,我们就先来看看在服务器端的Controller程序中,如何获取这类简单参数。 具体的方案有如下三种:
- 方案一:通过原始的
HttpServletRequest 对象获取请求参数
java
/**
* 根据ID删除部门 - 简单参数接收: 方式一 (HttpServletRequest)
*/
@DeleteMapping("/depts")
public Result delete(HttpServletRequest request){
String idStr = request.getParameter("id");
int id = Integer.parseInt(idStr);
System.out.println("根据ID删除部门: " + id);
return Result.success();
}
这种方案实现较为繁琐,而且还需要进行手动类型转换。【项目开发很少用】
- 方案二:通过Spring提供的 @RequestParam 注解,将请求参数绑定给方法形参
📚不用手动转换类型,会自动转换
java
@DeleteMapping("/depts") public Result delete(@RequestParam("id") Integer deptId){
System.out.println("根据ID删除部门: " + deptId);
return Result.success(); }
@RequestParam 注解的value属性,需要与前端传递的参数名保持一致 。
📍@RequestParam注解required属性默认为true,代表该参数必须传递,如果不传递将报错。
如果参数可选,可以将属性设置为false。
- 方案三:如果
请求参数名与形参变量名相同,直接定义方法形参即可接收。(省略@RequestParam)
- 请求参数名:浏览器 / 前端发请求时,参数的名字(URL 上、表单里的名字),属于请求里面的名字。
- 形参变量名:Java的Controller 方法括号里写的变量名字,属于Java 代码里的变量名。
java
@DeleteMapping("/depts")
public Result delete(Integer id){
System.out.println("根据ID删除部门: " + deptId);
return Result.success();
}
对于以上的这三种方案呢,我们推荐第三种方案。
☀️

3.5 代码实现
1). Controller层
在 DeptMapper 中,增加 delete 方法,代码实现如下:
java
/**
* 根据id删除部门 - delete http://localhost:8080/depts?id=1
*/
@DeleteMapping("/depts")
public Result delete(Integer id){
System.out.println("根据id删除部门, id=" + id);
deptService.deleteById(id);
return Result.success();
}
2). Service层
在 DeptService 中,增加 deleteById 方法,代码实现如下:
java
/**
* 根据id删除部门
*/
void deleteById(Integer id);
在 DeptServiceImpl 中,增加 deleteById 方法,代码实现如下:
java
public void deleteById(Integer id) {
deptMapper.deleteById(id);
}
3). Mapper层
在 DeptMapper 中,增加 deleteById 方法,代码实现如下:
java
/**
* 根据id删除部门
*/
@Delete("delete from dept where id = #{id}")
void deleteById(Integer id);
如果mapper接口方法形参只有一个普通类型的参数 ,#{...} 里面的属性名可以随便写,如:#{id}、#{value}。
对于 DML 语句来说,执行完毕,也是有返回值的,返回值代表的是增删改操作,影响的记录数,所以可以将执行 DML 语句的方法返回值设置为 Integer。 但是一般开发时,是不需要这个返回值的, 所以也可以设置为void。
代码编写完毕之后,我们就可以启动服务,进行测试了。

✋

☀️

4. 新增部门
4.1 需求
点击 "新增部门" 的按钮之后,弹出新增部门表单,填写部门名称之后,点击确定之后,保存部门数据。

了解了需求之后,我们再看看接口文档中,关于新增部门的接口的描述,然后根据接口文档进行服务端接口的开发 。
4.2 接口描述
参照课程资料中提供的接口文档。 部门管理 -> 新增部门

4.3 思路分析
明确了新增部门的需求之后,再来梳理一下实现该功能时,三层架构每一层的职责:

4.4 json参数接收
我们看到,在controller中,需要接收前端传递的请求参数。 那接下来,我们就先来看看在服务器端的Controller程序中,如何获取json格式的参数。
- JSON格式的参数,通常会使用一个实体对象进行接收 。
- 规则:JSON数据的键名与方法形参对象的属性名相同,并需要使用
@RequestBody注解标识。
前端传递的请求参数格式为json,内容如下:{"name":"研发部"}。这里,我们可以通过一个对象来接收,只需要保证对象中有name属性即可。

📚
@RequestBody:读请求体 JSON,用于 JSON 提交。
@RequestParam:读 url 问号后的查询参数。
📚HttpServletRequest:请求,拿前端传来的东西
HttpServletResponse:响应,写给前端
@RequestParam、@PathVariable、@RequestBody,底层全部都是基于 HttpServletRequest 实现的。
4.5 代码实现
1). Controller层
在DeptController中增加方法save,具体代码如下:
java
/**
* 新增部门 - POST http://localhost:8080/depts 请求参数:{"name":"研发部"}
*/
@PostMapping("/depts")
public Result save(@RequestBody Dept dept){
System.out.println("新增部门, dept=" + dept);
deptService.save(dept);
return Result.success();
}
2). Service层
在DeptService中增加接口方法save,具体代码如下:
java
/**
* 新增部门
*/
void save(Dept dept);
在DeptServiceImpl中增加save方法,完成添加部门的操作,具体代码如下:
java
public void save(Dept dept) {
//补全基础属性
dept.setCreateTime(LocalDateTime.now());
dept.setUpdateTime(LocalDateTime.now());
//保存部门
deptMapper.insert(dept);
}
3). Mapper层
java
/**
* 保存部门
*/
@Insert("insert into dept(name,create_time,update_time) values(#{name},#{createTime},#{updateTime})")
void insert(Dept dept);
如果在mapper接口中,需要传递多个参数,可以把多个参数封装到一个对象中。 在SQL语句中获取参数的时候,#{...} 里面写的是对象的属性名【注意是属性名,不是表的字段名】。
代码编写完毕之后,我们就可以启动服务,进行测试了。
java
{
"name": "教研部"
}


✋
一开始状态码500错误,服务器出问题,

将报错提交给后端后说是,数据表部门名字是唯一的,教研部已存在,
改成财务部后,接口测试通过了,

前后端联调也成功,

☀️


5. 修改部门
对于任何业务的修改功能来说,一般都会分为两步进行:查询回显、修改数据。

5.1 查询回显
5.1.1 需求
当我们点击 "编辑" 的时候,需要根据ID查询部门数据,然后用于页面回显展示。

5.1.2 接口描述
参照参照课程资料中提供的接口文档。 部门管理 -> 根据ID查询

5.1.3 思路分析
明确了根据ID查询部门的需求之后,再来梳理一下实现该功能时,三层架构每一层的职责:

了解了需求之后,我们再看看接口文档中,关于根据ID查询部门的接口的描述,然后根据接口文档进行服务端接口的开发 。
5.1.4 路径参数接收
/depts/1,/depts/2 这种在url中传递的参数,我们称之为路径参数。 那么如何接收这样的路径参数呢 ?
路径参数:通过请求URL直接传递参数,使用{...}来标识该路径参数,需要使用 @PathVariable获取路径参数。如下所示:

如果路径参数名与controller方法形参名称一致,@PathVariable注解的value属性是可以省略的。
5.1.5 代码实现
1). Controller层
在 DeptController 中增加 getById方法,具体代码如下:
java
/**
* 根据ID查询 - GET http://localhost:8080/depts/1
*/
@GetMapping("/depts/{id}")
public Result getById(@PathVariable Integer id){
System.out.println("根据ID查询, id=" + id);
Dept dept = deptService.getById(id);
return Result.success(dept);
}
2). Service层
在 DeptService 中增加 getById方法,具体代码如下:
java
/**
* 根据id查询部门
*/
Dept getById(Integer id);
在 DeptServiceImpl 中增加 getById方法,具体代码如下:
public Dept getById(Integer id) {
return deptMapper.getById(id);
}
3). Mapper层
在 DeptMapper 中增加 getById 方法,具体代码如下:
java
/**
* 根据ID查询部门数据
*/
@Select("select id, name, create_time, update_time from dept where id = #{id}")
Dept getById(Integer id);
代码编写完毕之后,我们就可以启动服务,进行测试了。

✋


☀️


5.2 修改数据
5.2.1 需求
查询回显回来之后,就可以对部门的信息进行修改了,修改完毕之后,点击确定,此时,就需要根据ID修改部门的数据。

了解了需求之后,我们再看看接口文档中,关于修改部门的接口的描述,然后根据接口文档进行服务端接口的开发 。
5.2.2 接口描述
参照参照课程资料中提供的接口文档。 部门管理 -> 修改部门

5.2.3 思路分析
参照接口文档,梳理三层架构每一层的职责:

通过接口文档,我们可以看到前端传递的请求参数是json格式的请求参数,在Controller的方法中,我们可以通过 @RequestBody 注解来接收,并将其封装到一个对象中。
5.2.4 代码实现
1). Controller层
在 DeptController 中增加 update 方法,具体代码如下:
java
/**
* 修改部门 - PUT http://localhost:8080/depts 请求参数:{"id":1,"name":"研发部"}
*/
@PutMapping("/depts")
public Result update(@RequestBody Dept dept){
System.out.println("修改部门, dept=" + dept);
deptService.update(dept);
return Result.success();
}
2). Service层
在 DeptService 中增加 update 方法。
java
/**
* 修改部门
*/
void update(Dept dept);
在 DeptServiceImpl 中增加 update 方法。 由于是修改操作,每一次修改数据,都需要更新updateTime。所以,具体代码如下:
public void update(Dept dept) {
//补全基础属性
dept.setUpdateTime(LocalDateTime.now());
//保存部门
deptMapper.update(dept);
}
3). Mapper层
在 DeptMapper 中增加 update 方法,具体代码如下:
java
/**
* 更新部门
*/
@Update("update dept set name = #{name},update_time = #{updateTime} where id = #{id}")
void update(Dept dept);
代码编写完毕之后,我们就可以启动服务,进行测试了。
java
{
"id": 1,
"name": "修改部"
}

修改完成之后,我们可以看到最新的数据,如下:

✋


☀️

☀️自己小结:
- 简单参数接收 :从 url 问号后获取,
@RequestParam,同名注解可省略。- JSON 参数接收 :读取请求体 body,必须加
@RequestBody,多用于 POST、PUT。- 路径参数接收 :取自 url 路径占位符{},使用
@PathVariable接收。
通过网盘分享的文件:api接口文档.md
链接: https://pan.baidu.com/s/1uMs3rOEVvcrZLhVCEUJfqg 提取码: fqe5
--来自百度网盘超级会员v1的分享
5.2.5 @RequestMapping
到此呢,关于基本的部门的增删改查功能,我们已经实现了。 我们会发现,我们在 DeptController 中所定义的方法,所有的请求路径,都是 /depts 开头的,只要操作的是部门数据,请求路径都是 /depts 开头。
那么这个时候,我们其实是可以把这个公共的路径 /depts 抽取到类上的,那在各个方法上,就可以省略了这个 /depts 路径。 代码如下:

✅一个完整的请求路径,应该是类上的
@RequestMapping 的value属性+ 方法上的
@RequestMapping的value属性。
6. 日志技术
6.1 概述
-
什么是日志?
- 日志就好比生活中的日记,可以随时随地记录你生活中的点点滴滴。
- 程序中的日志,是用来记录应用程序的运行信息、状态信息、错误信息的。
-
⭐️为什么要在程序中记录日志呢?
- 数据追踪:便于追踪应用程序中的数据信息、程序的执行过程。
- 性能优化:便于对应用程序的性能进行优化。
- 问题排查:便于应用程序出现问题之后,排查问题,解决问题。
- 系统监控:便于监控系统的运行状态。
- ... ...
-
之前我们编写程序时,也可以通过
System.out.println(...)来输出日志,为什么我们还要学习单独的日志技术呢?这是因为,如果通过 System.out.println(...) 来记录日志,会存在以下几点问题:
- 硬编码。所有的记录日志的代码,都是硬编码,没有办法做到灵活控制,要想不输出这个日志了,只能删除掉记录日志的代码。
- 只能输出日志到控制台。
- 不便于程序的扩展、维护。
所以,在现在的项目开发中,我们一般都会使用专业的日志框架,来解决这些问题。
6.2 日志框架
- JUL:这是JavaSE平台提供的官方日志框架,也被称为JUL。配置相对简单,但不够灵活,性能较差。
- Log4j:一个流行的日志框架,提供了灵活的配置选项,支持多种输出目标。
(可以将日志保存在数据库) - Logback:基于Log4j升级而来,提供了更多的功能和配置选项,性能由于Log4j。
- Slf4j:(Simple Logging Facade for Java)简单日志门面,提供了一套日志操作的标准接口及抽象类,允许应用程序使用不同的底层日志框架。
6.3 Logback入门
1). 准备工作:引入logback的依赖(springboot中无需引入,在springboot中已经传递了此依赖)
xml
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.11</version>
</dependency>
2). 引入配置文件 logback.xml (资料中已经提供,拷贝进来,放在 src/main/resources 目录下; 或者直接AI生成)
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度 %msg:日志消息,%n是换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>
<!-- 日志输出级别 -->
<root level="ALL">
<appender-ref ref="STDOUT" />
</root>
</configuration>
3). 记录日志:定义日志记录对象Logger,记录日志
java
public class LogTest {
//定义日志记录对象
private static final Logger log = LoggerFactory.getLogger(LogTest.class);
@Test
public void testLog(){
log.debug("开始计算...");
int sum = 0;
int[] nums = {1, 5, 3, 2, 1, 4, 5, 4, 6, 7, 4, 34, 2, 23};
for (int i = 0; i < nums.length; i++) {
sum += nums[i];
}
log.info("计算结果为: "+sum);
log.debug("结束计算...");
}
}
运行单元测试,可以在控制台中看到输出的日志,如下所示:

我们可以看到在输出的日志信息中,不仅输出了日志的信息,还包括:日志的输出时间、线程名、具体在那个类中输出的。
☀️

✋
java
package com.itheima;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.junit.jupiter.api.Test;
public class LogTest {
//定义日志记录对象
private static final Logger log = LoggerFactory.getLogger(LogTest.class);
@Test
public void testLog(){
log.debug("开始计算...");
int sum = 0;
int[] nums = {1, 5, 3, 2, 1, 4, 5, 4, 6, 7, 4, 34, 2, 23};
for (int i = 0; i < nums.length; i++) {
sum += nums[i];
}
log.info("计算结果为: "+sum);
log.debug("结束计算...");
}
}

6.4 Logback配置文件
Logback日志框架的配置文件叫 logback.xml 。
该配置文件是对Logback日志框架输出的日志进行控制的,可以来配置输出的格式、位置及日志开关等。
常用的两种输出日志的位置:控制台、系统文件。
1). 如果需要输出日志到控制台。添加如下配置:
没认真听,总之就是输出的格式
xml
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d 表示日期,%thread 表示线程名,%-5level表示级别从左显示5个字符宽度,%msg表示日志消息,%n表示换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>
2). 如果需要输出日志到文件。添加如下配置:
xml
<!-- 按照每天生成日志文件 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 日志文件输出的文件名, %i表示序号 -->
<FileNamePattern>D:/tlias-%d{yyyy-MM-dd}-%i.log</FileNamePattern>
<!-- 最多保留的历史日志文件数量 -->
<MaxHistory>30</MaxHistory>
<!-- 最大文件大小,超过这个大小会触发滚动到新文件,默认为 10MB -->
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d 表示日期,%thread 表示线程名,%-5level表示级别从左显示5个字符宽度,%msg表示日志消息,%n表示换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>
3). 日志开关配置 (开启日志(ALL),取消日志(OFF))
xml
<!-- 日志输出级别 -->
<root level="ALL">
<!--输出到控制台-->
<appender-ref ref="STDOUT" />
<!--输出到文件-->
<appender-ref ref="FILE" />
</root>
完整的配置文件:
xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 控制台输出 -->
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d 表示日期,%thread 表示线程名,%-5level表示级别从左显示5个字符宽度,%logger显示日志记录器的名称, %msg表示日志消息,%n表示换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>
<!-- 系统文件输出 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<!-- 日志文件输出的文件名, %i表示序号 -->
<FileNamePattern>D:/tlias-%d{yyyy-MM-dd}-%i.log</FileNamePattern>
<!-- 最多保留的历史日志文件数量 -->
<MaxHistory>30</MaxHistory>
<!-- 最大文件大小,超过这个大小会触发滚动到新文件,默认为 10MB -->
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<!--格式化输出:%d 表示日期,%thread 表示线程名,%-5level表示级别从左显示5个字符宽度,%msg表示日志消息,%n表示换行符 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50}-%msg%n</pattern>
</encoder>
</appender>
<!-- 日志输出级别 -->
<root level="ALL">
<appender-ref ref="STDOUT" />
<appender-ref ref="FILE" />
</root>
</configuration>
6.5 Logback日志级别
日志级别指的是日志信息的类型,日志都会分级别,常见的日志级别如下(优先级由低到高):

可以在配置文件logback.xml中,灵活的控制输出那些类型的日志。(大于等于配置的日志级别的日志才会输出)
xml
<!-- 日志输出级别 -->
<root level="info">
<!--输出到控制台-->
<appender-ref ref="STDOUT" />
<!--输出到文件-->
<appender-ref ref="FILE" />
</root>
6.6 案例日志记录
java
/**
* 部门管理控制器
*/
@Slf4j
@RequestMapping("/depts")
@RestController
public class DeptController {
@Autowired
private DeptService deptService;
/**
* 查询部门列表
*/
//@RequestMapping(value = "/depts", method = RequestMethod.GET)
@GetMapping
public Result list(){
//System.out.println("查询部门列表");
log.info("查询部门列表");
List<Dept> deptList = deptService.findAll();
return Result.success(deptList);
}
/**
* 根据id删除部门 - delete http://localhost:8080/depts?id=1
*/
@DeleteMapping
public Result delete(Integer id){
//System.out.println("根据id删除部门, id=" + id);
log.info("根据id删除部门, id: {}" , id);
deptService.deleteById(id);
return Result.success();
}
/**
* 新增部门 - POST http://localhost:8080/depts 请求参数:{"name":"研发部"}
*/
@PostMapping
public Result save(@RequestBody Dept dept){
//System.out.println("新增部门, dept=" + dept);
log.info("新增部门, dept: {}" , dept);
deptService.save(dept);
return Result.success();
}
/**
* 根据ID查询 - GET http://localhost:8080/depts/1
*/
@GetMapping("/{id}")
public Result getById(@PathVariable Integer id){
//System.out.println("根据ID查询, id=" + id);
log.info("根据ID查询, id: {}" , id);
Dept dept = deptService.getById(id);
return Result.success(dept);
}
/**
* 修改部门 - PUT http://localhost:8080/depts 请求参数:{"id":1,"name":"研发部"}
*/
@PutMapping
public Result update(@RequestBody Dept dept){
//System.out.println("修改部门, dept=" + dept);
log.info("修改部门, dept: {}" , dept);
deptService.update(dept);
return Result.success();
}
}
✅lombok中提供的
@Slf4j注解,可以简化定义日志记录器这步操作。添加了该注解,就相当于在类中定义了日志记录器,就下面这句代码:
private static Logger log = LoggerFactory. getLogger(Xxx. class);
☀️

08-后端Web实战(员工管理)
实现了部门管理的功能之后,接下来我们再来实现员工管理的功能。

从页面原型中,我们可以看到,在查询员工信息的时候,除了要展示 姓名、性别、头像、职位、入职日期、最后操作时间这些员工信息外,还要展示出所属部门,那此时就需要从两张表中查询数据,一张是部门表,一张是员工表,此时就会涉及到多表操作。 所以今天的课程内容如下:
课程内容:
- 多表关系
- 多表查询
- 员工列表查询
1. 多表关系
项目开发中,在进行数据库表结构设计时,会根据业务需求及业务模块之间的关系,分析并设计表结构,由于业务之间相互关联,所以各个表结构之间也存在着各种联系,基本上分为三种:
- 一对多(多对一)
- 多对多
- 一对一
1.1 一对多
- 场景:部门与员工的关系(一个部门下有多个员工)。
- 部门管理的页面原型:

- 员工管理的页面原型:

由于一个部门下,会关联多个员工。 而一个员工,是归属于某一个部门的 。那么此时,我们就需要在 emp 表中增加一个字段 dept_id 来标识这个员工属于哪一个部门,dept_id 关联的是 dept 的 id 。 如下所示:

上述的 emp 员工表的dept_id字段,关联的是 dept 部门表的id。部门表是一的一方,也称为父表 ,员工表是多的一方,称之为子表 。

那接下来,我们就可以将上述的两张表创建出来。具体的SQL语句如下:
sql
CREATE TABLE dept (
id int unsigned PRIMARY KEY AUTO_INCREMENT COMMENT 'ID, 主键',
name varchar(10) NOT NULL UNIQUE COMMENT '部门名称',
create_time datetime DEFAULT NULL COMMENT '创建时间',
update_time datetime DEFAULT NULL COMMENT '修改时间'
) COMMENT '部门表';
INSERT INTO dept VALUES (1,'学工部','2024-09-25 09:47:40','2023-04-25 09:47:40'),
(2,'教研部','2024-09-25 09:47:40','2024-10-09 15:17:04'),
(3,'咨询部2','2024-09-25 09:47:40','2024-11-30 21:26:24'),
(4,'就业部','2024-09-25 09:47:40','2024-09-25 09:47:40'),
(5,'人事部','2024-09-25 09:47:40','2024-09-25 09:47:40'),
(6,'行政部','2024-11-30 20:56:37','2024-11-30 20:56:37');
create table emp(
id int unsigned primary key auto_increment comment 'ID,主键',
username varchar(20) not null unique comment '用户名',
password varchar(50) default '123456' comment '密码',
name varchar(10) not null comment '姓名',
gender tinyint unsigned not null comment '性别, 1:男, 2:女',
phone char(11) not null unique comment '手机号',
job tinyint unsigned comment '职位, 1 班主任, 2 讲师 , 3 学工主管, 4 教研主管, 5 咨询师',
salary int unsigned comment '薪资',
image varchar(300) comment '头像',
entry_date date comment '入职日期',
dept_id int unsigned comment '部门ID',
create_time datetime comment '创建时间',
update_time datetime comment '修改时间'
) comment '员工表';
INSERT INTO emp VALUES
(1,'shinaian','123456','施耐庵',1,'13309090001',4,15000,'5.png','2000-01-01',2,'2023-10-20 16:35:33','2023-11-16 16:11:26'),
(2,'songjiang','123456','宋江',1,'13309090002',2,8600,'01.png','2015-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:37'),
(3,'lujunyi','123456','卢俊义',1,'13309090003',2,8900,'01.png','2008-05-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:39'),
(4,'wuyong','123456','吴用',1,'13309090004',2,9200,'01.png','2007-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:41'),
(5,'gongsunsheng','123456','公孙胜',1,'13309090005',2,9500,'01.png','2012-12-05',2,'2023-10-20 16:35:33','2023-10-20 16:35:43'),
(6,'huosanniang','123456','扈三娘',2,'13309090006',3,6500,'01.png','2013-09-05',1,'2023-10-20 16:35:33','2023-10-20 16:35:45'),
(7,'chaijin','123456','柴进',1,'13309090007',1,4700,'01.png','2005-08-01',1,'2023-10-20 16:35:33','2023-10-20 16:35:47'),
(8,'likui','123456','李逵',1,'13309090008',1,4800,'01.png','2014-11-09',1,'2023-10-20 16:35:33','2023-10-20 16:35:49'),
(9,'wusong','123456','武松',1,'13309090009',1,4900,'01.png','2011-03-11',1,'2023-10-20 16:35:33','2023-10-20 16:35:51'),
(10,'linchong','123456','林冲',1,'13309090010',1,5000,'01.png','2013-09-05',1,'2023-10-20 16:35:33','2023-10-20 16:35:53'),
(11,'huyanzhuo','123456','呼延灼',1,'13309090011',2,9700,'01.png','2007-02-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:55'),
(12,'xiaoliguang','123456','小李广',1,'13309090012',2,10000,'01.png','2008-08-18',2,'2023-10-20 16:35:33','2023-10-20 16:35:57'),
(13,'yangzhi','123456','杨志',1,'13309090013',1,5300,'01.png','2012-11-01',1,'2023-10-20 16:35:33','2023-10-20 16:35:59'),
(14,'shijin','123456','史进',1,'13309090014',2,10600,'01.png','2002-08-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:01'),
(15,'sunerniang','123456','孙二娘',2,'13309090015',2,10900,'01.png','2011-05-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:03'),
(16,'luzhishen','123456','鲁智深',1,'13309090016',2,9600,'01.png','2010-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:05'),
(17,'liying','12345678','李应',1,'13309090017',1,5800,'01.png','2015-03-21',1,'2023-10-20 16:35:33','2023-10-20 16:36:07'),
(18,'shiqian','123456','时迁',1,'13309090018',2,10200,'01.png','2015-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:09'),
(19,'gudasao','123456','顾大嫂',2,'13309090019',2,10500,'01.png','2008-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:11'),
(20,'ruanxiaoer','123456','阮小二',1,'13309090020',2,10800,'01.png','2018-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:13'),
(21,'ruanxiaowu','123456','阮小五',1,'13309090021',5,5200,'01.png','2015-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:15'),
(22,'ruanxiaoqi','123456','阮小七',1,'13309090022',5,5500,'01.png','2016-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:17'),
(23,'ruanji','123456','阮籍',1,'13309090023',5,5800,'01.png','2012-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:19'),
(24,'tongwei','123456','童威',1,'13309090024',5,5000,'01.png','2006-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:21'),
(25,'tongmeng','123456','童猛',1,'13309090025',5,4800,'01.png','2002-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:23'),
(26,'yanshun','123456','燕顺',1,'13309090026',5,5400,'01.png','2011-01-01',3,'2023-10-20 16:35:33','2023-11-08 22:12:46'),
(27,'lijun','123456','李俊',1,'13309090027',2,6600,'8.png','2004-01-01',2,'2023-10-20 16:35:33','2023-11-16 17:56:59'),
(28,'lizhong','123456','李忠',1,'13309090028',5,5000,'6.png','2007-01-01',3,'2023-10-20 16:35:33','2023-11-17 16:34:22'),
(30,'liyun','123456','李云',1,'13309090030',NULL,NULL,'01.png','2020-03-01',NULL,'2023-10-20 16:35:33','2023-10-20 16:36:31'),
(36,'linghuchong','123456','令狐冲',1,'18809091212',2,6800,'1.png','2023-10-19',2,'2023-10-20 20:44:54','2023-11-09 09:41:04');
问题:一对多的表关系,在数据库层面该如何实现 ?
在数据库表中多的一方,添加字段,来关联一的一方的主键 。
1.2 多表问题分析
1.2.1 问题
表结构创建完毕后,我们看到两张表的数据分别为:


我们看到,在3号部门下,是关联的有7个员工。 当删除了3号部门后,数据变为:


3号部门被删除了,但是依然还有7个员工是属于3号部门的。 此时:就出现数据的不完整、不一致了。
1.2.2 分析
- 现象:部门数据可以直接删除,然而还有部分员工归属于该部门下,此时就出现了数据的不完整、不一致问题 。
- 原因:目前上述的两张表,在数据库层面,并未建立关联,所以是无法保证数据的一致性和完整性的 。
- 解决方案:想解决上述的问题呢,我们就可以通过数据库中的 外键约束 来解决。

外键约束:让两张表的数据建立连接,保证数据的一致性和完整性。 对应的关键字:foreign key
⭐️外键约束的语法:
sql
-- 创建表时指定
create table 表名(
字段名 数据类型,
...
[constraint] [外键名称] foreign key (外键字段名) references 主表 (主表列名)
);
sql
-- 建完表后,添加外键
alter table 表名 add constraint 外键名称 foreign key(外键字段名) references 主表(主表列名);
那接下来,我们就为员工表的dept_id 建立外键约束,来关联部门表的主键。
当我们添加外键约束时,我们得保证当前数据库表中的数据是完整的。 所以,我们需要将之前删除掉的数据再添加回来。
方式1:通过SQL语句操作
sql
-- 修改表: 添加外键约束
alter table emp add constraint fk_dept_id foreign key (dept_id) references dept(id);
方式2:图形化界面操作
在左侧菜单栏,在emp表上右键,选择 modify Table... (old UI)

当我们添加了外键之后,再删除ID为3的部门,就会发现,此时数据库报错了,不允许删除。

外键约束(foreign key):保证了数据的完整性和一致性。
📚
每一个员工归属于某一个部门,员工表dept_id存部门 id,员工表是从表,外键建在从表
✋
为员工表的dept_id字段添加外键约束 ,



有了外键约束,如果员工表还有一个部门的员工,部门表的该部门就不能删。
1.2.3 物理外键与逻辑外键
-
物理外键
- 概念:使用foreign key定义外键关联另外一张表。
- 缺点:
- 影响增、删、改的效率(需要检查外键关系)。
- 仅用于单节点数据库,不适用于分布式、集群场景。
- 容易引发数据库的死锁问题,消耗性能。
-
逻辑外键
- 概念:在业务层逻辑中,解决外键关联。
- 通过逻辑外键,就可以很方便的解决上述问题。
📍在现在的企业开发中,很少会使用物理外键,都是使用逻辑外键。 甚至在一些数据库开发规范中,会明确指出禁止使用物理外键 foreign key
1.3 一对一
一对一关系表在实际开发中应用起来比较简单,通常是用来做单表的拆分,也就是将一张大表拆分成两张小表,将大表中的一些基础字段放在一张表当中,将其他的字段放在另外一张表当中,以此来提高数据的操作效率。
一对一的应用场景: 用户表(基本信息+身份信息)

- 基本信息:用户的ID、姓名、性别、手机号、学历
- 身份信息:民族、生日、身份证号、身份证签发机关,身份证的有效期(开始时间、结束时间)
如果在业务系统当中,对用户的基本信息查询频率特别的高,但是对于用户的身份信息查询频率很低,此时出于提高查询效率的考虑,我就可以将这张大表拆分成两张小表,第一张表存放的是用户的基本信息,而第二张表存放的就是用户的身份信息。他们两者之间一对一的关系,一个用户只能对应一个身份证,而一个身份证也只能关联一个用户。
那么在数据库层面怎么去体现上述两者之间是一对一的关系呢?
其实一对一我们可以看成一种特殊的一对多。一对多我们是怎么设计表关系的?是不是在多的一方添加外键 。同样我们也可以通过外键来体现一对一之间的关系,我们只需要在任意一方来添加一个外键就可以了。

SQL脚本:
sql
-- 用户基本信息表
create table tb_user(
id int unsigned primary key auto_increment comment 'ID',
name varchar(10) not null comment '姓名',
gender tinyint unsigned not null comment '性别, 1 男 2 女',
phone char(11) comment '手机号',
degree varchar(10) comment '学历'
) comment '用户基本信息表';
-- 测试数据
insert into tb_user values (1,'白眉鹰王',1,'18812340001','初中'),
(2,'青翼蝠王',1,'18812340002','大专'),
(3,'金毛狮王',1,'18812340003','初中'),
(4,'紫衫龙王',2,'18812340004','硕士');
-- 用户身份信息表
create table tb_user_card(
id int unsigned primary key auto_increment comment 'ID',
nationality varchar(10) not null comment '民族',
birthday date not null comment '生日',
idcard char(18) not null comment '身份证号',
issued varchar(20) not null comment '签发机关',
expire_begin date not null comment '有效期限-开始',
expire_end date comment '有效期限-结束',
user_id int unsigned not null unique comment '用户ID',
constraint fk_user_id foreign key (user_id) references tb_user(id)
) comment '用户身份信息表';
-- 测试数据
insert into tb_user_card values (1,'汉','1960-11-06','100000100000100001','朝阳区公安局','2000-06-10',null,1),
(2,'汉','1971-11-06','100000100000100002','静安区公安局','2005-06-10','2025-06-10',2),
(3,'汉','1963-11-06','100000100000100003','昌平区公安局','2006-06-10',null,3),
(4,'回','1980-11-06','100000100000100004','海淀区公安局','2008-06-10','2028-06-10',4);
一对一 :在任意一方加入外键,关联另外一方的主键,并且设置外键为唯一的(UNIQUE)
✋

1.4 多对多
多对多的关系在开发中属于也比较常见的。比如:学生和老师的关系,一个学生可以有多个授课老师,一个授课老师也可以有多个学生。在比如:学生和课程的关系,一个学生可以选修多门课程,一个课程也可以供多个学生选修。
案例:学生与课程的关系
- 关系:一个学生可以选修多门课程,一门课程也可以供多个学生选择
- 实现关系:建立第三张中间表,中间表至少包含两个外键,分别关联两方主键

SQL脚本:
sql
-- 学生表
create table tb_student(
id int auto_increment primary key comment '主键ID',
name varchar(10) comment '姓名',
no varchar(10) comment '学号'
) comment '学生表';
-- 学生表测试数据
insert into tb_student(name, no) values ('黛绮丝', '2000100101'),
('谢逊', '2000100102'),
('殷天正', '2000100103'),
('韦一笑', '2000100104');
-- 课程表
create table tb_course(
id int auto_increment primary key comment '主键ID',
name varchar(10) comment '课程名称'
) comment '课程表';
-- 课程表测试数据
insert into tb_course (name) values ('Java'), ('PHP'), ('MySQL') , ('Hadoop');
-- 学生课程表(中间表)
create table tb_student_course(
id int auto_increment comment '主键' primary key,
student_id int not null comment '学生ID',
course_id int not null comment '课程ID',
constraint fk_courseid foreign key (course_id) references tb_course (id),
constraint fk_studentid foreign key (student_id) references tb_student (id)
)comment '学生课程中间表';
-- 学生课程表测试数据
insert into tb_student_course(student_id, course_id) values (1,1),(1,2),(1,3),(2,2),(2,3),(3,4);
多对多 :需要建立一张中间表,中间表中有两个外键字段,分别关联两方的主键。
✋

1.5 案例
下面通过一个综合案例加深对于多表关系的理解,并掌握多表设计的流程。
需求
- 根据参考资料中提供的《Talis智能学习辅助系统》页面原型,设计员工管理模块涉及到的表结构。
步骤 - 阅读页面原型及需求文档,分析各个模块涉及到的表结构,及表结构之间的关系。
- 根据页面原型及需求文档,分析各个表结构中具体的字段及约束。
分析
涉及到的模块有:
-
部门管理

部门管理涉及到一张部门表,这个前面我们都已经设计过了。 无需再进行设计了。
-
员工管理

上述在员工列表查询的页面原型,当我们点击 "新增员工" 按钮时,会弹出一个新增员工的表单,表单展示形式如下:

在上述的页面原型中,我们可以看到,每一个员工是归属于某一个部门的,而一个部门下可以有多个员工,所以部门与员工之间的关系是一对多的关系。
从页面员工中,我们可以看到,员工还有工作经历的信息。而每一个员工,是可以添加多个工作经历的。 所以,工作经历我们可以再设计一张表 ,而员工与员工的工作经历之间的关系,是一对多的关系。
那么,这里总共会涉及三张表,分别是:部门表、员工表、员工工作经历表。
最终,具体的表结构如下:
sql
-- 部门表
create table dept (
id int unsigned PRIMARY KEY AUTO_INCREMENT COMMENT 'ID, 主键',
name varchar(10) NOT NULL UNIQUE COMMENT '部门名称',
create_time datetime COMMENT '创建时间',
update_time datetime COMMENT '修改时间'
) COMMENT '部门表';
-- 员工表
create table emp(
id int unsigned primary key auto_increment comment 'ID,主键',
username varchar(20) not null unique comment '用户名',
password varchar(50) default '123456' comment '密码',
name varchar(10) not null comment '姓名',
gender tinyint unsigned not null comment '性别, 1:男, 2:女',
phone char(11) not null unique comment '手机号',
job tinyint unsigned comment '职位, 1 班主任, 2 讲师 , 3 学工主管, 4 教研主管, 5 咨询师',
salary int unsigned comment '薪资',
image varchar(300) comment '头像',
entry_date date comment '入职日期',
dept_id int unsigned comment '部门ID', -- 关联的是dept部门表的ID
create_time datetime comment '创建时间',
update_time datetime comment '修改时间'
) comment '员工表';
-- 员工工作经历表
create table emp_expr(
id int unsigned primary key auto_increment comment 'ID, 主键',
emp_id int unsigned null comment '员工ID', -- 关联的是emp员工表的ID
begin date null comment '开始时间',
end date null comment '结束时间',
company varchar(50) null comment '公司名称',
job varchar(50) null comment '职位'
) comment '工作经历';
注意:在上述的表结构设计中,我们使用的都是逻辑外键。
2. 多表查询
2.1 概述
2.1.1 数据准备
创建数据库,执行如下SQL脚本:
sql
-- 部门管理
create table dept(
id int unsigned primary key auto_increment comment 'ID, 主键',
name varchar(10) not null unique comment '部门名称',
create_time datetime comment '创建时间',
update_time datetime comment '修改时间'
) comment '部门表' ;
insert into dept (id, name, create_time, update_time) values
(1,'学工部',now(),now()),
(2,'教研部',now(),now()),
(3,'咨询部',now(),now()),
(4,'就业部',now(),now()),
(5,'人事部',now(),now());
-- 员工管理
create table emp(
id int unsigned primary key auto_increment comment 'ID,主键',
username varchar(20) not null unique comment '用户名',
password varchar(32) not null comment '密码',
name varchar(10) not null comment '姓名',
gender tinyint unsigned not null comment '性别, 1:男, 2:女',
phone char(11) not null unique comment '手机号',
job tinyint unsigned comment '职位, 1:班主任,2:讲师,3:学工主管,4:教研主管,5:咨询师',
salary int unsigned comment '薪资',
image varchar(300) comment '头像',
entry_date date comment '入职日期',
dept_id int unsigned COMMENT '关联的部门ID',
create_time datetime comment '创建时间',
update_time datetime comment '修改时间'
) comment '员工表';
-- 准备测试数据
INSERT INTO `emp` VALUES (1,'shinaian','123456','施耐庵',1,'13309090001',4,15000,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2000-01-01',2,'2023-10-27 16:35:33','2023-10-27 16:35:35'),
(2,'songjiang','123456','宋江',1,'13309090002',2,8600,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2015-01-01',2,'2023-10-27 16:35:33','2023-10-27 16:35:37'),
(3,'lujunyi','123456','卢俊义',1,'13309090003',2,8900,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2008-05-01',2,'2023-10-27 16:35:33','2023-10-27 16:35:39'),
(4,'wuyong','123456','吴用',1,'13309090004',2,9200,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2007-01-01',2,'2023-10-27 16:35:33','2023-10-27 16:35:41'),
(5,'gongsunsheng','123456','公孙胜',1,'13309090005',2,9500,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2012-12-05',2,'2023-10-27 16:35:33','2023-10-27 16:35:43'),
(6,'huosanniang','123456','扈三娘',2,'13309090006',3,6500,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2013-09-05',1,'2023-10-27 16:35:33','2023-10-27 16:35:45'),
(7,'chaijin','123456','柴进',1,'13309090007',1,4700,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2005-08-01',1,'2023-10-27 16:35:33','2023-10-27 16:35:47'),
(8,'likui','123456','李逵',1,'13309090008',1,4800,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2014-11-09',1,'2023-10-27 16:35:33','2023-10-27 16:35:49'),
(9,'wusong','123456','武松',1,'13309090009',1,4900,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2011-03-11',1,'2023-10-27 16:35:33','2023-10-27 16:35:51'),
(10,'lichong','123456','林冲',1,'13309090010',1,5000,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2013-09-05',1,'2023-10-27 16:35:33','2023-10-27 16:35:53'),
(11,'huyanzhuo','123456','呼延灼',1,'13309090011',2,9700,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2007-02-01',2,'2023-10-27 16:35:33','2023-10-27 16:35:55'),
(12,'xiaoliguang','123456','小李广',1,'13309090012',2,10000,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2008-08-18',2,'2023-10-27 16:35:33','2023-10-27 16:35:57'),
(13,'yangzhi','123456','杨志',1,'13309090013',1,5300,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2012-11-01',1,'2023-10-27 16:35:33','2023-10-27 16:35:59'),
(14,'shijin','123456','史进',1,'13309090014',2,10600,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2002-08-01',2,'2023-10-27 16:35:33','2023-10-27 16:36:01'),
(15,'sunerniang','123456','孙二娘',2,'13309090015',2,10900,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2011-05-01',2,'2023-10-27 16:35:33','2023-10-27 16:36:03'),
(16,'luzhishen','123456','鲁智深',1,'13309090016',2,9600,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2010-01-01',2,'2023-10-27 16:35:33','2023-10-27 16:36:05'),
(17,'liying','12345678','李应',1,'13309090017',1,5800,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2015-03-21',1,'2023-10-27 16:35:33','2023-10-27 16:36:07'),
(18,'shiqian','123456','时迁',1,'13309090018',2,10200,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2015-01-01',2,'2023-10-27 16:35:33','2023-10-27 16:36:09'),
(19,'gudasao','123456','顾大嫂',2,'13309090019',2,10500,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2008-01-01',2,'2023-10-27 16:35:33','2023-10-27 16:36:11'),
(20,'ruanxiaoer','123456','阮小二',1,'13309090020',2,10800,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2018-01-01',2,'2023-10-27 16:35:33','2023-10-27 16:36:13'),
(21,'ruanxiaowu','123456','阮小五',1,'13309090021',5,5200,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2015-01-01',3,'2023-10-27 16:35:33','2023-10-27 16:36:15'),
(22,'ruanxiaoqi','123456','阮小七',1,'13309090022',5,5500,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2016-01-01',3,'2023-10-27 16:35:33','2023-10-27 16:36:17'),
(23,'ruanji','123456','阮籍',1,'13309090023',5,5800,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2012-01-01',3,'2023-10-27 16:35:33','2023-10-27 16:36:19'),
(24,'tongwei','123456','童威',1,'13309090024',5,5000,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2006-01-01',3,'2023-10-27 16:35:33','2023-10-27 16:36:21'),
(25,'tongmeng','123456','童猛',1,'13309090025',5,4800,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2002-01-01',3,'2023-10-27 16:35:33','2023-10-27 16:36:23'),
(26,'yanshun','123456','燕顺',1,'13309090026',5,5400,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2011-01-01',3,'2023-10-27 16:35:33','2023-10-27 16:36:25'),
(27,'lijun','123456','李俊',1,'13309090027',5,6600,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2004-01-01',3,'2023-10-27 16:35:33','2023-10-27 16:36:27'),
(28,'lizhong','123456','李忠',1,'13309090028',5,5000,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2007-01-01',3,'2023-10-27 16:35:33','2023-10-27 16:36:29'),
(29,'songqing','123456','宋清',1,'13309090029',NULL,5100,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2020-01-01',NULL,'2023-10-27 16:35:33','2023-10-27 16:36:31'),
(30,'liyun','123456','李云',1,'13309090030',NULL,NULL,'https://dawn-itcast.oss-cn-hangzhou.aliyuncs.com/01.png','2020-03-01',NULL,'2023-10-27 16:35:33','2023-10-27 16:36:31');
2.1.2 介绍
多表查询:查询时从多张表中获取所需数据
- 单表查询的SQL语句:select 字段列表 from 表名;
- 那么要执行多表查询,只需要使用逗号分隔多张表即可,如: select 字段列表 from 表1, 表2;
查询用户表和部门表中的数据:
sql
select * from emp , dept;

📚 员工表和部门表的信息都显示出来了
📚不消除多余信息的话,每个员工会和每一个部门进行一次组合。
此时,我们看到查询结果中包含了大量的结果集,总共180条记录,而这其实就是员工表所有的记录(30行)与部门表所有记录(6行)的所有组合情况,这种现象称之为笛卡尔积。
笛卡尔积 :笛卡尔乘积是指在数学中,两个集合(A集合和B集合)的所有组合情况。

在多表查询时,需要消除无效的笛卡尔积,只保留表关联部分的数据。

在SQL语句中,如何去除无效的笛卡尔积呢?只需要给多表查询加上连接查询的条件即可。
sql
select * from emp , dept where emp.dept_id = dept.id ;

这样,我们就查询出来了所有的员工,及其这个员工所属的部门信息。 而由于id为29、30的员工,没有dept_id字段值,所以在多表查询时,根据连接查询的条件并没有查询到。
2.1.3 分类
多表查询可以分为:

- 连接查询
- 内连接 :相当于查询
A、B交集部分数据 - 外连接
- 左外连接:查询左表所有数据(包括两张表交集部分数据)
- 右外连接:查询右表所有数据(包括两张表交集部分数据)
- 子查询
2.2 内连接
内连接查询:查询两表或多表中交集部分数据。
内连接从语法上可以分为:
- 隐式内连接
- 显式内连接
隐式内连接语法:
sql
select 字段列表 from 表1 , 表2 where 条件 ... ;
显式内连接语法:
sql
select 字段列表 from 表1 [ inner ] join 表2 on 连接条件 ... ;
- 案例1:查询所有员工的ID,姓名,及所属的部门名称
- 隐式内连接实现
sql
select emp.id, emp.name, dept.name from emp , dept where emp.dept_id = dept.id;
- 显式内连接实现
sql
select emp.id, emp.name, dept.name from emp inner join dept on emp.dept_id = dept.id;
- 案例2:查询 性别为男, 且工资 高于8000 的员工的ID, 姓名, 及所属的部门名称
- 隐式内连接实现
sql
select emp.id, emp.name, dept.name from emp , dept where emp.dept_id = dept.id and emp.gender = 1 and emp.salary > 8000;
- 显式内连接实现
sql
select emp.id, emp.name, dept.name from emp inner join dept on emp.dept_id = dept.id where emp.gender = 1 and emp.salary > 8000;
在多表联查时,我们指定字段时,需要在字段名前面加上表名,来指定具体是哪一张的字段。 如:
emp.dept_id
给表起别名简化书写:
sql
select 字段列表 from 表1 as 别名1 , 表2 as 别名2 where 条件 ... ;
select 字段列表 from 表1 别名1 , 表2 别名2 where 条件 ... ; -- as 可以省略
使用了别名的多表查询:
sql
select e.id, e.name, d.name from emp as e , dept as d where e.dept_id = d.id and e.gender = 1 and e.salary > 8000;
📍注意事项: 一旦为表起了别名,就不能再使用表名来指定对应的字段了,此时只能够使用别名来指定字段。
2.3 外连接
外连接分为两种:左外连接 和 右外连接。
左外连接语法:
sql
select 字段列表 from 表1 left [ outer ] join 表2 on 连接条件 ... ;
⭐️左外连接相当于查询表1(左表)的所有数据,当然也包含表1和表2交集部分的数据。
右外连接语法:
sql
select 字段列表 from 表1 right [ outer ] join 表2 on 连接条件 ... ;
⭐️右外连接相当于查询表2(右表)的所有数据,当然也包含表1和表2交集部分的数据。
案例1:查询员工表 所有 员工的姓名, 和对应的部门名称 (左外连接)
sql
-- 左外连接:以left join关键字左边的表为主表,查询主表中所有数据,以及和主表匹配的右边表中的数据
select e.name , d.name from emp as e left join dept as d on e.dept_id = d.id ;
案例2:查询部门表 所有 部门的名称, 和对应的员工名称 (右外连接)
sql
-- 右外连接:以right join关键字右边的表为主表,查询主表中所有数据,以及和主表匹配的左边表中的数据
select e.name , d.name from emp as e right join dept as d on e.dept_id = d.id;
案例3:查询工资 高于8000 的 所有员工的姓名, 和对应的部门名称 (左外连接)
sql
select e.name , d.name from emp as e left join dept as d on e.dept_id = d.id where e.salary > 8000;
✋案例二:

📍注意事项:
左外连接和右外连接是可以相互替换的,只需要调整连接查询时SQL语句中表的先后顺序就可以了。而我们在日常开发使用时,更偏向于
左外连接。
2.4 子查询
2.4.1 介绍
SQL语句中嵌套select语句,称为嵌套查询,又称子查询。
sql
SELECT * FROM t1 WHERE column1 = ( SELECT column1 FROM t2 ... );
子查询外部的语句可以是insert / update / delete / select 的任何一个,最常见的是 select。
根据子查询结果的不同分为:
- 标量子查询(子查询结果为单个值 一行一列)
- 列子查询(子查询结果为一列,但可以是多行)
- 行子查询(子查询结果为一行,但可以是多列)
- 表子查询(子查询结果为多行多列相当于子查询结果是一张表)
子查询可以书写的位置:
- where之后
- from之后
- select之后
📍子查询的要点是,先对需求做拆分,明确具体的步骤,然后再逐条编写SQL语句。 最终将多条SQL语句合并为一条。
2.4.2 标量子查询
子查询返回的结果是单个值(数字、字符串、日期等),最简单的形式,这种子查询称为标量子查询。
常用的操作符: = <> > >= < <=
- 案例1:查询 最早入职 的员工信息
sql
-- 1. 查询最早的入职时间
select min(entry_date) from emp; -- 结果: 2000-01-01
-- 2. 查询入职时间 = 最早入职时间的员工信息
select * from emp where entry_date = '2000-01-01';
-- 3. 合并为一条SQL
select * from emp where entry_date = (select min(entry_date) from emp);
- 案例2:查询在 阮小五 入职之后入职的员工信息
sql
-- 1. 查询 "阮小五" 的入职日期
select entry_date from emp where name = '阮小五'; -- 结果: 2015-01-01
-- 2. 根据上述查询到的这个入职日期, 查询在该日期之后入职的员工信息
select * from emp where entry_date > '2015-01-01';
-- 3. 合并SQL为一条SQL
select * from emp where entry_date > (select entry_date from emp where name = '阮小五');
2.4.3 列子查询
子查询返回的结果是一列(可以是多行),这种子查询称为列子查询。
常用的操作符:

- 案例1:查询 "教研部" 和 "咨询部" 的所有员工信息
sql
-- 1. 查询 "教研部" 和 "咨询部" 的部门ID
select id from dept where name = '教研部' or name = '咨询部'; -- 结果: 3,2
-- 2. 根据上面查询出来的部门ID, 查询员工信息
select * from emp where dept_id in(3,2);
-- 3. 合并SQL为一条SQL语句
select * from emp where dept_id in (select id from dept where name = '教研部' or name = '咨询部');
2.4.4 行子查询
子查询返回的结果是一行(可以是多列),这种子查询称为行子查询。
常用的操作符:= 、<> 、IN 、NOT IN
- 案例1:查询与 "李忠" 的薪资 及 职位都相同的员工信息
sql
-- 1. 查询 "李忠" 的薪资和职位
select salary , job from emp where name = '李忠'; -- 结果: 5000, 5
-- 2. 根据上述查询到的薪资和职位 , 查询对应员工的信息
select * from emp where (salary, job) = (5000,5);
-- 3. 将两条SQL合并为一条SQL
select * from emp where (salary, job) = (select salary , job from emp where name = '李忠');
📚优化前的语句
✋

2.4.5 表子查询
子查询返回的结果是多行多列,常作为临时表,这种子查询称为表子查询。
- 案例:获取每个部门中薪资最高的员工信息
sql
-- a. 获取每个部门的最高薪资(分组)
select dept_id, max(salary) from emp group by dept_id;
-- b. 查询每个部门中薪资最高的员工信息
select * from emp e , (select dept_id, max(salary) max_sal from emp group by dept_id) a
where e.dept_id = a.dept_id and e.salary = a.max_sal;
📚将 每个部门的最高薪资表 作为一张中间表,再与其他表作联查。
☀️

2.4.6 案例
根据需求,完成多表查询的SQL语句的编写。
📚感觉都挺难的,以后面试遇到再慢慢深究吧
-
- 查询 "教研部" 性别为 男,且在 "2011-05-01" 之后入职的员工信息 。
sql
select e.* from emp as e , dept as d where e.dept_id = d.id and d.name = '教研部' and e.gender = 1 and e.entry_date > '2011-05-01';
-
- 查询工资 低于公司平均工资的 且 性别为男 的员工信息 。
sql
select e.* from emp as e , dept as d where e.dept_id = d.id and e.salary < (select avg(salary) from emp) and e.gender = 1;
-
- 查询部门人数超过 10 人的部门名称 。
sql
select d.name , count(*) from emp as e , dept as d where e.dept_id = d.id group by d.name having count(*) > 10;
-
- 查询在 "2010-05-01" 后入职,且薪资高于 10000 的 "教研部" 员工信息,并根据薪资倒序排序。
sql
select * from emp e , dept d where e.dept_id = d.id and e.entry_date > '2010-05-01' and e.salary > 10000 and d.name = '教研部' order by e.salary desc;
-
- 查询工资 低于本部门平均工资的员工信息 。【难】
sql
-- 5.1 查询每个部门的平均工资
select dept_id, avg(salary) avg_sal from emp group by dept_id;
-- 5.2 查询工资 低于本部门平均工资 的员工信息 。
select e.* from emp e , (select dept_id, avg(salary) avg_sal from emp group by dept_id) as a
where e.dept_id = a.dept_id and e.salary < a.avg_sal;
3. 员工列表查询
那接下来,我们要来完成的是员工列表的查询功能实现。 具体的需求如下:
在查询员工列表数据时,既需要查询 员工的基本信息,还需要查询员工所属的部门名称,所以这里呢,会涉及到多表查询的操作。
而且,在查询员工列表数据时,既要考虑搜索栏中的查询条件,还要考虑对查询的结果进行分页处理。
那么接下来,我们在实现这个功能时,将会分为三个部分来逐一实现:
- 准备工作
- 分页查询
- 条件分页查询

3.1 准备工作
需求:查询所有员工信息,并查询出部门名称。(涉及到的表:emp、dept)

3.1.1 基础代码准备
1). 创建员工管理相关表结构
sql
-- 员工表
create table emp(
id int unsigned primary key auto_increment comment 'ID,主键',
username varchar(20) not null unique comment '用户名',
password varchar(50) default '123456' comment '密码',
name varchar(10) not null comment '姓名',
gender tinyint unsigned not null comment '性别, 1:男, 2:女',
phone char(11) not null unique comment '手机号',
job tinyint unsigned comment '职位, 1 班主任, 2 讲师 , 3 学工主管, 4 教研主管, 5 咨询师',
salary int unsigned comment '薪资',
image varchar(300) comment '头像',
entry_date date comment '入职日期',
dept_id int unsigned comment '部门ID',
create_time datetime comment '创建时间',
update_time datetime comment '修改时间'
) comment '员工表';
INSERT INTO emp VALUES
(1,'shinaian','123456','施耐庵',1,'13309090001',4,15000,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2000-01-01',2,'2023-10-20 16:35:33','2023-11-16 16:11:26'),
(2,'songjiang','123456','宋江',1,'13309090002',2,8600,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2015-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:37'),
(3,'lujunyi','123456','卢俊义',1,'13309090003',2,8900,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2008-05-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:39'),
(4,'wuyong','123456','吴用',1,'13309090004',2,9200,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2007-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:41'),
(5,'gongsunsheng','123456','公孙胜',1,'13309090005',2,9500,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2012-12-05',2,'2023-10-20 16:35:33','2023-10-20 16:35:43'),
(6,'huosanniang','123456','扈三娘',2,'13309090006',3,6500,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2013-09-05',1,'2023-10-20 16:35:33','2023-10-20 16:35:45'),
(7,'chaijin','123456','柴进',1,'13309090007',1,4700,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2005-08-01',1,'2023-10-20 16:35:33','2023-10-20 16:35:47'),
(8,'likui','123456','李逵',1,'13309090008',1,4800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2014-11-09',1,'2023-10-20 16:35:33','2023-10-20 16:35:49'),
(9,'wusong','123456','武松',1,'13309090009',1,4900,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2011-03-11',1,'2023-10-20 16:35:33','2023-10-20 16:35:51'),
(10,'linchong','123456','林冲',1,'13309090010',1,5000,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2013-09-05',1,'2023-10-20 16:35:33','2023-10-20 16:35:53'),
(11,'huyanzhuo','123456','呼延灼',1,'13309090011',2,9700,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2007-02-01',2,'2023-10-20 16:35:33','2023-10-20 16:35:55'),
(12,'xiaoliguang','123456','小李广',1,'13309090012',2,10000,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2008-08-18',2,'2023-10-20 16:35:33','2023-10-20 16:35:57'),
(13,'yangzhi','123456','杨志',1,'13309090013',1,5300,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2012-11-01',1,'2023-10-20 16:35:33','2023-10-20 16:35:59'),
(14,'shijin','123456','史进',1,'13309090014',2,10600,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2002-08-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:01'),
(15,'sunerniang','123456','孙二娘',2,'13309090015',2,10900,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2011-05-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:03'),
(16,'luzhishen','123456','鲁智深',1,'13309090016',2,9600,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2010-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:05'),
(17,'liying','12345678','李应',1,'13309090017',1,5800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2015-03-21',1,'2023-10-20 16:35:33','2023-10-20 16:36:07'),
(18,'shiqian','123456','时迁',1,'13309090018',2,10200,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2015-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:09'),
(19,'gudasao','123456','顾大嫂',2,'13309090019',2,10500,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2008-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:11'),
(20,'ruanxiaoer','123456','阮小二',1,'13309090020',2,10800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2018-01-01',2,'2023-10-20 16:35:33','2023-10-20 16:36:13'),
(21,'ruanxiaowu','123456','阮小五',1,'13309090021',5,5200,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2015-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:15'),
(22,'ruanxiaoqi','123456','阮小七',1,'13309090022',5,5500,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2016-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:17'),
(23,'ruanji','123456','阮籍',1,'13309090023',5,5800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2012-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:19'),
(24,'tongwei','123456','童威',1,'13309090024',5,5000,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2006-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:21'),
(25,'tongmeng','123456','童猛',1,'13309090025',5,4800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2002-01-01',3,'2023-10-20 16:35:33','2023-10-20 16:36:23'),
(26,'yanshun','123456','燕顺',1,'13309090026',5,5400,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2011-01-01',3,'2023-10-20 16:35:33','2023-11-08 22:12:46'),
(27,'lijun','123456','李俊',1,'13309090027',2,6600,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2004-01-01',2,'2023-10-20 16:35:33','2023-11-16 17:56:59'),
(28,'lizhong','123456','李忠',1,'13309090028',5,5000,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2007-01-01',3,'2023-10-20 16:35:33','2023-11-17 16:34:22'),
(30,'liyun','123456','李云',1,'13309090030',NULL,NULL,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2020-03-01',NULL,'2023-10-20 16:35:33','2023-10-20 16:36:31'),
(36,'linghuchong','123456','令狐冲',1,'18809091212',2,6800,'https://web-framework.oss-cn-hangzhou.aliyuncs.com/2023/1.jpg','2023-10-19',2,'2023-10-20 20:44:54','2023-11-09 09:41:04');
-- 员工工作经历信息
create table emp_expr(
id int unsigned primary key auto_increment comment 'ID, 主键',
emp_id int unsigned comment '员工ID',
begin date comment '开始时间',
end date comment '结束时间',
company varchar(50) comment '公司名称',
job varchar(50) comment '职位'
)comment '工作经历';
2). 准备emp表对应的实体类Emp、EmpExpr
java
package com.itheima.pojo;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class Emp {
private Integer id; //ID,主键
private String username; //用户名
private String password; //密码
private String name; //姓名
private Integer gender; //性别, 1:男, 2:女
private String phone; //手机号
private Integer job; //职位, 1:班主任,2:讲师,3:学工主管,4:教研主管,5:咨询师
private Integer salary; //薪资
private String image; //头像
private LocalDate entryDate; //入职日期
private Integer deptId; //关联的部门ID
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间
//封装部门名称数
private String deptName; //部门名称
}
java
package com.itheima.pojo;
import lombok.Data;
import java.time.LocalDate;
/**
* 工作经历
*/
@Data
public class EmpExpr {
private Integer id; //ID
private Integer empId; //员工ID
private LocalDate begin; //开始时间
private LocalDate end; //结束时间
private String company; //公司名称
private String job; //职位
}
3). 准备Emp员工管理的基础结构,包括Controller、Service、Mapper
EmpMapper:
java
package com.itheima.mapper;
import com.itheima.pojo.Emp;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface EmpMapper {
}
EmpService:
java
package com.itheima.service;
public interface EmpService {
}
EmpServiceImpl:
java
package com.itheima.service.impl;
import com.itheima.mapper.EmpMapper;
import com.itheima.service.EmpService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 员工管理
*/
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
}
EmpController:
java
package com.itheima.controller;
import com.itheima.service.EmpService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RestController;
/**
* 员工管理
*/
@Slf4j
@RestController
public class EmpController {
@Autowired
private EmpService empService;
}
3.1.2 SQL&Mapper接口
那接下来,我们就先考虑一下要查询所有的员工数据,及其关联的部门名称,这个SQL语句该如何实现 ? 这里,要查询所有的员工,也就意味着,即使员工没有部门,也需要将该员工查询出来 。
所以,这里需要用左外连接 实现,具体SQL如下:
1). 基于AI生成对应的SQL语句
sql
-- 查询所有的员工信息,如果员工关联了部门,也要查询出部门名称
select e.*, d.name as dept_name from emp e left join dept d on e.dept_id = d.id;
2). Mapper接口方法定义
那接下来,我们就定义一个员工管理的mapper接口 EmpMapper 并在其中完成员工信息的查询。 具体代码如下:
java
@Mapper
public interface EmpMapper {
/**
* 查询所有的员工及其对应的部门名称
*/
@Select("select e.*, d.name as deptName from emp e left join dept d on e.dept_id = d.id")
public List<Emp> list();
}
注意,上述SQL语句中,给 部门名称起了别名 deptName ,是因为在接口文档中,要求部门名称给前端返回的数据中,就必须叫 deptName。 而这里我们需要将查询返回的每一条记录都封装到Emp对象中,那么就必须保证查询返回的字段名与属性名是一一对应的。
此时,我们就需要在Emp中定义一个属性 deptName 用来封装部门名称。 具体如下:
java
@Data
public class Emp {
private Integer id; //ID,主键
private String username; //用户名
private String password; //密码
private String name; //姓名
private Integer gender; //性别, 1:男, 2:女
private String phone; //手机号
private Integer job; //职位, 1:班主任,2:讲师,3:学工主管,4:教研主管,5:咨询师
private Integer salary; //薪资
private String image; //头像
private LocalDate entryDate; //入职日期
private Integer deptId; //关联的部门ID
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间
//封装部门名称数
private String deptName; //部门名称
}
代码编写完毕后,我们可以编写一个单元测试,对上述的程序进行测试:
java
@SpringBootTest
class TliasWebManagementApplicationTests {
@Autowired
private EmpMapper empMapper;
@Test
public void testList(){
List<Emp> empList = empMapper.list();
empList.forEach(System.out::println);
}
}
运行单元测试后,我们看到控制台输出的数据:

可以看到,员工的信息,员工关联的部门名称都查询出来了。
✋
返回30条数据,

3.2 分页查询
3.2.1 分析
上述我们在Mapper接口中定义了接口方法,完成了查询所有员工及其部门名称的功能,是将数据库中所有的数据查询出来了。 试想如果数据库中的数据有很多(假设有几千几万条)的时候,将数据全部展示出来肯定不现实,那如何解决这个问题呢?
使用分页解决这个问题。每次只展示一页的数据,比如:一页展示10条数据,如果还想看其他的数据,可以通过点击页码进行查询。
而在员工管理的需求中,就要求我们进行分页查询,展示出对应的数据。 具体的页面原型如下:

要想从数据库中进行分页查询,我们要使用LIMIT关键字,格式为:limit 开始索引 每页显示的条数。
1). 查询第1页数据的SQL语句是:
sql
select * from emp limit 0,10;
2). 查询第2页数据的SQL语句是:
sql
select * from emp limit 10,10;
3). 查询第3页的数据的SQL语句是:
sql
select * from emp limit 20,10;
观察以上SQL语句,发现: 开始索引一直在改变 , 每页显示条数是固定的
开始索引的计算公式: 开始索引 = (当前页码 - 1) * 每页显示条数
我们继续基于页面原型,继续分析,得出以下结论:
前端在请求服务端时,传递的参数
- 当前页码 page
- 每页显示条数 pageSize
后端需要响应什么数据给前端
- 所查询到的数据列表(存储到List 集合中)
- 总记录数

后台给前端返回的数据包含:List集合(数据列表)、total(总记录数)
而这两部分我们通常封装到PageResult对象中,并将该对象转换为json格式的数据响应回给浏览器。
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult {
private Long total; //总记录数
private List rows; //当前页数据列表
}
📚应该是
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult<T> {
private Long total; //总记录数
private List<T> rows; //当前页数据列表
}
3.2.2 接口描述
1). 基本信息
请求路径:/emps
请求方式:GET
接口描述:该接口用于员工列表数据的条件分页查询
2). 请求参数

请求数据样例:
java
/emps?name=张&gender=1&begin=2007-09-01&end=2022-09-01&page=1&pageSize=10
3). 响应数据
参数格式:application/json
参数说明:

响应数据样例:
json
{
"code": 1,
"msg": "success",
"data": {
"total": 2,
"rows": [
{
"id": 1,
"username": "jinyong",
"password": "123456",
"name": "金庸",
"gender": 1,
"image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-53B.jpg",
"job": 2,
"salary": 8000,
"entryDate": "2015-01-01",
"deptId": 2,
"deptName": "教研部",
"createTime": "2022-09-01T23:06:30",
"updateTime": "2022-09-02T00:29:04"
},
{
"id": 2,
"username": "zhangwuji",
"password": "123456",
"name": "张无忌",
"gender": 1,
"image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-53B.jpg",
"job": 2,
"salary": 6000,
"entryDate": "2015-01-01",
"deptId": 2,
"deptName": "教研部",
"createTime": "2022-09-01T23:06:30",
"updateTime": "2022-09-02T00:29:04"
}
]
}
}
目前我们只考虑分页查询,先不考虑查询条件,而上述的接口文档中,与分页查询相关的参数就两个,一个是page,一个是pageSize。
3.2.3 原始方式
3.2.3.1 代码实现
通过查看接口文档:员工列表查询
请求路径:/emps
请求方式:GET
请求参数:跟随在请求路径后的参数字符串。 例:/emps?page=1&pageSize=10
响应数据:json格式
1). EmpController
java
@Slf4j
@RequestMapping("/emps")
@RestController
public class EmpController {
@Autowired
private EmpService empService;
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page ,
@RequestParam(defaultValue = "10") Integer pageSize){
log.info("查询员工信息, page={}, pageSize={}", page, pageSize);
PageResult pageResult = empService.page(page, pageSize);
return Result.success(pageResult);
}
}
@RequestParam(defaultValue="默认值") //设置请求参数默认值
2). EmpService
java
public interface EmpService {
/**
* 分页查询
* @param page 页码
* @param pageSize 每页记录数
*/
PageResult page(Integer page, Integer pageSize);
}
3). EmpServiceImpl
java
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Override
public PageResult page(Integer page, Integer pageSize) {
//1. 获取总记录数
Long total = empMapper.count();
//2. 获取结果列表
Integer start = (page - 1) * pageSize;
List<Emp> empList = empMapper.list(start, pageSize);
//3. 封装结果
return new PageResult(total, empList);
}
}
4). EmpMapper
java
@Mapper
public interface EmpMapper {
/**
* 查询总记录数
*/
@Select("select count(*) from emp e left join dept d on e.dept_id = d.id ")
public Long count();
/**
* 查询所有的员工及其对应的部门名称
*/
@Select("select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id limit #{start}, #{pageSize}")
public List<Emp> list(Integer start , Integer pageSize);
}
🐛
java
Parameter 'start' not found. Available parameters are [arg1, arg0, param1, param2]
Mapper 接口方法有多个参数,MyBatis 找不到参数名start,只识别到 arg0、arg1。
改正:Mapper 接口上加 @Param
sql
/**
* 查询所有的员工及其对应的部门名称
*/
@Select("select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id limit #{start}, #{pageSize}")
public List<Emp> list(@Param("start") Integer start, @Param("pageSize") Integer pageSize);
3.2.3.2 功能测试
功能开发完成后,重新启动项目,使用Apifox,发起GET请求:

✋

3.2.3.3 前后端联调
打开浏览器,测试后端功能接口:

点击下面的页码,可以正常的查询出对应的数据 。
3.2.4 PageHelper分页插件
3.2.4.1 介绍
前面我们已经完了基础的分页查询,大家会发现:分页查询功能编写起来比较繁琐。 而分页查询的功能是非常常见的,我们查询员工信息需要分页查询,将来在做其他项目时,查询用户信息、订单信息、商品信息等等都是需要进行分页查询的。
而分页查询的思路、步骤是比较固定的。 在Mapper接口中定义两个方法执行两条不同的SQL语句:
- 查询总记录数
- 指定页码的数据列表
在Service当中,调用Mapper接口的两个方法,分别获取:总记录数、查询结果列表,然后在将获取的数据结果封装到PageBean对象中。
大家思考下:在未来开发其他项目,只要涉及到分页查询功能(例:订单、用户、支付、商品),都必须按照以上操作完成功能开发
结论:原始方式的分页查询,存在着"步骤固定"、"代码频繁"的问题
解决方案:可以使用一些现成的分页插件完成。对于Mybatis来讲现在最主流的就是PageHelper。
PageHelper是第三方提供的Mybatis框架中的一款功能强大、方便易用的分页插件,支持任何形式的单标、多表的分页查询。
官网:https://pagehelper.github.io/
那接下来,我们可以对比一下,使用PageHelper分页插件进行分页 与 原始方式进行分页代码实现的上的差别。

- Mapper接口层:
- 原始的分页查询功能中,我们需要在Mapper接口中定义两条SQL语句。
- PageHelper实现分页查询之后,只需要编写一条SQL语句,而且不需要考虑分页操作,就是一条正常的查询语句。
- Service层:
- 需要根据页码、每页展示记录数,手动的计算起始索引。
- 无需手动计算起始索引,直接告诉PageHelper需要查询那一页的数据,每页展示多少条记录即可。
3.2.4.2 代码实现
当使用了PageHelper分页插件进行分页,就无需再Mapper中进行手动分页了。 在Mapper中我们只需要进行正常的列表查询即可。在Service层中,调用Mapper的方法之前设置分页参数,在调用Mapper方法执行查询之后,解析分页结果,并将结果封装到PageResult对象中返回。
1). 在pom.xml引入依赖
xml
<!--分页插件PageHelper-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>
2). EmpMapper
java
/**
* 查询所有的员工及其对应的部门名称
*/
@Select("select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id")
public List<Emp> list();
3). EmpServiceImpl
java
@Override
public PageResult page(Integer page, Integer pageSize) {
//1. 设置分页参数
PageHelper.startPage(page,pageSize);
//2. 执行查询
List<Emp> empList = empMapper.list();
Page<Emp> p = (Page<Emp>) empList;
//3. 封装结果
return new PageResult(p.getTotal(), p.getResult());
}
📚
Page<Emp> p = (Page<Emp>) empList;将返回结果强转成Page,拿到总条数、总页数。
- 为什么可以将List强转成Page
因为List就是Page实现的
3.2.4.3 功能测试
功能开发完成后,我们重启项目工程,打开Apifox,发起GET请求,访问 :http://localhost:8080/emps?page=1\&pageSize=5

我们可以看到数据可以正常查询返回,是可以正常实现分页查询的。
✋


3.2.4.4 实现机制
我们打开Idea的控制台,可以看到在进行分页查询时,输出的SQL语句。

我们看到执行了两条SQL语句,而这两条SQL语句,其实是从我们在Mapper接口中定义的SQL演变而来的。
-
第一条SQL语句,用来查询总记录数。

其实就是将我们编写的SQL语句进行的
改造增强,将查询返回的字段列表替换成了count(0)来统计总记录数。 -
第二条SQL语句,用来进行分页查询,查询指定页码对应 的数据列表。

其实就是将我们编写的SQL语句进行的
改造增强,在SQL语句之后拼接上了limit进行分页查询,而由于测试时查询的是第一页,起始索引是0,所以简写为limit ?。
而PageHelper在进行分页查询时,会执行上述两条SQL语句,并将查询到的总记录数,与数据列表封装到了 Page 对象中,我们再获取查询结果时,只需要调用Page对象的方法就可以获取。
📍注意:
- PageHelper实现分页查询时,SQL语句的结尾一定一定一定不要加分号( ; )。
(加分号后拼接limit就不是一句正确的sql语句了)- PageHelper只会对紧跟在其后的第一条SQL语句进行分页处理。
☀️

3.3 条件分页查询
完了分页查询后,下面我们需要在分页查询的基础上,添加条件。
3.3.1 需求

通过员工管理的页面原型我们可以看到,员工列表页面的查询,不仅仅需要考虑分页,还需要考虑查询条件。 分页查询我们已经实现了,接下来,我们需要考虑在分页查询的基础上,再加上查询条件。
我们看到页面原型及需求中描述,搜索栏的搜索条件有三个,分别是:
- 姓名:模糊匹配
- 性别:精确匹配
- 入职日期:范围匹配
3.3.2 接口描述
参照接口文档中的 员工管理 -> 员工列表查询
3.3.3 思路分析

3.3.4 功能开发
通过查看接口文档:员工列表查询
请求路径:/emps
请求方式:GET
请求参数:

在原有分页查询的代码基础上进行改造。
1). 在EmpController方法中通过多个方法形参,依次接收这几个参数
java
@Slf4j
@RestController
@RequestMapping("/emps")
public class EmpController {
@Autowired
private EmpService empService;
@GetMapping
public Result page(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
String name, Integer gender,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate begin,
@DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate end) {
log.info("查询请求参数: {}, {}, {}, {}, {}, {}", page, pageSize, name, gender, begin, end);
PageResult pageResult = empService.page(page, pageSize,name, gender, begin, end);
return Result.success(pageResult);
}
}
📚
@DateTimeFormat统一前端传过来日期参数的格式(中间分隔可能是/或-,不统一)
2). 修改EmpService及EmpServiceImpl中的代码逻辑
EmpService:
java
public interface EmpService {
/**
* 分页查询
*/
PageResult page(Integer page, Integer pageSize, String name, Integer gender, LocalDate begin, LocalDate end);
}
EmpServiceImpl:
java
/**
* 员工管理
*/
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Override
public PageResult page(Integer page, Integer pageSize, String name, Integer gender, LocalDate begin, LocalDate end) {
//1. 设置PageHelper分页参数
PageHelper.startPage(page, pageSize);
//2. 执行查询
List<Emp> empList = empMapper.list(name, gender, begin, end);
//3. 封装分页结果
Page<Emp> p = (Page<Emp>) empList;
return new PageResult(p.getTotal(), p.getResult());
}
}
📚

3). 调整EmpMapper接口方法
java
@Mapper
public interface EmpMapper {
/**
* 查询所有的员工及其对应的部门名称
*/
public List<Emp> list(String name, Integer gender, LocalDate begin, LocalDate end);
}
由于SQL语句比较复杂,建议将SQL语句配置在XML映射文件中。
🐛要起别名,不知道视频中不起别名是怎么通过的
java
public List<Emp> list(@Param("name") String name,
@Param("gender") Integer gender,
@Param("begin") LocalDate begin,
@Param("end") LocalDate end
);
4). 新增Mapper映射文件EmpMapper.xml
xml
<!--定义Mapper映射文件的约束和基本结构-->
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.EmpMapper">
<select id="list" resultType="com.itheima.pojo.Emp">
select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id
where e.name like concat('%',#{name},'%')
and e.gender = #{gender}
and e.entry_date between #{begin} and #{end}
</select>
</mapper>
📚规范写法,利用字符串拼接函数
concat:('%',#{name},'%')
功能测试:

测试时,需要注意传递的查询条件,有些查询条件查不到数据的,因为数据库没有符合条件的记录。
✋
java
http://localhost:8080/emps?page=1&pageSize=5&name=李&gender=1&begin=2010-01-01&end=2020-01-01

☀️

3.3.5 程序优化1
在上述分页条件查询中,请求参数比较多,有6个,如下所示:
- 请求参数:/emps?name=张&gender=1&begin=2007-09-01&end=2022-09-01&page=1&pageSize=10
那我们在controller层方法中,接收请求参数的时候,直接在controller方法中声明这样6个参数即可,这样做,功能可以实现,但是不方便维护和管理。

那接下来呢,我们就可以通过 通义零码 AI辅助工具,对这一块儿的代码进行优化。 具体操作如下:

⭐️优化思路:定义一个实体类,来封装这几个请求参数 。 【需要保证,前端传递的请求参数和实体类的属性名是一样的】

1). 定义实体类:EmpQueryParam
java
package com.itheima.pojo;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
@Data
public class EmpQueryParam {
private Integer page = 1; //页码
private Integer pageSize = 10; //每页展示记录数
private String name; //姓名
private Integer gender; //性别
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate begin; //入职开始时间
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate end; //入职结束时间
}
相当于设置默认值:
private Integer page = 1; //页码
private Integer pageSize = 10; //每页展示记录数
@DateTimeFormat:设置格式
2). EmpController接收请求参数
java
@GetMapping
public Result page(EmpQueryParam empQueryParam) {
log.info("查询请求参数: {}", empQueryParam);
PageResult pageResult = empService.page(empQueryParam);
return Result.success(pageResult);
}
3). 修改EmpService接口方法
java
public interface EmpService {
/**
* 分页查询
*/
//PageResult page(Integer page, Integer pageSize, String name, Integer gender, LocalDate begin, LocalDate end);
PageResult page(EmpQueryParam empQueryParam);
}
4). 修改EmpServiceImpl中的page方法
java
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
/*@Override
public PageResult page(Integer page, Integer pageSize, String name, Integer gender, LocalDate begin, LocalDate end) {
//1. 设置PageHelper分页参数
PageHelper.startPage(page, pageSize);
//2. 执行查询
List<Emp> empList = empMapper.list(name, gender, begin, end);
//3. 封装分页结果
Page<Emp> p = (Page<Emp>) empList;
return new PageResult(p.getTotal(), p.getResult());
}*/
public PageResult page(EmpQueryParam empQueryParam) {
//1. 设置PageHelper分页参数
PageHelper.startPage(empQueryParam.getPage(), empQueryParam.getPageSize());
//2. 执行查询
List<Emp> empList = empMapper.list(empQueryParam);
//3. 封装分页结果
Page<Emp> p = (Page<Emp>)empList;
return new PageResult(p.getTotal(), p.getResult());
}
}
5). 修改EmpMapper接口方法
java
@Mapper
public interface EmpMapper {
/**
* 查询所有的员工及其对应的部门名称
*/
// @Select("select e.*, d.name as deptName from emp e left join dept d on e.dept_id = d.id")
// public List<Emp> list(String name, Integer gender, LocalDate begin, LocalDate end);
/**
* 根据查询条件查询员工
*/
List<Emp> list(EmpQueryParam empQueryParam);
}
EmpMapper.xml 中的配置无需修改。
代码优化完毕之后,重新启动运行测试,依然正常运行:

当我们在测试的时候,页码输入负数,查询是有问题的,查不到对应的数据了。

那其实在PageHelper中,我们可以通过合理化参数配置,来解决这个问题。直接在application.yml中,引入如下配置即可:
yaml
pagehelper:
reasonable: true
helper-dialect: mysql
reasonable:分页合理化参数,默认值为false。当该参数设置为true时,pageNum<=0时会查询第一页,pageNum>pages(超过总数时),会查询最后一页。默认false 时,直接根据参数进行查询。
测试如下:

✋

3.3.6 程序优化2
当前,我们在查询的时候,Mapper映射配置文件中的SQL语句中,查询条件是写死的。 而我们在员工管理中,根据条件查询员工信息时,查询条件是可选的,可以输入也可以不输入。

- 如果只输入 姓名 这个查询条件,则SQL语句中只根据name字段查询,SQL如下:
sql
select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id where e.name like concat('%',#{name},'%');
- 如果只输入 性别 这个查询条件,则SQL语句中只根据gender字段查询,SQL如下:
sql
select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id where e.gender = #{gender};
- 如果输入 姓名 和 性别 这两个查询条件,则SQL语句中要根据name、gender两个字段查询,SQL如下:
sql
select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id
where e.name like concat('%',#{name},'%') and e.gender = #{gender};
我们看到,这个SQL语句不应该是写死的,而应该根据用户输入的条件的变化而变化。 那这里呢,就要通过Mybatis中的动态SQL来实现。
所谓动态SQL,指的就是随着用户的输入或外部的条件的变化而变化的SQL语句。
具体的代码实现如下:
xml
<!--定义Mapper映射文件的约束和基本结构-->
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.itheima.mapper.EmpMapper">
<select id="list" resultType="com.itheima.pojo.Emp">
select e.*, d.name deptName from emp as e left join dept as d on e.dept_id = d.id
<where>
<if test="name != null and name != ''">
e.name like concat('%',#{name},'%')
</if>
<if test="gender != null">
and e.gender = #{gender}
</if>
<if test="begin != null and end != null">
and e.entry_date between #{begin} and #{end}
</if>
</where>
</select>
</mapper>
Localdate不可能为空串,所以不用判断。(""是字符串,不能赋值给 LocalDate,类型都对不上。)
在这里呢,我们用到了两个动态SQL的标签: 。 这两个标签的具体作用如下:
\<if>:判断条件是否成立,如果条件为true,则拼接SQL。\<where>:根据查询条件,来生成where关键字,并会自动去除条件前面多余的and或or。
代码优化完毕后,重新启动服务,测试如下:

没有输入任何查询条件时,运行日志如下:

当我们输入姓名和性别时

运行日志如下:

我们可以看到,当我们输入不同的搜索条件时,会动态的根据查询条件,动态拼接SQL语句。
✋
把begin和end都去掉,也成功返回了。


前后端联调测试:

☀️

09-后端Web实战(员工管理)
完成了员工管理的列表查询功能之后,接下来呢,我们再来完成新增员工的功能。 具体的需求如下:

那么在新增员工的时候,涉及到两部分:新增员工、文件上传。 今天的课程内容包括如下几个部分:
- 新增员工
- 事务管理
- 文件上传
1. 新增员工
1.1 需求

在新增员工的时候,在表单中,我们既要录入员工的基本信息,又要录入员工的工作经历信息。 员工基本信息,对应的表结构是 emp表,员工工作经历信息,对应的表结构是 emp_expr 表,所以这里我们要操作两张表,往两张表中保存数据。
1.2 接口描述
参照提供的接口文档中 员工管理 -> 添加员工 接口的描述。
1.3 思路分析
新增员工的具体的流程:

- 接口文档规定:
- 请求路径:/emps
- 请求方式:POST
- 请求参数:Json格式数据
- 响应数据:Json格式数据
- 问题1:如何限定请求方式是POST? @PostMapping
- 问题2:怎么在controller中接收json格式的请求参数?@RequestBody
1.4 功能开发
1.4.1 准备工作
准备EmpExprMapper接口及映射配置文件EmpExprMapper.xml,并准备实体类接收前端传递的json格式的请求参数。
1). EmpExprMapper接口
java
@Mapper
public interface EmpExprMapper {
}
2). EmpExprMapper.xml 配置文件
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.itheima.mapper.EmpExprMapper">
</mapper>
3). 需要在 Emp 员工实体类中增加属性 exprList 来封装工作经历数据。 最终完整代码如下:
java
@Data
public class Emp {
private Integer id; //ID,主键
private String username; //用户名
private String password; //密码
private String name; //姓名
private Integer gender; //性别, 1:男, 2:女
private String phone; //手机号
private Integer job; //职位, 1:班主任,2:讲师,3:学工主管,4:教研主管,5:咨询师
private Integer salary; //薪资
private String image; //头像
private LocalDate entryDate; //入职日期
private Integer deptId; //关联的部门ID
private LocalDateTime createTime; //创建时间
private LocalDateTime updateTime; //修改时间
//封装部门名称数
private String deptName; //部门名称
//封装员工工作经历信息
private List<EmpExpr> exprList;
}
1.4.2 保存员工基本信息
1). EmpController
在 EmpController 中增加save方法。
java
/**
* 添加员工
*/
@PostMapping
public Result save(@RequestBody Emp emp){
log.info("请求参数emp: {}", emp);
empService.save(emp);
return Result.success();
}
📚
- GET 请求:参数放在 URL 后面,直接写实体类即可接收,不需要@RequestBody。
- POST/PUT请求 :
@RequestBody用于 POST/PUT,读取请求体中的 JSON 数据,GET 请求不支持。
2). EmpService & EmpServiceImpl
在 EmpService 中增加 save 方法
java
/**
* 添加员工
* @param emp
*/
void save(Emp emp);
在 EmpServiceImpl 中增加save方法 , 实现接口中的save方法
java
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
//3. 保存员工的工作经历信息 - 批量 (稍后完成)
}
3). EmpMapper
在 EmpMapper 中增加insert方法,新增员工的基本信息。
java
/**
* 新增员工数据
*/
@Options(useGeneratedKeys = true, keyProperty = "id")
@Insert("insert into emp(username, name, gender, phone, job, salary, image, entry_date, dept_id, create_time, update_time) " +
"values (#{username},#{name},#{gender},#{phone},#{job},#{salary},#{image},#{entryDate},#{deptId},#{createTime},#{updateTime})")
void insert(Emp emp);
主键返回 :
@Options(useGeneratedKeys = true, keyProperty = "id")由于稍后,我们在保存工作经历信息的时候,需要记录是哪位员工的工作经历。
所以,保存完员工信息之后,是需要
获取到员工的ID的,那这里就需要通过Mybatis中提供的主键返回功能来获取。
1.4.3 批量保存工作经历
1.4.3.1 分析
一个员工,是可以有多段工作经历的,所以在页面上将来用户录入员工信息时,可以自己根据需要添加多段工作经历。页面原型展示如下:

那如果员工只有一段工作经历,我们就需要往工作经历表中保存一条记录。 执行的SQL如下:

如果员工有两段工作经历,我们就需要往工作经历表中保存两条记录。执行的SQL如下:

如果员工有三段工作经历,我们就需要往工作经历表中保存三条记录。执行的SQL如下:

所以,这里最终我们需要执行的是批量插入数据的insert语句。
1.4.3.2 实现
1). EmpServiceImpl
完善save方法中保存员工信息的逻辑。完整逻辑如下:
java
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
2). EmpExprMapper
java
@Mapper
public interface EmpExprMapper {
/**
* 批量插入员工工作经历信息
*/
public void insertBatch(List<EmpExpr> exprList);
}
3). EmpExprMapper.xml
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.itheima.mapper.EmpExprMapper">
<!--批量插入员工工作经历信息-->
<insert id="insertBatch">
insert into emp_expr (emp_id, begin, end, company, job) values
<foreach collection="exprList" item="expr" separator=",">
(#{expr.empId}, #{expr.begin}, #{expr.end}, #{expr.company}, #{expr.job})
</foreach>
</insert>
</mapper>
这里用到Mybatis中的动态SQL里提供的 <foreach> 标签,改标签的作用,是用来遍历循环,常见的属性说明:
- collection:集合名称
- item:集合遍历出来的元素/项
- separator:每一次遍历使用的分隔符
- open:遍历开始前拼接的片段
- close:遍历结束后拼接的片段
上述的属性,是可选的,并不是所有的都是必须的。 可以自己根据实际需求,来指定对应的属性。
1.5 功能测试
代码开发完成后,重启服务器,打开 Apifox 发送 POST 请求,请求路径:http://localhost:8080/emps

请求完毕后,可以打开idea的控制台看到控制台输出的日志:

1.6 前后端联调
功能测试通过后,我们再进行通过打开浏览器,测试后端功能接口:

点击保存之后,可以看到列表中已经展示出了这条数据。
✋
测试,导入json文件


json
{
"image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-03-07-37-38222.jpg",
"username": "linpingzhi",
"name": "林平之",
"gender": 1,
"job": 1,
"entryDate": "2022-09-18",
"deptId": 1,
"phone": "18809091234",
"salary": 8000,
"exprList": [
{
"company": "百度科技股份有限公司",
"job": "java开发",
"begin": "2012-07-01",
"end": "2019-03-03"
},
{
"company": "阿里巴巴科技股份有限公司",
"job": "架构师",
"begin": "2019-03-15",
"end": "2023-03-01"
}
]
}


到达controller层的时候,员工id还没有,

前后端联调也成功:

☀️

2. 事务管理
2.1 问题分析
目前我们实现的新增员工功能中,操作了两次数据库,执行了两次 insert 操作。
- 第一次:保存员工的基本信息到 emp 表中。
- 第二次:保存员工的工作经历信息到 emp_expr 表中。
如果说,保存员工的基本信息成功了,而保存员工的工作经历信息出错了,会发生什么现象呢?那接下来,我们来做一个测试 。 我们可以在代码中,人为在保存员工的service层的save方法中,构造一个错误:

那接下来,我们就重启服务,打开浏览器,来做一个测试:

点击 "保存" 之后,提示 "系统接口异常"。我们可以打开IDEA控制台看一下,报出的错误信息。 我们看到,保存了员工的基本信息之后,系统出现了异常。

我们再打开数据库,看看表结构中的数据是否正常。
1). emp 员工表中是有 shaseng 这条数据的。

2). emp_expr 表中没有该员工的工作经历信息。

最终,我们看到,程序出现了异常 ,员工表 emp 数据保存成功了, 但是 emp_expr 员工工作经历信息表,数据保存失败了。 那是否允许这种情况发生呢?
- 不允许
- 因为这属于一个业务操作,如果保存员工信息成功了,保存工作经历信息失败了,就会造成数据库数据的不完整、不一致。
那如何解决这个问题呢? 这需要通过数据库中的事务来解决这个问题。
2.2 介绍
概念: 事务是一组操作的集合,它是一个不可分割的工作单位。事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作 要么同时成功,要么同时失败。
就拿添加员工的这个业务为例,在这个业务操作中,包含了两个操作,那这两个操作是一个不可分割的工作单位。

这两个操作,要么同时失败,要么同时成功。
默认MySQL的事务是自动提交的,也就是说,当执行一条DML语句,MySQL会立即隐式的提交事务。
2.3 操作
事务控制主要三步操作:开启事务、提交事务/回滚事务。
- 需要在这组操作执行之前,先开启事务 ( start transaction; / begin;)。
- 所有操作如果全部都执行成功,则提交事务 ( commit; )。
- 如果这组操作中,有任何一个操作执行失败,都应该回滚事务 ( rollback )。
那接下来,我们就可以将添加员工的业务操作,进行事务管理。 具体的SQL如下:
sql
-- 开启事务
start transaction; / begin;
-- 1. 保存员工基本信息
insert into emp values (39, 'Tom', '123456', '汤姆', 1, '13300001111', 1, 4000, '1.jpg', '2023-11-01', 1, now(), now());
-- 2. 保存员工的工作经历信息
insert into emp_expr(emp_id, begin, end, company, job) values (39,'2019-01-01', '2020-01-01', '百度', '开发'), (39,'2020-01-10', '2022-02-01', '阿里', '架构');
-- 提交事务(全部成功)
commit;
-- 回滚事务(有一个失败)
rollback;
事务管理的场景,是非常多的,比如:
- 银行转账
- 下单扣减库存
☀️

2.4 Spring事务管理
2.4.1 分析
在上述实现的新增员工的功能中,一旦在保存员工基本信息后出现异常。 我们就会发现,员工信息保存成功,但是工作经历信息保存失败,造成了数据的不完整不一致。

产生原因:
- 先执行新增员工的操作,这步执行完毕,就已经往员工表 emp 插入了数据。
- 执行 1/0 操作,抛出异常
- 抛出异常之前,下面所有的代码都不会执行了,批量保存工作经历信息,这个操作也不会执行 。
此时就出现问题了,员工基本信息保存了,员工的工作经历信息未保存,业务操作前后数据不一致。
而要想保证操作前后,数据的一致性,就需要让新增员工中涉及到的两个业务操作,要么全部成功,要么全部失败 。 那我们如何,让这两个操作要么全部成功,要么全部失败呢 ?
那就可以通过事务来实现,因为一个事务中的多个业务操作,要么全部成功,要么全部失败。
此时,我们就需要在新增员工功能中添加事务。

在方法运行之前,开启事务,如果方法成功执行,就提交事务,如果方法执行的过程当中出现异常了,就回滚事务。
思考:开发中所有的业务操作,一旦我们要进行控制事务,是不是都是这样的套路?
答案:是的。
所以在spring框架当中就已经把事务控制的代码都已经封装好了,并不需要我们手动实现。我们使用了spring框架,我们只需要通过一个简单的注解@Transactional就搞定了。
2.4.2 Transactional注解
注解:@Transactional
作用:就是在当前这个方法执行开始之前来开启事务,方法执行完毕之后提交事务。如果在这个方法执行的过程当中出现了异常,就会进行事务的回滚操作。
位置:业务层的方法上、类上、接口上
- 方法上:当前方法交给spring进行事务管理
- 类上:当前类中所有的方法都交由spring进行事务管理
- 接口上:接口下所有的实现类当中所有的方法都交给spring 进行事务管理
📚开发中一般加在方法上
接下来,我们就可以在业务方法save上加上 @Transactional 来控制事务 。
java
@Transactional
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
int i = 1/0;
//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
@Transactional注解:我们一般会在业务层当中来控制事务,因为在业务层当中,一个业务功能可能会包含多个数据访问的操作。在业务层来控制事务,我们就可以将多个数据访问操作控制在一个事务范围内。
说明:可以在application.yml配置文件中开启事务管理日志,这样就可以在控制看到和事务相关的日志信息了
yaml
#spring事务管理日志
logging:
level:
org.springframework.jdbc.support.JdbcTransactionManager: debug
📚专门打开 Spring 事务管理器的 DEBUG 日志。
- 虽然全局规定:大家只允许输出 info 及以上;
- 但是单独对JdbcTransactionManager说:你特殊,允许输出 debug。
接下来,我们再次添加员工,看看控制台输出的日志信息。

添加Spring事务管理后,由于服务端程序引发了异常,所以事务进行回滚。

打开数据库,我们会看到 emp 表 与 emp_expr 表中都没有对应的数据信息,保证了数据的一致性、完整性。
✋
触发事务回滚,刚插入的员工数据被撤销,

2.4.3 事务进阶
前面我们通过spring事务管理注解@Transactional已经控制了业务层方法的事务。接下来我们要来详细的介绍一下@Transactional事务管理注解的使用细节。我们这里主要介绍@Transactional注解当中的两个常见的属性:
- 异常回滚的属性:rollbackFor
- 事务传播行为:propagation
我们先来学习下rollbackFor属性。
2.4.3.1 rollbackFor
我们在之前编写的业务方法上添加了@Transactional注解,来实现事务管理。
java
@Transactional
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
int i = 1/0;
//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
以上业务功能save方法在运行时,会引发除0的算术运算异常(运行时异常),出现异常之后,由于我们在方法上加了@Transactional注解进行事务管理,所以发生异常会执行rollback回滚操作,从而保证事务操作前后数据是一致的。
下面我们在做一个测试,我们修改业务功能代码,在模拟异常的位置上直接抛出Exception异常(编译时异常)
java
@Transactional
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
//模拟:异常发生
if(true){
throw new Exception("出现异常了~~~");
}
//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
说明:在service中向上抛出一个Exception编译时异常之后,由于是controller调用service,所以在controller中要有异常处理代码,此时我们选择在controller中继续把异常向上抛。
重新启动服务后,打开Apifox进行测试,请求添加员工的接口:
通过Apifox返回的结果,我们看到抛出异常了。然后我们在回到IDEA的控制台来看一下。

我们看到数据库的事务居然提交了,并没有进行回滚。
⭐️通过以上测试可以得出一个结论:默认情况下,只有出现RuntimeException(运行时异常)才会回滚事务。
⭐️假如我们想让所有的异常都回滚,需要来配置@Transactional注解当中的rollbackFor属性,通过rollbackFor这个属性可以指定出现何种异常类型回滚事务。
java
@Transactional(rollbackFor = Exception.class)
@Override
public void save(Emp emp) throws Exception {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
//int i = 1/0;
if(true){
throw new Exception("出异常啦....");
}
//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
接下来我们重新启动服务,测试新增员工的操作:

控制台日志,可以看到因为出现了异常又进行了事务回滚。

结论:
- 在Spring的事务管理中,默认只有运行时异常 RuntimeException才会回滚。
- 如果还需要回滚指定类型的异常,可以通过rollbackFor属性来指定。
2.4.3.2 propagation
2.4.3.2.1 介绍
我们接着继续学习@Transactional注解当中的第二个属性propagation,这个属性是用来配置事务的传播行为的。
什么是事务的传播行为呢?
- 就是当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制。
例如:两个事务方法,一个A方法,一个B方法。在这两个方法上都添加了@Transactional注解,就代表这两个方法都具有事务,而在A方法当中又去调用了B方法。

所谓事务的传播行为,指的就是在A方法运行的时候,首先会开启一个事务,在A方法当中又调用了B方法, B方法自身也具有事务,那么B方法在运行的时候,到底是加入到A方法的事务当中来,还是B方法在运行的时候新建一个事务?这个就涉及到了事务的传播行为。
我们要想控制事务的传播行为,在@Transactional注解的后面指定一个属性propagation,通过 propagation 属性来指定传播行为。接下来我们就来介绍一下常见的事务传播行为。

对于这些事务传播行为,我们只需要关注以下两个就可以了:
- REQUIRED(默认值)
- REQUIRES_NEW
2.4.3.2.2 案例
接下来我们就通过一个案例来演示下事务传播行为propagation属性的使用。
需求:在新增员工信息时,无论是成功还是失败,都要记录操作日志。
步骤:
- 准备日志表 emp_log、实体类EmpLog、Mapper接口EmpLogMapper
- 在新增员工时记录日志
准备工作:
1). 创建数据库表 emp_log 日志表
sql
-- 创建员工日志表
create table emp_log(
id int unsigned primary key auto_increment comment 'ID, 主键',
operate_time datetime comment '操作时间',
info varchar(2000) comment '日志信息'
) comment '员工日志表';
2). 引入资料中提供的实体类:EmpLog
java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmpLog {
private Integer id; //ID
private LocalDateTime operateTime; //操作时间
private String info; //详细信息
}
3). 引入资料中提供的Mapper接口:EmpLogMapper
java
@Mapper
public interface EmpLogMapper {
//插入日志
@Insert("insert into emp_log (operate_time, info) values (#{operateTime}, #{info})")
public void insert(EmpLog empLog);
}
4). 引入资料中提供的业务接口:EmpLogService
java
public interface EmpLogService {
//记录新增员工日志
public void insertLog(EmpLog empLog);
}
5). 引入资料中提供的业务实现类:EmpLogServiceImpl
java
@Service
public class EmpLogServiceImpl implements EmpLogService {
@Autowired
private EmpLogMapper empLogMapper;
@Transactional
@Override
public void insertLog(EmpLog empLog) {
empLogMapper.insert(empLog);
}
}
代码实现:
业务实现类:EmpServiceImpl
java
@Autowired
private EmpMapper empMapper;
@Autowired
private EmpExprMapper empExprMapper;
@Autowired
private EmpLogService empLogService;
@Transactional(rollbackFor = {Exception.class})
@Override
public void save(Emp emp) {
try {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
int i = 1/0;
//3. 保存员工的工作经历信息 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
} finally {
//记录操作日志
EmpLog empLog = new EmpLog(null, LocalDateTime.now(), emp.toString());
empLogService.insertLog(empLog);
}
}
测试:
重新启动SpringBoot服务,测试新增员工操作 。我们可以看到控制台中输出的日志:

从日志中我们可以看到:
- 执行了插入员工数据的操作
- 执行了插入日志操作
- 程序发生Exception异常
- 执行事务回滚(保存员工数据、插入操作日志 因为在一个事务范围内,两个操作都会被回滚 )
然后在 emp_log 表中没有记录日志数据 。
原因分析:
接下来我们就需要来分析一下具体是什么原因导致的日志没有成功的记录。
- 在执行 save 方法时开启了一个事务
- 当执行 empLogService.insertLog 操作时,insertLog设置的事务传播行是
默认值REQUIRED,表示有事务就加入,没有则新建事务 - 此时:save 和 insertLog 操作
使用了同一个事务,同一个事务中的多个操作,要么同时成功,要么同时失败,所以当异常发生时进行事务回滚,就会回滚 save 和 insertLog 操作
解决方案:
在EmpLogServiceImpl类中insertLog方法上,添加 @Transactional(propagation = Propagation.REQUIRES_NEW)
Propagation.REQUIRES_NEW:不论是否有事务,都创建新事务 ,运行在一个独立的事务中。
java
@Service
public class EmpLogServiceImpl implements EmpLogService {
@Autowired
private EmpLogMapper empLogMapper;
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void insertLog(EmpLog empLog) {
empLogMapper.insert(empLog);
}
}
重启SpringBoot服务,再次测试 新增员工的操作 ,会看到具体的日志如下:

那此时,EmpServiceImpl 中的 save 方法运行时,会开启一个事务。 当调用 empLogService.insertLog(empLog) 时,也会创建一个新的事务 ,那此时,当 insertLog 方法运行完毕之后,事务就已经提交了。 即使外部的事务出现异常,内部已经提交的事务,也不会回滚了,因为是两个独立的事务。
(所以员工插入失败,但记录日志成功)
到此事务传播行为已演示完成,
⭐️事务的传播行为我们只需要掌握两个:REQUIRED、REQUIRES_NEW。
- REQUIRED:大部分情况下都是用该传播行为即可。
- REQUIRES_NEW:当我们不希望事务之间相互影响时,可以使用该传播行为。比如:下订单前需要记录日志,不论订单保存成功与否,都需要保证日志记录能够记录成功。
2.5 事务四大特性
面试题:事务有哪些特性?
- 原子性(Atomicity):事务是不可分割的最小单元,要么全部成功,要么全部失败。
- 一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
(例如转账前后金额总额不变) - 隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。
(一个事务看不到另一个事务还没提交的半成品数据。) - 持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。
事务的四大特性简称为:ACID

- 原子性(Atomicity) :原子性是指事务包装的一组sql是一个不可分割的工作单元,事务中的操作要么全部成功,要么全部失败。
- 一致性(Consistency):一个事务完成之后数据都必须处于一致性状态。
- 如果事务成功的完成,那么数据库的所有变化将生效。
- 如果事务执行出现错误,那么数据库的所有变化将会被回滚(撤销),返回到原始状态。
- 隔离性(Isolation):多个用户并发的访问数据库时,一个用户的事务不能被其他用户的事务干扰,多个并发的事务之间要相互隔离。
- 一个事务的成功或者失败对于其他的事务是没有影响。
- 持久性(Durability):一个事务一旦被提交或回滚,它对数据库的改变将是永久性的,哪怕数据库发生异常,重启之后数据亦然存在。
3. 文件上传
在我们完成的 新增员工 功能中,还存在一个问题:没有头像(图片缺失)

上述问题,需要我们通过文件上传技术来解决。下面我们就进入到文件上传技术的学习。
文件上传技术这块我们主要讲解三个方面:首先我们先对文件上传做一个整体的介绍,接着再学习文件上传的本地存储方式,最后学习云存储方式。
接下来我们就先来学习下什么是文件上传。
3.1 简介
文件上传,是指将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载的过程。
文件上传在项目中应用非常广泛,我们经常发微博、发微信朋友圈都用到了文件上传功能。

在我们的案例中,在新增员工的时候,要上传员工的头像,此时就会涉及到文件上传的功能。在进行文件上传时,我们点击加号或者是点击图片,就可以选择手机或者是电脑本地的图片文件了。当我们选择了某一个图片文件之后,这个文件就会上传到服务器,从而完成文件上传的操作。
AI提示词:你是一名java开发工程师,现在正在学习文件上传功能,如何基于HTML+SpringBoot完成文件上传功能。
1). 生成的前端代码形式如下:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>上传文件</title>
</head>
<body>
<form action="/upload" method="post" enctype="multipart/form-data">
姓名: <input type="text" name="name"><br>
年龄: <input type="text" name="age"><br>
头像: <input type="file" name="file"><br>
<input type="submit" value="提交">
</form>
</body>
</html>

上传文件的原始form表单,要求表单必须具备以下三点(上传文件页面三要素):
- 表单必须有
file域,用于选择要上传的文件 - 表单提交方式必须为POST:通常上传的文件会比较大,所以需要使用 POST 提交方式
- 表单的编码类型enctype必须要设置为:
multipart/form-data:普通默认的编码格式是不适合传输大型的二进制数据的,所以在文件上传时,表单的编码格式必须设置为multipart/form-data
前端页面的3要素我们了解后,接下来我们就来验证下所讲解的文件上传3要素。
在提供的"课程资料"中有一个名叫"文件上传"的文件夹,直接将里的 upload.html 文件,复制到springboot项目工程下的static目录里面。

2). 生成的服务端代码形式如下
java
package com.itheima.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
@Slf4j
@RestController
public class UploadController {
/**
* 上传文件 - 参数名file
*/
@PostMapping("/upload")
public Result upload(String username, Integer age , MultipartFile file) throws Exception {
log.info("上传文件:{}, {}, {}", username, age, file);
if(!file.isEmpty()){
file.transferTo(new File("D:\\images\\" + file.getOriginalFilename()));
}
return Result.success();
}
}
在定义的方法中接收提交过来的数据 (方法中的形参名和请求参数的名字保持一致)
- 用户名:String name
- 年龄: Integer age
- 文件: MultipartFile file

Spring中提供了一个API:MultipartFile,使用这个API就可以来接收到上传的文件
问题1:如果表单项的名字和方法中形参名不一致,该怎么办?
java
public Result upload(String username,
Integer age,
MultipartFile image) //image形参名和请求参数名file不一致
解决:使用@RequestParam注解进行参数绑定
java
public Result upload(String username,
Integer age,
@RequestParam("file") MultipartFile image)
3.2 本地存储
上面我们已经完成了文件上传最基本的功能实现,已经可以在服务端接收到上传的文件,并将文件保存在本地服务器的磁盘目录中了。 但是我们测试的时候发现,如果上传的文件名相同,后面上传的会覆盖前面上传的文件,那接下来,我们就要来优化这一块的功能。
🤖AI提示词:请完善上述代码, 将接收到的文件存储在本地的磁盘目录中(D:/images)中, 并要保证上传的文件名不重复
代码实现如下:
java
package com.itheima.controller;
import com.itheima.pojo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.util.UUID;
@Slf4j
@RestController
public class UploadController {
private static final String UPLOAD_DIR = "D:/images/";
/**
* 上传文件 - 参数名file
*/
@PostMapping("/upload")
public Result upload(MultipartFile file) throws Exception {
log.info("上传文件:{}, {}, {}", username, age, file);
if (!file.isEmpty()) {
// 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName;
// 拼接完整的文件路径
File targetFile = new File(UPLOAD_DIR + uniqueFileName);
// 如果目标目录不存在,则创建它
if (!targetFile.getParentFile().exists()) {
targetFile.getParentFile().mkdirs();
}
// 保存文件
file.transferTo(targetFile);
}
return Result.success();
}
}
1、
String originalFilename = file.getOriginalFilename();:获取原始文件名,2、
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));:找到最后一个.的位置,开始截取 → ".jpg"。3、
String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName;:UUID生成唯一字符串,去掉横杠,拼接后缀。
- 例:a1b2c3d4e5f6 + .jpg → a1b2c3d4e5f6.jpg
- 目的:避免重名文件覆盖旧文件
4、
File targetFile = new File(UPLOAD_DIR + uniqueFileName);: UPLOAD_DIR是预先定义好的上传根路径,拼接文件名,得到完整保存路径
MultipartFile 常见方法:
- String getOriginalFilename(); //获取原始文件名
- void transferTo(File dest); //将接收的文件转存到磁盘文件中
- long getSize(); //获取文件的大小,单位:字节
- byte\[\] getBytes(); //获取文件内容的字节数组
- InputStream getInputStream(); //获取接收到的文件内容的输入流
利用 Apifox 测试,注意:请求参数名和controller方法形参名保持一致。
通过 Apifox 测试,我们发现文件上传是没有问题的。
在解决了文件名唯一性的问题后,我们再次上传一个较大的文件(超出1M)时发现,后端程序报错:

报错原因呢,是因为:在SpringBoot中,文件上传时默认单个文件最大大小为1M
那么如果需要上传大文件,可以在 application.properties 进行如下配置:
yaml
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 100MB
- max‑file‑size:单个文件最大
- max‑request‑size:一次请求全部数据总和最大
到时此,我们文件上传的本地存储方式已完成了。但是这种本地存储方式还存在一问题:

如果直接存储在服务器的磁盘目录中,存在以下缺点:
- 不安全:磁盘如果损坏,所有的文件就会丢失
- 容量有限:如果存储大量的图片,磁盘空间有限(磁盘不可能无限制扩容)
- 无法直接访问
为了解决上述问题呢,通常有两种解决方案:
- 自己搭建存储服务器,如:fastDFS 、MinIO
- 使用现成的云服务,如:阿里云,腾讯云,华为云
3.3 阿里云OSS
3.3.1 准备
3.3.1.1 介绍
阿里云是阿里巴巴集团旗下全球领先的云计算公司,也是国内最大的云服务提供商 。

云服务 指的就是通过互联网对外提供的各种各样的服务,比如像:语音服务、短信服务、邮件服务、视频直播服务、文字识别服务、对象存储服务 等等。
当我们在项目开发时需要用到某个或某些服务,就不需要自己来开发了,可以直接使用阿里云提供好的这些现成服务就可以了。
比如:在项目开发当中,我们要实现一个短信发送的功能,如果我们项目组自己实现,将会非常繁琐,因为你需要和各个运营商进行对接。而此时阿里云完成了和三大运营商对接,并对外提供了一个短信服务。我们项目组只需要调用阿里云提供的短信服务,就可以很方便的来发送短信了。这样就降低了我们项目的开发难度,同时也提高了项目的开发效率。(大白话:别人帮我们实现好了功能,我们只要调用即可)
云服务提供商给我们提供的软件服务通常是需要收取一部分费用的。
阿里云对象存储OSS(Object Storage Service),是一款海量、安全、低成本、高可靠的云存储服务。使用OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种文件。

在我们使用了阿里云OSS对象存储服务之后,我们的项目当中如果涉及到文件上传这样的业务,在前端进行文件上传并请求到服务端时,在服务器本地磁盘当中就不需要再来存储文件了。我们直接将接收到的文件上传到oss,由 oss帮我们存储和管理,同时阿里云的oss存储服务还保障了我们所存储内容的安全可靠。

那我们学习使用这类云服务,我们主要学习什么呢?其实我们主要学习的是如何在项目当中来使用云服务完成具体的业务功能。而无论使用什么样的云服务,阿里云也好,腾讯云、华为云也罢,在使用第三方的服务时,操作的思路都是一样的。

SDK :Software Development Kit
的缩写,软件开发工具包 ,包括辅助软件开发的依赖(jar包)、代码示例等,都可以叫做SDK。
简单说,sdk中包含了我们使用第三方云服务时所需要的依赖,以及一些示例代码。我们可以参照sdk所提供的示例代码就可以完成入门程序。
第三方服务使用的通用思路,我们做一个简单介绍之后,接下来我们就来介绍一下我们当前要使用的阿里云oss对象存储服务具体的使用步骤。

Bucket:存储空间是用户用于存储对象(Object,就是文件)的容器,所有的对象都必须隶属于某个存储空间。
3.3.1.2 账号准备
下面我们根据之前介绍的使用步骤,完成准备工作:
1). 注册阿里云账户(注册完成后需要实名认证)
https://account.aliyun.com/login/login.htm?oauth_callback=https%3A%2F%2Fwww.aliyun.com%2F

2). 注册完账号之后,就可以登录阿里云

3.3.1.3 开通OSS云服务
1). 通过控制台找到对象存储OSS服务

选择要开通的服务

如果是第一次访问,还需要开通对象存储服务OSS

2). 开通OSS服务之后,就可以进入到阿里云对象存储的控制台

3). 点击左侧的 "Bucket列表",创建一个Bucket

输入Bucket的相关信息.

其他的信息,配置项使用默认的即可。
3.3.1.4 配置AccessKey
1). 创建AccessKey
点击 "AccessKey管理",进入到管理页面。

点击 "创建AccessKey"。


2). 配置AccessKey
以管理员身份打开CMD命令行,执行如下命令,配置系统的环境变量。
sql
set OSS_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
set OSS_ACCESS_KEY_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
注意:将上述的ACCESS_KEY_ID 与 ACCESS_KEY_SECRET 的值一定一定一定一定一定一定要替换成自己的 。
执行如下命令,让更改生效。
sql
setx OSS_ACCESS_KEY_ID "%OSS_ACCESS_KEY_ID%"
setx OSS_ACCESS_KEY_SECRET "%OSS_ACCESS_KEY_SECRET%"
执行如下命令,验证环境变量是否生效。
sql
echo %OSS_ACCESS_KEY_ID%
echo %OSS_ACCESS_KEY_SECRET%
✋
acesskey泄露会花自己钱,好害怕,不贴截图了,反正就是完成了。
3.3.2 入门
阿里云oss 对象存储服务的准备工作我们已经完成了,接下来我们就来完成第二步操作:参照官方所提供的sdk示例来编写入门程序。
首先我们需要来打开阿里云OSS的官方文档,在官方文档中找到 SDK 的示例代码:

如果是在实际开发当中,我们是需要从前往后仔细的去阅读这一份文档的,但是由于现在是教学,我们就只挑重点的去看。有兴趣的同学大家下来也可以自己去看一下这份官方文档。
参照文档,引入依赖:

xml
<!--阿里云OSS依赖-->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<!-- no more than 2.3.3-->
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.3</version>
</dependency>
参照文档,编写入门程序:

将官方提供的入门程序,复制过来,将里面的参数值改造成我们自己的即可。代码如下:
java
package com.itheima;
import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.nio.file.Files;
public class Demo {
public static void main(String[] args) throws Exception {
// Endpoint以华东1(杭州)为例,其它Region请按实际情况填写。
String endpoint = "https://oss-cn-beijing.aliyuncs.com";
// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
// 填写Bucket名称,例如examplebucket。
String bucketName = "java-ai";
// 填写Object完整路径,例如exampledir/exampleobject.txt。Object完整路径中不能包含Bucket名称。
String objectName = "001.jpg";
// 填写Bucket所在地域。以华东1(杭州)为例,Region填写为cn-hangzhou。
String region = "cn-beijing";
// 创建OSSClient实例。
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.clientConfiguration(clientBuilderConfiguration)
.region(region)
.build();
try {
File file = new File("C:\\Users\\deng\\Pictures\\1.jpg");
byte[] content = Files.readAllBytes(file.toPath());
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
}
}
🐛又又又遇到Maven依赖已经写进 pom.xml,但是 IDEA 没有正确识别/下载/刷新。
解决:
1、在终端让 Maven主动下载依赖
2、重新构建整个项目
重新构建整个项目3、刷新 IDEA Maven
Reload All Maven Projects4、清 IDEA 缓存
切记,大家需要将上面的 endpoint ,bucketName,objectName,file 都需要改成自己的。
在以上代码中,需要替换的内容为:
- endpoint:阿里云OSS中的bucket对应的域名
- bucketName:Bucket名称
- objectName:对象名称,在Bucket中存储的对象的名称
- region:bucket所属区域
运行以上程序后,会把本地的文件上传到阿里云OSS服务器上。
3.3.3 集成
3.3.3.1 介绍
阿里云oss对象存储服务的准备工作以及入门程序我们都已经完成了,接下来我们就需要在案例当中集成oss对象存储服务,来存储和管理案例中上传的图片。

在新增员工的时候,上传员工的图像,而之所以需要上传员工的图像,是因为将来我们需要在系统页面当中访问并展示员工的图像。而要想完成这个操作,需要做两件事:
- 需要上传员工的图像,并把图像保存起来(存储到阿里云OSS)
- 访问员工图像(通过图像在阿里云OSS的存储地址访问图像)
- OSS中的每一个文件都会分配一个访问的url,通过这个url就可以访问到存储在阿里云上的图片。所以需要把url返回给前端,这样前端就可以通过url获取到图像。
我们参照接口文档来开发文件上传功能:
基本信息
- 请求路径:/upload
请求方式:POST
接口描述:上传图片接口
请求参数- 参数格式:multipart/form-data
- 参数说明:

响应数据 - 参数格式:application/json
- 参数说明:

- 响应数据样例:
json
{
"code": 1,
"msg": "success",
"data": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-0400.jpg"
}
✋
把老版的阿里云镜像换了:
http://maven.aliyun.com/nexus/content/groups/public/
↓
https://maven.aliyun.com/repository/public
修改下上传文件的代码:
java
File file = new File("D:\\data\\图片\\1.jpg.webp");
byte[] content = Files.readAllBytes(file.toPath());
// 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。
// ObjectMetadata metadata = new ObjectMetadata();
// metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
// metadata.setObjectAcl(CannedAccessControlList.Private);
// putObjectRequest.setMetadata(metadata);
// 上传字符串。
ossClient.putObject(bucketName,objectName,new ByteArrayInputStream(content));

3.3.3.2 实现
1). 引入阿里云OSS上传文件工具类(由官方的示例代码改造而来)
java
package com.itheima.utils;
import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
@Component
public class AliyunOSSOperator {
private String endpoint = "https://oss-cn-beijing.aliyuncs.com";
private String bucketName = "java-ai";
private String region = "cn-beijing";
public String upload(byte[] content, String originalFilename) throws Exception {
// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
// 填写Object完整路径,例如202406/1.png。Object完整路径中不能包含Bucket名称。
//获取当前系统日期的字符串,格式为 yyyy/MM
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
//生成一个新的不重复的文件名
String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = dir + "/" + newFileName;
// 创建OSSClient实例。
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.clientConfiguration(clientBuilderConfiguration)
.region(region)
.build();
try {
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
} finally {
ossClient.shutdown();
}
return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
}
}
2). 修改UploadController代码
java
package com.itheima.controller;
import com.itheima.pojo.Result;
import com.itheima.utils.AliyunOSSOperator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.util.UUID;
@Slf4j
@RestController
public class UploadController {
@Autowired
private AliyunOSSOperator aliyunOSSOperator;
@PostMapping("/upload")
public Result upload(MultipartFile file) throws Exception {
log.info("上传文件:{}", file);
if (!file.isEmpty()) {
// 生成唯一文件名
String originalFilename = file.getOriginalFilename();
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName;
// 上传文件
String url = aliyunOSSOperator.upload(file.getBytes(), uniqueFileName);
return Result.success(url);
}
return Result.error("上传失败");
}
}
使用 Apifox 测试:

接口测试通过之后,我们就可以进行前后端联调了。
✋
成功返回

报错返回数据结构与接口定义不一致1. $.data 应当是 object,null 类型是因为:Apifox 接口文档的模型类型写的和真实返回不匹配。
已成功保存到OSS,

前后端联调:

头像成功显示,
成功保存到OSS,

3.4 功能优化
员工管理的新增功能我们已开发完成,但在我们所开发的程序中还一些小问题,下面我们就来分析一下当前案例中存在的问题以及如何优化解决。
在刚才我们制作的AliyunOSS操作的工具类中,我们直接将 endpoint、bucketName参数直接在java文件中写死了。如下所示:

如果后续,项目要部署到测试环境、上生产环境,我们需要来修改这两个参数。 而如果开发一个大型项目,所有用到的技术涉及到的这些个参数全部写死在java代码中,是非常不便于维护和管理的。
那么对于这些容易变动的参数,我们可以将其配置在配置文件中,然后通过 @Value 注解来注解外部配置的属性。如下所示:

具体实现代码如下:
1). application.yml
yml
#阿里云OSS
aliyun:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
bucketName: java-ai
region: cn-beijing
2). AliyunOSSOperator
java
package com.itheima.utils;
import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
@Component
public class AliyunOSSOperator {
//方式一: 通过@Value注解一个属性一个属性的注入
@Value("${aliyun.oss.endpoint}")
private String endpoint;
@Value("${aliyun.oss.bucketName}")
private String bucketName;
@Value("${aliyun.oss.region}")
private String region;
public String upload(byte[] content, String originalFilename) throws Exception {
// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
// 填写Object完整路径,例如2024/06/1.png。Object完整路径中不能包含Bucket名称。
//获取当前系统日期的字符串,格式为 yyyy/MM
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
//生成一个新的不重复的文件名
String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = dir + "/" + newFileName;
// 创建OSSClient实例。
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.clientConfiguration(clientBuilderConfiguration)
.region(region)
.build();
try {
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
} finally {
ossClient.shutdown();
}
return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
}
}
如果只有一两个属性需要注入,而且不需要考虑复用性,使用@Value注解就可以了。
但是使用@Value注解注入配置文件的配置项,如果配置项多,注入繁琐,不便于维护管理 和 复用。如下所示:

✋

成功返回,

那么有没有一种方式可以简化这些配置参数的注入呢?答案是肯定有,在Spring中给我们提供了一种简化方式,可以直接将配置文件中配置项的值自动的注入到对象的属性中。
Spring提供的简化方式套路 :
1). 需要创建一个实现类,且实体类中的属性名和配置文件当中key的名字必须要一致
- 比如:配置文件当中叫endpoint,实体类当中的属性也得叫endpoint,
- 另外实体类当中的属性还需要提供 getter / setter方法
2). 需要将实体类交给Spring的IOC容器管理,成为IOC容器当中的bean对象
3). 在实体类上添加@ConfigurationProperties注解,并通过perfect属性来指定配置参数项的前缀

具体实现步骤:
1). 定义实体类AliyunOSSProperties ,并交给IOC容器管理
java
package com.itheima.utils;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliyunOSSProperties {
private String endpoint;
private String bucketName;
private String region;
}
2). 修改AliyunOSSOperator
java
package com.itheima.utils;
import com.aliyun.oss.*;
import com.aliyun.oss.common.auth.CredentialsProviderFactory;
import com.aliyun.oss.common.auth.EnvironmentVariableCredentialsProvider;
import com.aliyun.oss.common.comm.SignVersion;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
@Component
public class AliyunOSSOperator {
//方式一: 通过@Value注解一个属性一个属性的注入
//@Value("${aliyun.oss.endpoint}")
//private String endpoint;
//@Value("${aliyun.oss.bucketName}")
//private String bucketName;
//@Value("${aliyun.oss.region}")
//private String region;
@Autowired
private AliyunOSSProperties aliyunOSSProperties;
public String upload(byte[] content, String originalFilename) throws Exception {
String endpoint = aliyunOSSProperties.getEndpoint();
String bucketName = aliyunOSSProperties.getBucketName();
String region = aliyunOSSProperties.getRegion();
// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
// 填写Object完整路径,例如2024/06/1.png。Object完整路径中不能包含Bucket名称。
//获取当前系统日期的字符串,格式为 yyyy/MM
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
//生成一个新的不重复的文件名
String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = dir + "/" + newFileName;
// 创建OSSClient实例。
ClientBuilderConfiguration clientBuilderConfiguration = new ClientBuilderConfiguration();
clientBuilderConfiguration.setSignatureVersion(SignVersion.V4);
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.clientConfiguration(clientBuilderConfiguration)
.region(region)
.build();
try {
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
} finally {
ossClient.shutdown();
}
return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
}
}
☀️

10-后端Web实战(员工管理)
我们已经完成了员工管理中的列表查询、新增员工的功能,那关于员工管理还有两个功能分别是:修改员工、删除员工。

除了员工管理的功能以外,我们还要完成员工信息统计的功能,包括:员工职位统计、员工性别统计。

所以,今天课程内容包含如下几个部分:
- 删除员工
- 修改员工
- 异常处理
- 员工信息统计
1. 删除员工
1.1 需求

当我们勾选列表前面的复选框,然后点击 "批量删除" 按钮,就可以将这一批次的员工信息删除掉了。也可以只勾选一个复选框,仅删除一个员工信息。
问题:我们需要开发两个功能接口吗?一个删除单个员工,一个删除多个员工
答案:不需要。 只需要开发一个功能接口即可(删除多个员工包含只删除一个员工)
1.2 接口文档
参照资料中提供的接口文档,查看 员工管理 -> 删除员工 接口的描述。

1.3 思路分析

1.4 功能开发
1.4.1 Controller接收参数
在 EmpController 中增加如下方法 delete ,来执行批量删除员工的操作。
方式一:在Controller方法中通过数组来接收
多个参数,默认可以将其封装到一个数组中,需要保证前端传递的参数名 与 方法形参名称保持一致。
java
/**
* 批量删除员工
*/
@DeleteMapping
public Result delete(Integer[] ids){
log.info("批量删除部门: ids={} ", Arrays.asList(ids));
return Result.success();
}
方式二:在Controller方法中通过集合来接收
也可以将其封装到一个List<Integer>集合中,如果要将其封装到一个集合中,需要在集合前面加上 @RequestParam 注解。
java
/**
* 批量删除员工
*/
@DeleteMapping
public Result delete(@RequestParam List<Integer> ids){
log.info("批量删除部门: ids={} ", ids);
empService.deleteByIds(ids);
return Result.success();
}
两种方式,选择其中一种就可以,我们一般推荐选择集合,因为基于集合操作其中的元素会更加方便。
1.4.2 Service
1). 在接口中 EmpService 中定义接口方法 deleteByIds
java
/**
* 批量删除员工
*/
void deleteByIds(List<Integer> ids);
2). 在实现类 EmpServiceImpl 中实现接口方法 deleteByIds
在删除员工信息时,既需要删除 emp 表中的员工基本信息,还需要删除 emp_expr 表中员工的工作经历信息
java
@Transactional
@Override
public void deleteByIds(List<Integer> ids) {
//1. 根据ID批量删除员工基本信息
empMapper.deleteByIds(ids);
//2. 根据员工的ID批量删除员工的工作经历信息
empExprMapper.deleteByEmpIds(ids);
}
由于删除员工信息,既要删除员工基本信息,又要删除工作经历信息,操作多次数据库的删除,所以需要进行事务控制。
1.4.3 Mapper
1). 在 EmpMapper 接口中增加 deleteByIds 方法实现批量删除员工基本信息
java
/**
* 批量删除员工信息
*/
void deleteByIds(List<Integer> ids);
2). 在 EmpMapper.xml 配置文件中, 配置对应的SQL语句
sql
<!--批量删除员工信息-->
<delete id="deleteByIds">
delete from emp where id in
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</delete>
3). 在 EmpExprMapper 接口中增加 deleteByEmpIds 方法实现根据员工ID批量删除员工的工作经历信息
java
/**
* 根据员工的ID批量删除工作经历信息
*/
void deleteByEmpIds(List<Integer> empIds);
4). 在 EmpExprMapper.xml 配置文件中, 配置对应的SQL语句
sql
<!--根据员工的ID批量删除工作经历信息-->
<delete id="deleteByEmpIds">
delete from emp_expr where emp_id in
<foreach collection="empIds" item="empId" open="(" close=")" separator=",">
#{empId}
</foreach>
</delete>
🐛又又又报的错误:MyBatis 找不到名字叫ids的参数。
解决:加上
@Param("ids")
1.5 功能测试
功能开发完成后,重启项目工程,打开 Apifox,发起DELETE请求:
控制台SQL语句:

✋

1.6 前后端联调
打开浏览器,测试后端功能接口:

2. 修改员工

在进行修改员工信息的时候,我们首先先要根据员工的ID查询员工的详细信息用于页面回显展示,然后用户修改员工数据之后,点击保存按钮,就可以将修改的数据提交到服务端,保存到数据库。 具体操作为:
- 根据ID查询员工信息
- 保存修改的员工信息
2.1 查询回显
2.1.1 接口描述
参照资料中提供的接口文档,查看 员工管理 -> 根据ID查询 接口的描述。
2.1.2 思路
在查询回显时,既需要查询出员工的基本信息,又需要查询出该员工的工作经历信息。

我们可以先通过一条SQL语句,查询出指定员工的基本信息,及其员工的工作经历信息。SQL如下:
sql
select e.*,
ee.id ee_id,
ee.begin ee_begin,
ee.end ee_end,
ee.company ee_company,
ee.job ee_job
from emp e left join emp_expr ee on e.id = ee.emp_id where e.id = 39;
具体的实现思路如下:

2.1.3 代码实现
1). EmpController 添加 getInfo 用来根据ID查询员工数据,用于页面回显
java
/**
* 查询回显
*/
@GetMapping("/{id}")
public Result getInfo(@PathVariable Integer id){
log.info("根据id查询员工的详细信息");
Emp emp = empService.getInfo(id);
return Result.success(emp);
}
2). EmpService 接口中增加 getInfo 方法
java
/**
* 根据ID查询员工的详细信息
*/
Emp getInfo(Integer id);
3). EmpServiceImpl 实现类中实现 getInfo 方法
java
@Override
public Emp getInfo(Integer id) {
return empMapper.getById(id);
}
4). EmpMapper 接口中增加 getById 方法
java
/**
* 根据ID查询员工详细信息
*/
Emp getById(Integer id);
5). EmpMapper.xml 配置文件中定义对应的SQL
sql
<!--自定义结果集ResultMap-->
<resultMap id="empResultMap" type="com.itheima.pojo.Emp">
<id column="id" property="id" />
<result column="username" property="username" />
<result column="password" property="password" />
<result column="name" property="name" />
<result column="gender" property="gender" />
<result column="phone" property="phone" />
<result column="job" property="job" />
<result column="salary" property="salary" />
<result column="image" property="image" />
<result column="entry_date" property="entryDate" />
<result column="dept_id" property="deptId" />
<result column="create_time" property="createTime" />
<result column="update_time" property="updateTime" />
<!--封装exprList-->
<collection property="exprList" ofType="com.itheima.pojo.EmpExpr">
<id column="ee_id" property="id"/>
<result column="ee_company" property="company"/>
<result column="ee_job" property="job"/>
<result column="ee_begin" property="begin"/>
<result column="ee_end" property="end"/>
<result column="ee_empid" property="empId"/>
</collection>
</resultMap>
<!--根据ID查询员工的详细信息-->
<select id="getById" resultMap="empResultMap">
select e.*,
ee.id ee_id,
ee.emp_id ee_empid,
ee.begin ee_begin,
ee.end ee_end,
ee.company ee_company,
ee.job ee_job
from emp e left join emp_expr ee on e.id = ee.emp_id
where e.id = #{id}
</select>
📚一个员工可能对应多条工作经验记录,所以就不能用mybatis的自动结果封装了。
📚区分:1、id和result
2、column和property
3、collection property:封装集合属性
⭐️在这种一对多的查询中,我们要想成功的封装的结果,需要手动的基于 <resultMap> 来进行封装结果。
Mybatis中封装查询结果,什么时候用 resultType,什么时候用resultMap ?
- 如果查询返回的字段名与实体的属性名可以直接对应上,用resultType 。
- 如果查询返回的字段名与实体的属性名对应不上,或实体属性比较复杂,可以通过resultMap手动封装 。
2.1.4 Apifox测试
重新启动服务,基于Apifox进行接口测试。

✋

2.1.5 前后端联调测试
打开浏览器,进行前后端联调测试。

✋

2.2 修改员工
查询回显之后,就可以在页面上修改员工的信息了。

- 当用户修改完数据之后,点击保存按钮,就需要将数据提交到服务端,然后服务端需要将修改后的数据更新到数据库中 。
- 而此次更新的时候,既需要更新员工的基本信息; 又需要更新员工的工作经历信息 。
2.2.1 接口文档
参照资料中提供的接口文档,查看 员工管理 -> 修改员工 接口的描述。
2.2.2 实现思路

📚修改员工经历:先删除再增加
2.2.3 代码实现
1). EmpController 增加 update 方法接收请求参数,响应数据
java
/**
* 更新员工信息
*/
@PutMapping
public Result update(@RequestBody Emp emp){
log.info("修改员工信息, {}", emp);
empService.update(emp);
return Result.success();
}
2). EmpService 接口增加 update 方法
java
/**
* 更新员工信息
* @param emp
*/
void update(Emp emp);
3). EmpServiceImpl实现类实现 update 方法
java
@Transactional
@Override
public void update(Emp emp) {
//1. 根据ID更新员工基本信息
emp.setUpdateTime(LocalDateTime.now());
empMapper.updateById(emp);
//2. 根据员工ID删除员工的工作经历信息 【删除老的】
empExprMapper.deleteByEmpIds(Arrays.asList(emp.getId()));
//3. 新增员工的工作经历数据 【新增新的】
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if(!CollectionUtils.isEmpty(exprList)){
exprList.forEach(empExpr -> empExpr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
4). EmpMapper 接口中增加 updateById 方法
java
/**
* 更新员工基本信息
*/
void updateById(Emp emp);
5). EmpMapper.xml 配置文件中定义对应的SQL语句,基于动态SQL更新员工信息
sql
<!--根据ID更新员工信息-->
<update id="updateById">
update emp
<set>
<if test="username != null and username != ''">username = #{username},</if>
<if test="password != null and password != ''">password = #{password},</if>
<if test="name != null and name != ''">name = #{name},</if>
<if test="gender != null">gender = #{gender},</if>
<if test="phone != null and phone != ''">phone = #{phone},</if>
<if test="job != null">job = #{job},</if>
<if test="salary != null">salary = #{salary},</if>
<if test="image != null and image != ''">image = #{image},</if>
<if test="entryDate != null">entry_date = #{entryDate},</if>
<if test="deptId != null">dept_id = #{deptId},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
</set>
where id = #{id}
</update>
2.2.4 Apifox测试
重新启动服务,打开 Apifox 进行接口测试。

✋
json
{
"id": 2,
"username": "zhangwuji",
"password": "123456",
"name": "张无忌",
"gender": 1,
"image": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-53B.jpg",
"job": 2,
"salary": 8000,
"entryDate": "2015-01-01",
"deptId": 2,
"createTime": "2022-09-01T23:06:30",
"updateTime": "2022-09-02T00:29:04",
"exprList": [
{
"id": 1,
"begin": "2012-07-01",
"end": "2015-06-20"
"company": "中软国际股份有限公司"
"job": "java开发",
"empId": 2
},
{
"id": 2,
"begin": "2015-07-01",
"end": "2019-03-03"
"company": "百度科技股份有限公司"
"job": "java开发",
"empId": 2
},
{
"id": 3,
"begin": "2019-3-15",
"end": "2023-03-01"
"company": "阿里巴巴科技股份有限公司"
"job": "架构师",
"empId": 2
}
]
}

2.2.5 前后端联调

点击保存之后,查看更新后的数据。

✋
保存之后正常显示:

3. 异常处理
3.1 问题分析
当我们在修改部门数据的时候,如果输入一个在数据库表中已经存在的手机号,点击保存按钮之后,前端提示了错误信息,但是返回的结果并不是统一的响应结果,而是框架默认返回的错误结果 。

状态码为500,表示服务器端异常,我们打开idea,来看一下,服务器端出了什么问题。

上述错误信息的含义是,emp员工表的phone手机号字段的值重复了,因为在数据库表emp中已经有了13309090027这个手机号了,我们之前设计这张表时,为phone字段建议了唯一约束,所以该字段的值是不能重复的。
而当我们再将该员工的手机号也设置为 13309090027,就违反了唯一约束 ,此时就会报错。
我们来看一下出现异常之后,最终服务端给前端响应回来的数据长什么样。

响应回来的数据是一个JSON格式的数据。但这种JSON格式的数据还是我们开发规范当中所提到的统一响应结果Result吗?显然并不是。由于返回的数据不符合开发规范,所以前端并不能解析出响应的JSON数据 。
接下来我们需要思考的是出现异常之后,当前案例项目的异常是怎么处理的? 答案:没有做任何的异常处理
当我们没有做任何的异常处理时,我们三层架构处理异常的方案:
- Mapper接口在操作数据库的时候出错了,此时异常会往上抛(谁调用Mapper就抛给谁),会抛给service。
- service 中也存在异常了,会抛给controller。
- 而在controller当中,我们也没有做任何的异常处理,所以最终异常会再往上抛。最终抛给框架之后,框架就会返回一个JSON格式的数据,里面封装的就是错误的信息,但是框架返回的JSON格式的数据并不符合我们的开发规范。
3.2 解决方案
那么在三层构架项目中,出现了异常,该如何处理?
- 方案一:在所有Controller的所有方法中进行try...catch处理
缺点:代码臃肿(不推荐)

- 方案二:全局异常处理器
好处:简单、优雅(推荐)

3.3 全局异常处理器
我们该怎么样定义全局异常处理器?
- 定义全局异常处理器非常简单,就是定义一个类,在类上加上一个注解
@RestControllerAdvice,加上这个注解就代表我们定义了一个全局异常处理器。 - 在全局异常处理器当中,需要
定义一个方法来捕获异常,在这个方法上需要加上注解@ExceptionHandler。通过@ExceptionHandler注解当中的value属性来指定我们要捕获的是哪一类型的异常。
java
@RestControllerAdvice
public class GlobalExceptionHandler {
//处理异常
@ExceptionHandler
public Result ex(Exception e){//方法形参中指定能够处理的异常类型
e.printStackTrace();//打印堆栈中的异常信息
//捕获到异常之后,响应一个标准的Result
return Result.error("对不起,操作失败,请联系管理员");
}
}
@RestControllerAdvice = @ControllerAdvice + @ResponseBody
处理异常的方法返回值会转换为json后再响应给前端
重新启动SpringBoot服务,打开浏览器,再来测试一下 修改员工 这个操作,我们依然设置已存在的 13309090027这个手机号:

此时,我们可以看到,出现异常之后,异常已经被全局异常处理器捕获了。然后返回的错误信息,被前端程序正常解析,然后提示出了对应的错误提示信息。
以上就是全局异常处理器的使用,主要涉及到两个注解:
@RestControllerAdvice//表示当前类为全局异常处理器@ExceptionHandler//指定可以捕获哪种类型的异常进行处理
📚出错,但不知道哪里出错,所以还要具体一点,
再声明一个方法捕获具体一点的异常,
最终会按照继承关系从下往上匹配,

java
@ExceptionHandler
public Result handleDuplicateKeyException(DuplicateKeyException e){
log.error("程序出错啦~",e);
String message = e.getMessage();
int i = message.indexOf("Duplicate entry");
String errMsg = message.substring(i);
String[] arr = errMsg.split( " ");
return Result.error(arr[2]+"已存在");
}
代码解释:
java
// 全局异常处理器注解:捕获Controller抛出的 DuplicateKeyException 数据库唯一键冲突异常
@ExceptionHandler
public Result handleDuplicateKeyException(DuplicateKeyException e){
// 打印错误堆栈日志,记录异常,方便后端排查问题
log.error("程序出错啦~",e);
// 获取异常完整错误信息,MySQL报唯一索引重复的原始字符串
String message = e.getMessage();
// 找到字符串中 "Duplicate entry" 起始下标
int i = message.indexOf("Duplicate entry");
// 截取从 Duplicate entry 开始往后的全部文本
String errMsg = message.substring(i);
// 按空格切割字符串
String[] arr = errMsg.split( " ");
// arr[2] 就是重复的值,组装返回给前端:xxx已存在
return Result.error(arr[2]+"已存在");
}
✋

☀️

4. 员工信息统计
员工管理的增删改查功能我们已开发完成,接下来,我们再来完成员工信息统计的接口开发。 对于这些图形报表的开发,其实呢,都是基于现成的一些图形报表的组件开发的,比如:Echarts、HighCharts等。
而报表的制作,主要是前端人员开发,引入对应的组件(比如:ECharts)即可。 服务端开发人员仅为其提供数据即可。
官网:https://echarts.apache.org/zh/index.html

4.1 职位统计
4.1.1 需求

对于这类的图形报表,服务端要做的,就是为其提供数据即可。 我们可以通过官方的示例,看到提供的数据其实就是X轴展示的信息,和对应的数据。

📚有现成的组件可以使用,这里使用的是百度的组件
4.1.2 接口描述
参照资料中提供的接口文档,查看 数据统计 -> 员工职位统计 接口的描述。
4.1.3 代码实现
1). 定义封装结果对象JobOption
在 com.itheima.pojo 包中定义实体类 JobOption
java
package com.itheima.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class JobOption {
private List jobList;
private List dataList;
}
1). 定义ReportController,并添加方法。
java
@Slf4j
@RequestMapping("/report")
@RestController
public class ReportController {
@Autowired
private ReportService reportService;
/**
* 统计各个职位的员工人数
*/
@GetMapping("/empJobData")
public Result getEmpJobData(){
log.info("统计各个职位的员工人数");
JobOption jobOption = reportService.getEmpJobData();
return Result.success(jobOption);
}
}
2). 定义ReportService接口,并添加接口方法。
java
public interface ReportService {
/**
* 统计各个职位的员工人数
* @return
*/
JobOption getEmpJobData();
}
3). 定义ReportServiceImpl实现类,并实现方法
java
@Service
public class ReportServiceImpl implements ReportService {
@Autowired
private EmpMapper empMapper;
@Override
public JobOption getEmpJobData() {
List<Map<String,Object>> list = empMapper.countEmpJobData();
List<Object> jobList = list.stream().map(dataMap -> dataMap.get("pos")).toList();
List<Object> dataList = list.stream().map(dataMap -> dataMap.get("total")).toList();
return new JobOption(jobList, dataList);
}
}
4). 定义EmpMapper 接口
统计的是员工的信息,所以需要操作的是员工表。 所以代码我们就写在 EmpMapper 接口中即可。
java
/**
* 统计各个职位的员工人数
*/
@MapKey("pos")
List<Map<String,Object>> countEmpJobData();
✅如果查询的记录往
Map中封装,可以通过@MapKey注解指定返回的map中的唯一标识是那个字段。【也可以不指定】
5). 定义EmpMapper.xml
sql
<!-- 统计各个职位的员工人数 -->
<select id="countEmpJobData" resultType="java.util.Map">
select
(case job when 1 then '班主任'
when 2 then '讲师'
when 3 then '学工主管'
when 4 then '教研主管'
when 5 then '咨询师'
else '其他' end) pos,
count(*) total
from emp group by job
order by total
</select>
case流程控制函数:
语法一:case when cond1 then res1 when cond2 then res2 else res end ;
含义:如果 cond1 成立, 取 res1。 如果 cond2 成立,取 res2。 如果前面的条件都不成立,则取 res。
语法二(仅适用于等值匹配):case expr when val1 then res1 when val2 then res2 else res end ;
含义:如果 expr 的值为 val1 , 取 res1。 如果 expr 的值为 val2 ,取 res2。 如果前面的条件都不成立,则取 res。
✋
🐛报错:Usage of API documented as @since 16+Usage of API documented as @since 16+
项目,模块的jdk都设置成了17,还是报错,参考:https://blog.csdn.net/jdjdndhj/article/details/70256789
把pom.xml文件的java版本改了过来,


报错没有了。


☀️

4.2 性别统计
4.2.1 需求

对于这类的图形报表,服务端要做的,就是为其提供数据即可。 我们可以通过官方的示例,看到提供的数据就是一个json格式的数据。

4.2.2 接口描述
参照资料中提供的接口文档,查看 数据统计 -> 员工性别统计 接口的描述。


4.2.3 代码实现
1). 在ReportController,添加方法。
java
/**
* 统计员工性别信息
*/
@GetMapping("/empGenderData")
public Result getEmpGenderData(){
log.info("统计员工性别信息");
List<Map> genderList = reportService.getEmpGenderData();
return Result.success(genderList);
}
2). 在ReportService接口,添加接口方法。
java
/**
* 统计员工性别信息
*/
List<Map> getEmpGenderData();
3). 在ReportServiceImpl实现类,实现方法
java
@Override
public List<Map> getEmpGenderData() {
return empMapper.countEmpGenderData();
}
4). 定义EmpMapper 接口
统计的是员工的信息,所以需要操作的是员工表。 所以代码我们就写在 EmpMapper 接口中即可。
java
/**
* 统计员工性别信息
*/
@MapKey("name")
List<Map> countEmpGenderData();
5). 定义EmpMapper.xml
sql
<!-- 统计员工的性别信息 -->
<select id="countEmpGenderData" resultType="java.util.Map">
select
if(gender = 1, '男', '女') as name,
count(*) as value
from emp group by gender ;
</select>
if函数语法: if(条件, 条件为true取值, 条件为false取值)ifnull函数语法:ifnull(expr, val1) 如果expr不为null,取自身,否则取val1
✋



















