目录
1.新增员工
1.1 需求
在添加员工信息时,录入的信息包括两个部分,一个部分是员工的基本信息;另一个部分是员工的工作经历信息,这两个部分的信息最终在录入完成后,点击“保存”按钮后都会提交到服务器。
添加员工信息最终是要员工的基本信息保存在emp表中,而员工的工作经历信息,要保存在员工工作经历信息表emp_expr中的。
1.2 接口文档
我们参照接口文档来开发新增员工功能
基本信息
请求路径:/emps 请求方式:POST 接口描述:该接口用于添加员工的信息
请求参数
参数格式:application/json
参数说明:
名称 类型 是否必须 备注 username string 必须 用户名 name string 必须 姓名 gender number 必须 性别, 说明: 1 男, 2 女 image string 非必须 图像 deptId number 非必须 部门id entryDate string 非必须 入职日期 job number 非必须 职位, 说明: 1 班主任,2 讲师, 3 学工主管, 4 教研主管, 5 咨询师 salary number 非必须 薪资 exprList object[] 非必须 工作经历列表 |- company string 非必须 所在公司 |- job string 非必须 职位 |- begin string 非必须 开始时间 |- end string 非必须 结束时间
请求数据样例:
{
"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"
}
]
}
- 响应数据
参数格式:application/json
参数说明:
参数名 | 类型 | 是否必须 | 备注 |
---|---|---|---|
code | number | 必须 | 响应码,1 代表成功,0 代表失败 |
msg | string | 非必须 | 提示信息 |
data | object | 非必须 | 返回的数据 |
响应数据样例:
{
"code":1,
"msg":"success",
"data":null
}
1.3 思路分析
新增员工的具体的流程:
接口文档规定:
请求路径:/emps
请求方式:POST
请求参数:Json格式数据
响应数据:Json格式数据
问题1:如何限定请求方式是POST?
在Controller实现新增员工的代码上方添加@PostMapping
问题2:怎么在controller中接收json格式的请求数据?
@RequestBody //把前端传递的json数据填充到实体类中
1.4 功能开发
1.4.1 准备工作
准备的EmpExprMapper
接口及映射配置文件EmpExprMapper.xml
,并准备实体类接收前端传递的json格式的请求参数。
1). EmpExprMapper接口
@Mapper
public interface EmpExprMapper {
}
2). EmpExprMapper.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
来封装工作经历数据。 最终完整代码如下:
@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
@PostMapping("/emps")
public Result save(@RequestBody Emp emp){
log.info("新增员工:{}",emp);
empService.save(emp);
return Result.success();
}
2). EmpServiceImpl
@Override
public void save(Emp emp) {
//1.调用mapper,保存员工的基本信息到emp表
//1.1 补充缺失的字段
emp.setPassword("123456");
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//1.2 调用mapper
empMapper.insert(emp);
Integer id = emp.getId();
log.info("id={}",id);
//2.调用mapper,保存员工的工作经历信息到emp_expr表
}
3). EmpMapper
/**
*新增员工基本信息
* @param emp
*/
@Options(useGeneratedKeys = true,keyProperty = "id")//插入成功后,将自增的id值返回给emp对象
//keyProperty指定将自增的id值返回给emp对象的哪个属性
@Insert("insert into emp values (null,#{username},#{password},#{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 分析
添加员工工作经历时,有可能是一条,也有可能是多条,所这里需要用到xml的动态SQL
1.4.3.2 实现
1). EmpServiceImpl
@Override
public void save(Emp emp) {
//1.调用mapper,保存员工的基本信息到emp表
//1.1 补充缺失的字段
emp.setPassword("123456");
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//1.2 调用mapper
empMapper.insert(emp);
Integer id = emp.getId();
log.info("id={}",id);
//2.调用mapper,保存员工的工作经历信息到emp_expr表
List<EmpExpr> exprList = emp.getExprList();
//传入的json数据并没有id,此时直接插入无法辨认数据所属id,所以需要先关联员工id,再插入
//2.1 关联员工id
if (!CollectionUtils.isEmpty(exprList)) {
exprList.forEach((expr)->{
expr.setEmpId(id);
});
//2.2 调用mapper方法,批量保存工作经历数据
empExprMapper.insertBatch(exprList);
}
}
在Service层中调用emp.getExprList()后,此时往数据库中添加数据并没有id,因为前端传入的json格式数据中并没有id,所以我们还要在第一步保存员工基本信息时提取出此时添加员工的id(用固定注解),然后在第二步中关联员工的id,最后再调用EmpExprMapper中的inertBatch才能批量保存工作经历数据。
2). EmpExprMapper
@Mapper
public interface EmpExprMapper {
/**
* 批量插入员工经历数据
* @param exprList
*/
//继续xml开发-动态SQL--<foreach>
void insertBatch(List<EmpExpr> exprList);
}
3). EmpExprMapper.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 前后端联调
点击保存之后,可以看到列表中已经展示出了这条数据。
2.事务管理
2.1 问题分析
目前我们实现的新增员工功能中,操作了两次数据库,执行了两次 insert 操作。
第一次:保存员工的基本信息到
emp
表中。第二次:保存员工的工作经历信息到
emp_expr
表中。
如果说,保存员工的基本信息成功了,而保存员工的工作经历信息出错了,会发生什么现象呢?那接下来,我们来做一个测试 。 我们可以在代码中,人为在保存员工的service层的save方法中,构造一个错误:
这样输入员工信息之后点击保存就会报错,我们看到在保存了员工的基本信息之后,系统出现了异常:
我们再打开数据库,看看表结构中的数据是否正常。
1). emp
员工表中是有 Jerry
这条数据的。
2). emp_expr
表中没有改员工的工作经历信息。
最终我们发现,程序出现了异常,员工表emp数据保存成功了,保存工作经历信息失败了,就会造成数据库数据的不完整、不一致,那么是否允许这种情况发生呢?
- 不允许
- 因为这属于一个业务操作,如果保存员工信息成功了,保存工作经历信息失败了,就会造成数据库数据的不完整、不一致。
解决这个问题需要用到数据库中的事务来解决。
2.2 介绍
概念: 事务是一组操作的集合,它是一个不可分割的工作单位。事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作 要么同时成功,要么同时失败。
就拿添加员工的这个业务为例,在这个业务操作中,包含了两个操作,那这两个操作是一个不可分割的工作单位。
默认MySQL的事务是自动提交的,也就是说,当执行一条DML语句,MySQL会立即隐式的提交事务。
2.3 操作
事务控制主要三步操作:开启事务、提交事务/回滚事务。
需要在这组操作执行之前,先开启事务 (
start transaction; / begin;
)。所有操作如果全部都执行成功,则提交事务 (
commit;
)。如果这组操作中,有任何一个操作执行失败,都应该回滚事务 (
rollback
)。
-- 开启事务
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事务管理
在上述实现的新增员工的功能中,一旦在保存员工基本信息后出现异常。 我们就会发现,员工信息保存成功,但是工作经历信息保存失败,造成了数据的不完整不一致。
产生原因:
先执行新增员工的操作,这步执行完毕,就已经往员工表
emp
插入了数据。执行 1/0 操作,抛出异常
抛出异常之前,下面所有的代码都不会执行了,批量保存工作经历信息,这个操作也不会执行。
此时就出现问题了,员工基本信息保存了,员工的工作经历信息未保存,业务操作前后数据不一致。
此时,我们就需要在新增员工功能中添加事务。
在运行方法之前,开启事务,如果方法运行成功,就提交事务,如果方法执行的过程当中出现了异常,就回滚事务。
2.4.1 Transactional注解
@Transactional作用:就是在当前这个方法执行开始之前来开启事务,方法执行完毕之后提交事务。如果在这个方法执行的过程当中出现了异常,就会进行事务的回滚操作。
@Transactional注解:我们一般会在业务层当中来控制事务,因为在业务层当中,一个业务功能可能会包含多个数据访问的操作。在业务层来控制事务,我们就可以将多个数据访问操作控制在一个事务范围内
@Transactional注解书写位置:
方法
当前方法交给spring进行事务管理
类
当前类中所有的方法都交由spring进行事务管理 (推荐)
接口
接口下所有的实现类当中所有的方法都交给spring 进行事务管理
接下来,我们就可以在业务方法save上加上 @Transactional 来控制事务 。
@Transactional //开启事务
@Override
public void save(Emp emp) {
//1.调用mapper,保存员工的基本信息到emp表
//1.1 补充缺失的字段
emp.setPassword("123456");
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//1.2 调用mapper
empMapper.insert(emp);
Integer id = emp.getId();
log.info("id={}",id);
//模拟异常
//int i = 1/0;
//2.调用mapper,保存员工的工作经历信息到emp_expr表
List<EmpExpr> exprList = emp.getExprList();
//传入的json数据并没有id,此时直接插入无法辨认数据所属id,所以需要先关联员工id,再插入
//2.1 关联员工id
if (!CollectionUtils.isEmpty(exprList)) {
exprList.forEach((expr)->{
expr.setEmpId(id);
});
//2.2 调用mapper方法,批量保存工作经历数据
empExprMapper.insertBatch(exprList);
}
}
说明:可以在application.properties
配置文件中开启事务管理日志,这样就可以在控制看到和事务相关的日志信息了
#spring事务管理日志
logging.level.org.springframework.jdbc.support.JdbcTransactionManager = debug
在业务层Service中添加了事务管理注解@Transactional后,进行模拟异常,重启SpringBoot服务,使用Apifox测试后,由于服务端程序引发了异常,所以事务进行回滚。
打开数据库,我们会看到 emp
表 与 emp_expr
表中都没有对应的数据信息,保证了数据的一致性、完整性。
2.4.2 事务进阶
前面我们通过spring事务管理注解@Transactional已经控制了业务层方法的事务,事务管理注解还有更多的使用细节和注解:
异常回滚的属性:
rollbackFor
事务传播行为:
propagation
我们先来学习下rollbackFor属性。
2.4.2.1 rollbackFor
@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异常(编译时异常)
@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进行测试,请求添加员工接口,我们会看到抛出异常了
我们会看到数据库的事务居然提交了,并没有进行回滚。
通过以上测试可以得出一个结论:默认情况下,只有出现RuntimeException(运行时异常)才会回滚事务。
假如我们想让所有的异常都回滚,需要来配置@Transactional注解当中的rollbackFor属性,通过rollbackFor这个属性可以指定出现何种异常类型回滚事务。
@Transactional(rollbackFor = Exception.class)
添加了@Transactional的详细注解之后,我们看到异常又进行了事务回滚
结论:
在Spring的事务管理中,默认只有运行时异常 RuntimeException才会回滚。
如果还需要回滚指定类型的异常,可以通过rollbackFor属性来指定。
2.4.2.2 propagation
@Transactional注解当中的第二个属性propagation是用来配置事务的传播行为的。
什么叫事务的传播行为呢?
就是当一个事务被另一个事务方法调用时,这个事务方法应该如何进行事务控制。
例如:两个事务方法,一个A方法,一个B方法。在这两个方法上都添加了@Transactional注解,就代表这两个方法都具有事务,而在A方法当中又去调用了B方法。
事务的传播行为指的就是,在A方法运行时会开启一个事务,但是A方法中又调用了一个B方法,B方法自身也具有事务,那么B方法在运行的时候,到底是加入到A方法的事务当中来,还是在B方法运行的时候新建一个事务?这就涉及到了事务的传播行为。
我们要想控制事务的传播行为,在@Transactional注解的后面指定一个属性propagation,通过propagation属性来指定传播行为。
属性值 | 含义 |
---|---|
REQUIRED | 【默认值】需要事务,有则加入,无则创建新事务 |
REQUIRES_NEW | 需要新事务,无论有无,总是创建新事务 |
SUPPORTS | 支持事务,有则加入,无则在无事务状态中运行 |
NOT_SUPPORTED | 不支持事务,在无事务状态下运行,如果当前存在已有事务,则挂起当前事务 |
MANDATORY | 必须有事务,否则抛异常 |
NEVER | 必须没事务,否则抛异常 |
… |
对于这些事务传播行为,我们只需要关注以下两个就可以了:
REQUIRED(默认值)
REQUIRES_NEW
案例:
需求:在新增员工信息时,无论是成功还是失败,都要记录操作日志。
步骤:1.准备日志表emp_log、实体类EmpLog、Mapper接口EmpLogMapper
2.在新增员工时记录日志(无论成功还是失败)
准备工作:
1). 创建数据库表 emp_log
日志表:
-- 创建员工日志表
create table emp_log(
id int unsigned primary key auto_increment comment 'ID, 主键',
operate_time datetime comment '操作时间',
info varchar(2000) comment '日志信息'
) comment '员工日志表';
2).创建实体类EmpLog
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmpLog {
private Integer id; //ID
private LocalDateTime operateTime; //操作时间
private String info; //详细信息
}
3).创建Mapper接口:EmpLogMapper
@Mapper
public interface EmpLogMapper {
@Insert("insert into emp_log (operate_time, info) values (#{operateTime}, #{info})")
public void insert(EmpLog empLog);
}
4). 业务接口:EmpLogService
public interface EmpLogService{
public void insertLog(EmpLog empLog);
}
5). 业务实现类:EmpLogServiceImpl
@Service
public class EmpLogServiceImpl implements EmpLogService {
@Autowired
private EmpLogMapper empLogMapper;
/**
* 插入操作日志
* @param empLog
*/
//@Transactional(propagation = Propagation.REQUIRED) //开启事务,REQUIED默认值,有则加入,无则创建
@Transactional(propagation = Propagation.REQUIRES_NEW) //开启事务,开启新事务
@Override
public void insertLog(EmpLog empLog){
empLogMapper.insert(empLog);
}
}
代码实现:
业务实现层:
@Slf4j
@Service
public class EmpServiceImpl implements EmpService {
@Autowired
private EmpMapper empMapper;
@Autowired
private EmpExprMapper empExprMapper;
@Autowired
private EmpLogService empLogService;
/**
* 分页查询
* @param page 当前页码
* @param pageSize 每页显示数量
* @return
*/
@Transactional(rollbackFor = Exception.class) //开启事务
@Override
public void save(Emp emp) {
try {
//1.调用mapper,保存员工的基本信息到emp表
//1.1 补充缺失的字段
emp.setPassword("123456");
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//1.2 调用mapper
empMapper.insert(emp);
Integer id = emp.getId();
log.info("id={}",id);
//模拟异常
int i = 1/0;
//2.调用mapper,保存员工的工作经历信息到emp_expr表
List<EmpExpr> exprList = emp.getExprList();
//传入的json数据并没有id,此时直接插入无法辨认数据所属id,所以需要先关联员工id,再插入
//2.1 关联员工id
if (!CollectionUtils.isEmpty(exprList)) {
exprList.forEach((expr)->{
expr.setEmpId(id);
});
//2.2 调用mapper方法,批量保存工作经历数据
empExprMapper.insertBatch(exprList);
}
} finally {
//无论新增员工成功与否,都需要添加操作日志
//创建日志对象,插入数据
EmpLog empLog = new EmpLog();
empLog.setOperateTime(LocalDateTime.now());
empLog.setInfo("插入员工信息:" + emp);
empLogService.insertLog(empLog);
}
}
}
save方法中我们需要实现无论新增员工成功与否都要记录日志,所以save方法中必须要用到try{ }finally{ }来实现。
测试:
重新启动SpringBoot服务,测试新增员工操作 。我们可以看到控制台中输出的日志:
执行了插入员工数据的操作
执行了插入日志操作
程序发生Exception异常
执行事务回滚(保存员工数据、插入操作日志 因为在一个事务范围内,两个操作都会被回滚)
然后在 emp_log
表中没有记录日志数据。
原因分析:
接下来我们就需要来分析一下具体是什么原因导致的日志没有成功的记录。
在执行
save
方法时开启了一个事务当执行
empLogService.insertLog
操作时,insertLog
设置的事务传播行是默认值REQUIRED,表示有事务就加入,没有则新建事务此时:
save
和insertLog
操作使用了同一个事务,同一个事务中的多个操作,要么同时成功,要么同时失败,所以当异常发生时进行事务回滚,就会回滚save
和insertLog
操作
解决方案:
在EmpLogServiceImpl
类中insertLog方法上,添加 @Transactional(propagation = Propagation.REQUIRES_NEW)
Propagation.REQUIRES_NEW :不论是否有事务,都创建新事务 ,运行在一个独立的事务中。
@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 简介
文件上传,是指将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载的过程。
<form action="/upload" method="post" enctype="multipart/form-data">
姓名: <input type="text" name="username"><br>
年龄: <input type="text" name="age"><br>
头像: <input type="file" name="file"><br>
<input type="submit" value="提交">
</form>
在我们的新增员工案例中,点击上传员工的头像,选择了某个图片文件之后,这个文件就会上传到服务器,从而完成文件上传的操作。
想要完成文件上传这个功能需要涉及到两个部分:
前端程序
服务端程序
我们先来看看在前端程序中要完成哪些代码:
<form action="/upload" method="post" enctype="multipart/form-data">
姓名: <input type="text" name="username"><br>
年龄: <input type="text" name="age"><br>
头像: <input type="file" name="file"><br>
<input type="submit" value="提交">
</form>
上传文件的原始form表单,要求表单必须具备一下三点(上传文件页面三要素):
表单必须有file域,用于选择要上传的文件
<input type="file" name="file"/>
表单提交方式必须为POST
通常上传的文件会比较大,所以需要使用 POST 提交方式
表单的编码类型enctype必须要设置为:multipart/form-data
普通默认的编码格式是不适合传输大型的二进制数据的,所以在文件上传时,表单的编码格式必须设置为multipart/form-data
知道了前端程序中需要设置上传文件页面三要素,那我们的后端程序又是如何实现的呢?
- 首先在服务器定义一个controller,用来进行文件上传,然后再controller当中定义一个方法来处理/upload请求
- 在定义的方法中接收提交过来的数据(方法中的形参名和请求参数的名字保持一致)
用户名:String name
年龄: Integer age
文件: MultipartFile file
Spring中提供了一个API:MultipartFile,使用这个API就可以来接收到上传的文件
问题:如果表单项的名字和方法中形参名不一致,该怎么办?
public Result upload(String username,
Integer age,
MultipartFile image) //image形参名和请求参数名file不一致
解决:使用@RequestParam注解进行参数绑定
public Result upload(String username,
Integer age,
@RequestParam("file") MultipartFile image)
UploadController代码:
@Slf4j
@RestController
public class UploadController {
/**
* 文件上传--本地存储
* @param username
* @param age
* @param file
* @return
*/
@RequestMapping("/upload")
public Result upload(String username, Integer age, MultipartFile file) throws Exception {
log.info("参数:{},{},{}",username,age,file);
//1.将前端上传的文件保存到服务器的磁盘中
//file.transferTo(new File("D:/001.jpg"));
return Result.success("001.jpg");
}
}
后端程序编写完成之后,打个断点,以debug方式启动SpringBoot项目
打开浏览器输入:http://localhost:8080/upload.html , 录入数据并提交
通过后端程序控制台可以看到,上传的文件是存放在一个临时目录
打开临时目录可以看到以下内容:
表单提交的三项数据(姓名、年龄、文件),分别存储在不同的临时文件中:
当我们程序运行完毕之后,这个临时文件会自动删除。
所以,我们如果想要实现文件上传,需要将这个临时文件,要转存到我们的磁盘目录中。
3.2 本地存储
前面我们分析了文件上传功能前端和后端的基础代码实现,文件上传时在服务端会产生一个临时文件,请求响应完成之后,这个临时文件会被自动删除,并没有进行保存。下面我们就需要完成将上传的文件保存到服务器的本地磁盘上。
代码实现:
在服务器本地磁盘上创建images目录,用来存储上传的文件(例:E盘创建images目录)
使用 MultipartFile 类提供的API方法,把临时文件转存到本地磁盘目录下
MultipartFile 常见方法:
String getOriginalFilename(); //获取原始文件名
void transferTo(File dest); //将接收的文件转存到磁盘文件中
long getSize(); //获取文件的大小,单位:字节
byte[] getBytes(); //获取文件内容的字节数组
InputStream getInputStream(); //获取接收到的文件内容的输入流
@Slf4j
@RestController
public class UploadController {
/**
* 文件上传--本地存储
* @param username
* @param age
* @param file
* @return
*/
@RequestMapping("/upload")
public Result upload(String username, Integer age, MultipartFile file) throws Exception {
log.info("参数:{},{},{}",username,age,file);
//1.获取原始文件名
String originalFilename = file.getOriginalFilename(); //eg:001.jpg
//2.通过UUID生成随机字符串
String newFileName = UUID.randomUUID() +
originalFilename.substring(originalFilename.lastIndexOf("."));//.jpg
//3.将前端上传的文件保存到服务器的磁盘中
file.transferTo(new File("D:/" + newFileName));
return Result.success(newFileName);
}
}
文件名相同时会覆盖,如何解决?
UUID.randomUUID()可以为下载的图片生成随机的名称(不会重复),之后用substring分割文件全名最后一个点后面的文件后缀。
在解决了文件名唯一性的问题之后,我们再次上传一个比较大的文件(超过1M)时发现,后端程序报错:
报错原因是在SpringBoot中,文件上传时默认单个文件最大大小为1M,那么如果需要上传大文件,可以在Spring的配置文件中application.properties进行如下配置:
#单个文件最大可上传大小,默认时1MB
spring.servlet.multipart.max-file-size=10MB
#单词请求所有文件最大可上传大小,默认时10MB
spring.servlet.multipart.max-request-size=100MB
到时此,我们文件上传的本地存储方式已完成了。但是这种本地存储方式还存在问题:
如果直接存储在服务器的磁盘目录中,存在以下缺点:
不安全:磁盘如果损坏,所有的文件就会丢失
容量有限:如果存储大量的图片,磁盘空间有限(磁盘不可能无限制扩容)
无法直接访问
为了解决上述问题呢,通常有两种解决方案:
自己搭建存储服务器,如:fastDFS 、MinIO
使用现成的云服务,如:阿里云,腾讯云,华为云
3.3 阿里云OSS
3.3.1 准备
阿里云是阿里巴巴集团旗下全球领先的云计算公司,也是国内最大的云服务提供商 。
云服务指的就是通过互联网对外提供的各种各样的服务,比如像:语音服务、短信服务、邮件服务、视频直播服务、文字识别服务、对象存储服务等等。
当我们在项目开发时需要用到某个或某些服务,就不需要自己来开发了,可以直接使用阿里云提供好的这些现成服务就可以了。比如:在项目开发当中,我们要实现一个短信发送的功能,如果我们项目组自己实现,将会非常繁琐,因为你需要和各个运营商进行对接。而此时阿里云完成了和三大运营商对接,并对外提供了一个短信服务。我们项目组只需要调用阿里云提供的短信服务,就可以很方便的来发送短信了。这样就降低了我们项目的开发难度,同时也提高了项目的开发效率。(大白话:别人帮我们实现好了功能,我们只要调用即可)
云服务提供商给我们提供的软件服务通常是需要收取一部分费用的。
阿里云对象存储OSS(Object Storage Service),是一款海量、安全、低成本、高可靠的云存储服务。使用OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种文件。
使用阿里云OSS对象存储服务之后,我们的项目中如果涉及到上传文件这样的业务,在前端进行文件上传并请求到服务端时,在服务器本地磁盘当中就不需要再来存储文件了。我们直接将基金而受到的文件上传到OSS,由OSS帮我们存储和管理,同时还能保障文件安全性。
SDK:Software Development Kit 的缩写,软件开发工具包,包括辅助软件开发的依赖(jar包)、代码示例等,都可以叫做SDK。
简单说,sdk中包含了我们使用第三方云服务时所需要的依赖,以及一些示例代码。我们可以参照sdk所提供的示例代码就可以完成入门程序。
第三方服务使用的通用思路,我们做一个简单介绍之后,接下来我们就来介绍一下我们当前要使用的阿里云oss对象存储服务具体的使用步骤。
Bucket:存储空间是用户用于存储对象(Object,就是文件)的容器,所有的对象都必须隶属于某个存储空间。
3.3.2 入门
我们要进行阿里云账号注册 -> 开通云服务 -> 创建Bucket -> 创建AccessKey -> 配置文件
3.3.3 集成
3.3.3.1 介绍
阿里云oss对象存储服务的准备工作以及入门程序我们都已经完成了,接下来我们就需要在案例当中集成oss对象存储服务,来存储和管理案例上的图片
在新增员工的时候,上传员工的图像,而之所以需要上传员工的图像,是因为将来我们需要在系统页面当中访问并展示员工的图像。而要想完成这个操作,需要做两件事:
需要上传员工的图像,并把图像保存起来(存储到阿里云OSS)
访问员工图像(通过图像在阿里云OSS的存储地址访问图像)
OSS中的每一个文件都会分配一个访问的url,通过这个url就可以访问到存储在阿里云上的图片。所以需要把url返回给前端,这样前端就可以通过url获取到图像。
我们参照接口文档来开发文件上传功能:
基本信息
请求路径:/upload 请求方式:POST 接口描述:上传图片接口
请求参数
参数格式:multipart/form-data
参数说明:
参数名称 参数类型 是否必须 示例 备注 image file 是 响应数据
参数格式:application/json
参数说明:
参数名 类型 是否必须 备注 code number 必须 响应码,1 代表成功,0 代表失败 msg string 非必须 提示信息 data object 非必须 返回的数据,上传图片的访问路径 响应数据样例:
{
"code": 1,
"msg": "success",
"data": "https://web-framework.oss-cn-hangzhou.aliyuncs.com/2022-09-02-00-27-0400.jpg"
}
3.3.3.2 实现
1). 引入阿里云OSS上传文件工具类(由官方的示例代码改造而来)
/**
* 阿里云OSS操作工具类
*/
@Slf4j
public class AliyunOSSUtils {
/**
* 上传文件
* @param endpoint endpoint域名
* @param bucketName 存储空间的名字
* @param content 内容字节数组
*/
public static String upload(String endpoint, String bucketName, byte[] content, String extName) throws Exception {
// 从环境变量中获取访问凭证。运行本代码示例之前,请确保已设置环境变量OSS_ACCESS_KEY_ID和OSS_ACCESS_KEY_SECRET。
//EnvironmentVariableCredentialsProvider credentialsProvider = CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
String accessKeyId = "LTAI5tETUUBGUj*******";
String accessKeySecret = "1UbOiMgRElT3v4ZDAss8************";
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
// 填写Object完整路径,完整路径中不能包含Bucket名称,例如exampledir/exampleobject.txt。
String objectName = UUID.randomUUID() + extName;
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
try {
// 创建PutObjectRequest对象。
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, objectName, new ByteArrayInputStream(content));
// 创建PutObject请求。
PutObjectResult result = ossClient.putObject(putObjectRequest);
} catch (OSSException oe) {
log.error("Caught an OSSException, which means your request made it to OSS, but was rejected with an error response for some reason.");
log.error("Error Message:" + oe.getErrorMessage());
log.error("Error Code:" + oe.getErrorCode());
log.error("Request ID:" + oe.getRequestId());
log.error("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
log.error("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.");
log.error("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
}
}
2). 修改UploadController代码:
private final String bucketName = "java147-tlias***";//OSS上的桶空间名
private final String endpoint = "http://oss-cn-beijing.aliyuncs.com"; //OSS上桶对应的域名
/**
* 文件上传--阿里云OSS存储
* @param file
* @return
* @throws Exception
*/
@PostMapping ("/upload")
public Result upload(MultipartFile file) throws Exception {
//1.获取原始文件名,截取后缀
String originalFilename = file.getOriginalFilename(); //1.2.3.jpg
log.info("原始文件名:{}",originalFilename);
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
//2.调用阿里云OSS工具类,将文件上传到OSS
String url = AliyunOSSUtils.upload(endpoint, bucketName, file.getBytes(), extName);
//3.返回图片路径
return Result.success(url);
}
联调之后发现可以成功上传图片:
4.配置文件
员工管理的新增功能我们已开发完成,但在我们所开发的程序中还一些小问题,下面我们就来分析一下当前案例中存在的问题以及如何优化解决。
4.1 参数配置化
在我们之前编写的程序中进行文件上传时,需要指定两个参数:
endpoint //阿里云OSS域名
bucket //存储空间的名字
但是关于以上阿里云相关的配置信息,我们是直接写死在了java代码中了(硬编码),如果我们在做项目时每涉及到一个第三方技术就将其硬编码,那么在Java程序中会存在两个问题:
如果这些参数发生变化了,就必须在源程序代码中改动这些参数,然后需要重新进行代码的编译,将Java代码编译成class字节码文件再重新运行程序。(比较繁琐)
如果我们开发的是一个真实的企业级项目, Java类可能会有很多,如果将这些参数分散的定义在各个Java类当中,我们要修改一个参数值,我们就需要在众多的Java代码当中来定位到对应的位置,再来修改参数,修改完毕之后再重新编译再运行。(参数配置过于分散,是不方便集中的管理和维护)
为了解决以上分析的问题,我们可以将参数配置在配置文件中。如下:
#自定义的阿里云OSS配置信息
aliyun.oss.endpoint=https://oss-cn-beijing.aliyuncs.com
aliyun.oss.bucketName=java417-web
那么我们该如何在controller层读取这两个配置信息呢?
因为 application.properties
是springboot项目默认的配置文件,所以springboot程序在启动时会默认读取 application.properties
配置文件,而我们可以使用一个现成的注解:@Value
,获取配置文件中的数据。
@Value
注解通常用于外部配置的属性注入,具体用法为: @Value("${配置文件中的key}")
具体的加载流程如下:
@Value("${aliyun.oss.endpoint}")
private String endpoint;
@Value("${aliyun.oss.bucket}")
private String bucketName;
/**
* 文件上传--阿里云OSS存储
* @param file
* @return
* @throws Exception
*/
@PostMapping ("/upload")
public Result upload(MultipartFile file) throws Exception {
//1.获取原始文件名,截取后缀
String originalFilename = file.getOriginalFilename(); //1.2.3.jpg
log.info("原始文件名:{}",originalFilename);
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
//2.调用阿里云OSS工具类,将文件上传到OSS
String url = AliyunOSSUtils.upload(endpoint, bucketName, file.getBytes(), extName);
//3.返回图片路径
return Result.success(url);
}
具体的加载流程如下:
4.2 yml配置文件
前面我们一直使用的是springboot项目创建完毕后自带的application.properties进行属性的配置,其实在springboot项目中是支持多种配置方式的,除了支持properties配置文件以外,还支持另外一种类型的配置文件,就是我们接下来要讲解的yml格式的配置文件。
application.properties
server.port=8080
server.address=127.0.0.1
application.yml
server: port: 8080 address: 127.0.0.1
application.yaml
server: port: 8080 address: 127.0.0.1
yml 格式的配置文件,后缀名有两种:
yml (推荐)
yaml
常见的配置文件格式对比:
我们可以看到配置同样的数据信息,yml格式的数据有以下特点:
容易阅读
容易与脚本语言交互
以数据为核心,重数据轻格式
简单的了解过springboot所支持的配置文件,以及不同类型配置文件之间的优缺点之后,接下来我们就来了解下yml配置文件的基本语法:
了解完yml格式配置文件的基本语法之后,接下来我们再来看下yml文件中常见的数据格式。在这里我们主要介绍最为常见的两类:
定义对象或Map集合
定义数组、list或set集合
对象/Map集合
user:
name: zhangsan
age: 18
password: 123456
数组/List/Set集合
hobby:
- java
- game
- sport
熟悉完了yml文件的基本语法之后,我们要修改之前所用的配置文件改为application.yml配置方式
4.3 @ConfigurationProperties
讲解完了yml配置文件之后,最后再来介绍一个注解 @ConfigurationProperties
。在介绍注解之前,我们先来看一个场景,分析下代码当中可能存在的问题:
我们在配置文件中配置了阿里云OSS的两项参数之后,如果java程序中需要这四项参数数据,我们直接通过@Value注解来进行注入,这种方式本身并没有什么问题,但是如果需要注入的属性比较多(eg:需要20多个参数数据),我们写起来就会比较繁琐。
那么有没有一种方式可以简化这些配置参数的注入呢?答案是肯定有,在Spring中给我们提供了一种简化方式,可以直接将配置文件中配置项的值自动的注入到对象的属性中。
Spring提供的简化方式套路:
1). 需要创建一个实现类,且实体类中的属性名和配置文件当中key的名字必须要一致
比如:配置文件当中叫endpoints,实体类当中的属性也得叫endpoints,另外实体类当中的属性还需要提供 getter / setter方法
2). 用@Component将实体类交给Spring的IOC容器管理,成为IOC容器当中的bean对象
3). 在实体类上添加@ConfigurationProperties
注解,并通过perfect属性来指定配置参数项的前缀
在entity包下创建配置实体类:AliyunOSSProperties
/**
* 阿里云OSS配置类
*/
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliOSSProperties {
private String endpoint;
private String bucket;
}
UploadController:
// @Value("${aliyun.oss.endpoint}")
// private String endpoint;
// @Value("${aliyun.oss.bucket}")
// private String bucketName;
@Autowired
private AliOSSProperties aliOSSProperties;
/**
* 文件上传--阿里云OSS存储
* @param file
* @return
* @throws Exception
*/
@PostMapping ("/upload")
public Result upload(MultipartFile file) throws Exception {
//1.获取原始文件名,截取后缀
String originalFilename = file.getOriginalFilename(); //1.2.3.jpg
log.info("原始文件名:{}",originalFilename);
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
//2.调用阿里云OSS工具类,将文件上传到OSS
String url = AliyunOSSUtils.upload(aliOSSProperties.getEndpoint() , aliOSSProperties.getBucket(), file.getBytes(), extName);
//3.返回图片路径
return Result.success(url);
}
@ConfigurationProperties注解我们已经介绍完了,接下来我们就来区分一下@ConfigurationProperties注解以及我们前面所介绍的另外一个@Value注解:
相同点:都是用来注入外部配置的属性的。
不同点:
@Value注解只能一个一个的进行外部属性的注入。
@ConfigurationProperties可以批量的将外部的属性配置注入到bean对象的属性中。
如果要注入的属性非常的多,并且还想做到复用,就可以定义这么一个bean对象。通过 configuration properties 批量的将外部的属性配置直接注入到 bean 对象的属性当中。在其他的类当中,我要想获取到注入进来的属性,我直接注入 bean 对象,然后调用 get 方法,就可以获取到对应的属性值了。