🤖 Tlias 智能学习辅助系统_3
上回刚讲完员工管理模块中的
新增员工的功能的开发,虽然说看得见效果了,但是还有一个比较大的问题值得讨论 ...
就是员工表和工作经历表是
两次数据库交互,如果说录入了员工信息后因为某些程序异常没有录入到对应的工作信息,那么就会导致
数据库的不完整所以说我们需要保证这两件事是作为一个整体,要不都成功,要不都失败
所以接下来需要先学习
数据库的事务管理
九、事务管理
9.1 问题分析
目前我们实现的新增员工功能中,操作了两次数据库,执行了两次 insert 操作。
- 第一次:保存员工的基本信息到
emp表中。 - 第二次:保存员工的工作经历信息到
emp_expr表中。
如果说,保存员工的基本信息成功了,而保存员工的工作经历信息出错了,会发生什么现象呢?
那接下来,我们来做一个测试 。 我们可以在代码中,人为在保存员工的 service 层的 save 方法中,
构造一个错误:

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

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

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

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

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

这两个操作,要么同时失败,要么同时成功。
📌 默认MySQL的事务是自动提交的,也就是说,当执行一条DML语句,MySQL会立即隐式的提交事务。
9.3 操作
事务控制主要三步操作:开启事务、提交事务/回滚事务。
- 需要在这组操作执行之前,先开启事务 (
start transaction; / begin;)。 - 所有操作如果全部都执行成功,则提交事务 (
commit;)。 - 如果这组操作中,有任何一个操作执行失败,都应该回滚事务 (
rollback)。
那接下来,我们就可以将添加员工的业务操作,进行事务管理。 具体的 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;事务管理的场景,是非常多的,比如:
- 银行转账
- 下单扣减库存
9.4 Spring 事务管理
9.4.1 分析
在上述实现的新增员工的功能中,一旦在保存员工基本信息后出现异常。
我们就会发现,员工信息保存成功,但是工作经历信息保存失败,造成了数据的不完整不一致。

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

核心思路:在方法运行之前,开启事务,如果方法成功执行,就提交事务,
如果方法执行的过程当中出现异常了,就回滚事务。
所以在
spring框架当中就已经把事务控制的代码都已经封装好了,并不需要我们手动实现。我们使用了
spring框架,我们只需要通过一个简单的注解@Transactional就搞定了。
9.4.2 Transactional 注解
注解:@Transactional
作用:就是在当前这个方法执行开始之前来开启事务,方法执行完毕之后提交事务。
如果在这个方法执行的过程当中出现了异常,就会进行事务的回滚操作。
位置:业务层的 方法上、类上、接口上
- 方法上:当前方法交给
spring进行事务管理 ( 推荐 ) - 类上:当前类中所有的方法都交由
spring进行事务管理 - 接口上:接口下所有的实现类当中所有的方法都交给
spring进行事务管理
接下来,我们就可以在业务方法 save 上加上 @Transactional 来控制事务 。
@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 配置文件中开启事务管理日志,
这样就可以在控制看到和事务相关的日志信息了
# spring 事务管理日志
logging:
level:
org.springframework.jdbc.support.JdbcTransactionManager: debug接下来,我们再次添加员工,看看控制台输出的日志信息。

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

打开数据库,我们会看到 emp 表 与 emp_expr 表中都没有对应的数据信息,保证了数据的一致性、完整性。
9.4.3 事务进阶
前面我们通过 spring 事务管理注解 @Transactional 已经控制了业务层方法的事务。
接下来我们要来详细的介绍一下 @Transactional 事务管理注解的使用细节。
我们这里主要介绍 @Transactional 注解当中的两个常见的属性:
- 异常回滚的属性:
rollbackFor - 事务传播行为:
propagation
9.4.3.1 rollbackFor
我们在之前编写的业务方法上添加了 @Transactional 注解,来实现事务管理。
@Transactional
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
int i = 1/0; // 故意引发除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 进行测试,请求添加员工的接口:

通过 Apifox 返回的结果,我们看到抛出异常了。然后我们在回到 IDEA 的控制台来看一下。

我们看到数据库的事务居然提交了,并没有进行回滚。
通过以上测试可以得出一个结论:
假如我们想让所有的异常都回滚,
需要来配置 @Transactional 注解当中的 rollbackFor 属性,
通过 rollbackFor 这个属性可以指定出现何种异常类型回滚事务。
@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属性来指定。
9.4.3.2 propagation
9.4.3.2.1 介绍
我们接着继续学习 @Transactional 注解当中的第二个属性 propagation,
这个属性是用来 配置事务的传播行为 的 。
什么是事务的传播行为呢?
- 就是当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制。
例如:两个事务方法,一个 A 方法,一个 B 方法。
在这两个方法上都添加了 @Transactional 注解,
就代表这两个方法都具有事务,而在 A 方法当中又去调用了 B 方法。

所谓事务的传播行为,指的就是在 A 方法运行的时候,首先会开启一个事务,
在 A 方法当中又调用了 B 方法, B 方法自身也具有事务,
那么 B 方法在运行的时候,到底是加入到 A 方法的事务当中来,
还是 B 方法在运行的时候新建一个事务? 这个就涉及到了事务的传播行为。
我们要想控制事务的传播行为,在 @Transactional 注解的后面指定一个属性 propagation,
通过 propagation 属性来指定传播行为。接下来我们就来了解一下常见的事务传播行为。
| 属性值 | 含义 |
|---|---|
REQUIRED | 【默认值】需要事务,有则加入,无则创建新事务 |
REQUIRES_NEW | 需要新事务,无论有无,总是创建新事务 |
SUPPORTS | 支持事务,有则加入,无则在无事务状态中运行 |
NOT_SUPPORTED | 不支持事务,在无事务状态下运行,如果当前存在已有事务,则挂起当前事务 |
MANADATORY | 必须有事务,否则抛异常 |
NEVER | 必须没事务,否则抛异常 |
| ... | ... |
对于这些事务传播行为,我们只需要关注以下两个就可以了:
REQUIRED(默认值)
REQUIRES_NEW
9.4.3.2.2 案例
接下来我们就通过一个案例来演示下事务传播行为 propagation 属性的使用。
需求:在新增员工信息时,无论是成功还是失败,都要记录操作日志。
步骤:
准备日志表
emp_log、实体类EmpLog、Mapper接口EmpLogMapper在新增员工时记录日志
准备工作 :
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;
@Transactional
@Override
public void insertLog(EmpLog empLog) {
empLogMapper.insert(empLog);
}
}代码实现 :
业务实现类:
EmpServiceImpl
@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)
@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:当我们不希望事务之间相互影响时,可以使用该传播行为。比如:下订单前需要记录日志,不论订单保存成功与否,都需要保证日志记录能够记录成功。
9.5 事物四大特性
面试题:事务有哪些特性?
原子性(
Atomicity) :原子性是指事务包装的一组 sql 是一个不可分割的工作单元,事务中的操作要么全部成功,要么全部失败。
一致性(
Consistency):一个事务完成之后数据都必须处于一致性状态。- 如果事务成功的完成,那么数据库的所有变化将生效。
- 如果事务执行出现错误,那么数据库的所有变化将会被回滚(撤销),返回到原始状态。
隔离性(
Isolation):多个用户并发的访问数据库时,一个用户的事务不能被其他用户的事务干扰,多个并发的事务之间要相互隔离。
- 一个事务的成功或者失败对于其他的事务是没有影响。
持久性(
Durability):一个事务一旦被提交或回滚,它对数据库的改变将是永久性的,哪怕数据库发生异常,重启之后数据亦然存在。
📌 事务的四大特性简称为:ACID
十、文件上传
在我们完成的 新增员工 功能中,还存在一个问题:没有头像(图片缺失)

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

在我们的案例中,在新增员工的时候,要上传员工的头像,此时就会涉及到文件上传的功能。
在进行文件上传时,我们点击加号或者是点击图片,就可以选择手机或者是电脑本地的图片文件了。
当我们选择了某一个图片文件之后,这个文件就会上传到服务器,从而完成文件上传的操作。
💡 AI-prompt : 你是一名 java 开发工程师,
现在正在学习文件上传功能,如何基于 HTML+SpringBoot 完成文件上传功能。
- 1). 生成的前端代码形式如下:
<!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="username"><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
按照上面的html 代码 创建一个 upload.html 文件到 springboot 项目工程下的 static 目录里面。

2). 生成的服务端代码形式如下
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 就可以来接收到上传的文件
问题:如果表单项的名字和方法中形参名不一致,该怎么办?
public Result upload(String username,
Integer age,
MultipartFile image) //image形参名和请求参数名file不一致解决:使用@RequestParam注解进行参数绑定
public Result upload(String username,
Integer age,
@RequestParam("file") MultipartFile image)10.2 本地存储
上面我们已经完成了文件上传最基本的功能实现,已经可以在服务端接收到上传的文件,
并将文件保存在本地服务器的磁盘目录中了。 但是我们测试的时候发现,如果上传的文件名相同,
后面上传的会覆盖前面上传的文件,那接下来,我们就要来优化这一块的功能。
💡 AI提示词:
请完善上述代码 , 将接收到的文件存储在本地的磁盘目录中 (D:/images) 中, 并要保证上传的文件名不重复
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();
}
}📌 MultipartFile 常见方法:
String getOriginalFilename();//获取原始文件名void transferTo(File dest);//将接收的文件转存到磁盘文件中long getSize();//获取文件的大小,单位:字节byte[] getBytes();//获取文件内容的字节数组InputStream getInputStream();//获取接收到的文件内容的输入流
利用 Apifox 测试,注意:请求参数名和 controller 方法形参名保持一致。

通过
Apifox测试,我们发现文件上传是没有问题的。
在解决了文件名唯一性的问题后,我们再次上传一个较大的文件 (超出1M) 时发现,后端程序报错:

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

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

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

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

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

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

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

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

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

选择要开通的服务

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

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

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


其他的信息,配置项使用默认的即可。
- 若创建时无法修改,先创建完再修改


修改完这两个就好了 ...
10.3.1.4 配置AccessKey
- 1). 创建AccessKey
点击 "AccessKey管理",进入到管理页面。

点击 "创建AccessKey"。


- 2). 配置AccessKey
以 打开 CMD 命令行,执行如下命令,配置系统的环境变量。
set OSS_ACCESS_KEY_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
set OSS_ACCESS_KEY_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx注意:
将上述的 ACCESS_KEY_ID 与 ACCESS_KEY_SECRET 的值一定一定一定一定一定一定要替换成自己的 。
执行如下命令,让更改生效。
setx OSS_ACCESS_KEY_ID "%OSS_ACCESS_KEY_ID%"
setx OSS_ACCESS_KEY_SECRET "%OSS_ACCESS_KEY_SECRET%"echo %OSS_ACCESS_KEY_ID%
echo %OSS_ACCESS_KEY_SECRET%10.3.2 入门
阿里云 oss 对象存储服务的准备工作我们已经完成了,
接下来我们就来完成第二步操作:参照官方所提供的 sdk 示例来编写入门程序。
- 首先我们需要来打开阿里云 OSS 的官方文档,在官方文档中找到 SDK 的示例代码:

- 参照文档,引入依赖:

<!--阿里云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>- 参照文档,编写入门程序:

将官方提供的入门程序,复制过来,将里面的参数值改造成我们自己的即可。代码如下:
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("D:\\image\\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();
}
}
}
}❗ 注意:
一定要将上面的高亮部分换成自己对应的再进行测试!!!
在以上代码中,需要替换的内容为:
endpoint:阿里云 OSS 中的 bucket 对应的域名bucketName:Bucket 名称objectName:对象名称,在 Bucket 中存储的对象的名称region:bucket 所属区域
运行以上程序后,会把本地的文件上传到阿里云 OSS 服务器上。
10.3.3 集成
10.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"
}10.3.3.2 实现
- 1). 引入阿里云OSS上传文件工具类
AliyunOSSOperator(由官方的示例代码改造而来)- 创建路径 :
com.itheima.utils.AliyunOSSOperator
- 创建路径 :
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代码
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.getOriginalFilename());
//将文件交给 OSS 存储管理
String url = aliyunOSSOperator.upload(file.getBytes(), file.getOriginalFilename());
log.info("文件上传OSS,url:{}",url);
return Result.success(url);
}
}使用 Apifox 测试:

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

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

具体实现代码如下:
- 1).
application.yml
#阿里云OSS
aliyun:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
bucketName: java-ai
region: cn-beijing- 2).
AliyunOSSOperator
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 提供的简化方式套路:
需要创建一个实现类,且实体类中的
属性名和配置文件当中key的名字必须要一致比如:配置文件当中叫
endpoint,实体类当中的属性也得叫endpoint,另外实体类当中的属性还需要提供
getter / setter方法
需要将实体类交给
Spring的IOC容器管理,成为IOC容器当中的bean对象在实体类上添加
@ConfigurationProperties注解,并通过prefix属性来指定配置参数项的前缀

具体实现步骤 :
- 1). 定义实体类
AliyunOSSProperties,并交给IOC容器管理- 路径:
com.itheima.utils.AliyunOSSProperties
- 路径:
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
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;
}
}