- 新增员工
- 事务管理
- 文件上传
新增员工
需求
新增员工时,在表单中,我们要录入员工的基本信息和员工的工作经历信息。对应的是emp和emp_expr两张表。
接口描述
员工管理
-> 添加员工
思路分析
-
接口文档规定:
- 请求路径:/emps
- 请求方式:POST
- 请求参数:Json格式数据
- 响应数据:Json格式数据
-
如何限定请求方式是POST?
@PostMapping
-
怎么在controller中接收json格式的请求参数?
@RequestBody
功能开发
准备工作
准备EmpExprMapper
接口及映射配置文件EmpExprMapper.xml
,并准备实体类接收前端传递的json格式的请求参数。
- EmpExprMapper接口
@Mapper
public interface EmpExprMapper {
}
- 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>
- 在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;
}
保存员工基本信息
- EmpController
增加save方法
/**
* 添加员工
*/
@PostMapping
public Result save(@RequestBody Emp emp){
log.info("请求参数emp: {}", emp);
empService.save(emp);
return Result.success();
}
- EmpService
增加save方法
/**
* 添加员工
* @param emp
*/
void save(Emp emp);
@Override
public void save(Emp emp) {
//1.补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
//2.保存员工基本信息
empMapper.insert(emp);
//3. 保存员工的工作经历信息 - 批量 (稍后完成)
}
- EmpMapper
增加insert方法
/**
* 新增员工数据
*/
@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中提供的主键返回功能来获取。
批量保存工作经历
分析
一个员工可以有多段工作经历
执行的SQL语句分别如下
最终我们需要执行的是批量插入数据的insert语句
实现
- EmpServiceImpl
完善sava方法
@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);
}
}
- EmpExprMapper
@Mapper
public interface EmpExprMapper {
/**
* 批量插入员工工作经历信息
*/
public void insertBatch(List<EmpExpr> exprList);
}
- 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>
标签,改标签的作用是用来循环遍历。
- colleciton:集合名称
- item:集合遍历出来的元素/项
- separator:每一次遍历使用的分隔符
- open:遍历开始前拼接的片段
- close:遍历结束后拼接的片段
均为可选
功能测试
Apifox发送POST请求
注意要传入如图json格式的数据。
请求完毕后,可以打开idea的控制台看到控制台输出的日志:
前后端联调
记得打开nginX服务
这里图片插入规定的参数是一个字符串,在自己电脑上直接选择不成功,在之后的文件上传部分会提及。
保存之后可以看到汤姆的数据。
事务管理
问题分析
目前实现的新增员工功能中,我们操作了两次数据库,执行了两次insert操作
- 保存员工的基本信息到
emp
表中。 - 保存员工的工作经历信息到
emp_expr
表中。
如果保存员工的基本信息成功,而保存工作经历信息出错了会发生什么?我们来做一个测试。
我们在代码中人为构造一个错误,处于保存员工的Service层的save方法中。
重启服务器,打开浏览器,做一个测试:
官方给的文档点击保存会显示这个,但我的不显示
此时打开控制台,可以看到保存了员工基本信息之后,系统出现了异常 /by zero
打开数据库查看
- emp表中有沙僧的员工信息
- emp_expr表,却没有沙僧的工作经历
最终我们看到程序出现了异常,在实际开发中我们不允许这种情况发生。
因为这属于一个业务操作,如果保存员工信息成功了,保存工作经历信息失败了,就会造成数据库数据的不完整、不一致。
我们需要通过数据库中的事务来解决这个问题。
介绍
事务是一组操作的集合,是不可分割的工作单位。事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作 要么同时成功,要么同时失败。
事务的四大特性
- 原子性:事务是不可分割的最小操作单元,要么全部成功,要么全部失败。
- 一致性:事务完成时,必须所有数据都保持一致状态。
- 隔离性:数据库提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。
- 持久性:事务一旦提交或回滚,它对数据库中的数据的改变是永久的。
默认MySQL的事务是自动提交的,也就是说当执行一条DML语句,MySQL会立即隐式地提交事务。
操作
事务控制三步骤:开启事务、提交事务/回滚事务
- 开启事务:在这组操作执行之前
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;
事务管理的场景,是非常多的,比如:
- 银行转账
- 下单扣减库存
Spring事务管理
分析
产生原因:
- 先执行新增员工的操作,这步执行完毕,就已经往员工表emp中插入了数据
- 执行1/0操作,抛出异常
- 在抛出异常之前,下面的代码都不会执行了
那么如何让这两个操作同时成功或者同时失败呢?添加事务。
我们只需要一个简单的注解@Transactional
Transactional注解
在这个方法执行开始之前开启事务,方法执行完毕之后提交事务,如果方法执行过程当中出现了异常,就会进行事物的回滚操作。
位置:业务层Service的方法上、类上、接口上
- 方法上:当前方法交给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);
}
}
我们一般会在业务层当中来控制事务,因为在业务层当中,一个业务功能可能会包含多个数据访问操作。在业务层来控制业务,我们就可以将多个数据访问操作控制在一个事务范围内。
可以在application.yml
配置文件中开启事务管理日志,就可以在控制台中看到和事务相关的日志信息了。
#spring事务管理日志
logging:
level:
org.springframework.jdbc.support.JdbcTransactionManager: debug
再次测试
由于服务端程序引发了异常,所以事务进行回滚
打开数据库可以发现emp表和emp_expr表中都没有对应的数据信息,保证了数据的一致性、完整性。
事务进阶
@Transactional
注解当中的两个常见属性:
- 异常回滚的属性:
rollbackFor
- 事务传播行为:
propagation
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);
}
}
之前我们添加了注解使得出现异常之后执行回滚操作,从而保证事务操作前后数据是一致的。
我们再做一个测试,修改业务功能代码,在模拟异常的位置上直接抛出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);
}
}
由于是controller调用service,所以在controller中要有异常处理代码,此时我们选择在controller中继续把异常向上抛。
实现类
接口
控制器
重新测试
此时发现抛出了异常,但是控制台显示transaction commit
事务提交了,并没有进行回滚,于是我们得出结论
默认情况下,只有出现RuntimeException(运行时异常)才会回滚事务
如果我们想让所有异常都回滚,需要来配置@Transaction
注解当中的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);
}
}
这表示这个方法里只要出现了任何异常都会回滚
重新测试
可以回滚
propagation 事务传播
介绍
事物的传播行为:当一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制
例如a方法和b方法都有@Transactional注解,而a方法当中调用了b方法
这时候a方法运行时会首先开启一个事务,在a方法当中又调用了b方法,b方法自身也具有事务,那么b方法在运行的时候,到底是加入到a方法的事务当中来,还是b方法在运行的时候新建一个事务。这就涉及到了事物的传播行为。
属性值 | 含义 |
---|---|
REQUIRED | 【默认值】需要事务,有则加入,无则创建新事务 |
REQUIRES_NEW | 需要新事务,无论有无,总是创建新事务 |
SUPPORTS | 支持事务,有则加入,无则在无事务状态中运行 |
NOT_SUPPORTED | 不支持事务,在无事务状态下运行,如果当前存在已有事务,则挂起当前事务 |
MANDATORY | 必须有事务,否则抛异常 |
NEVER | 必须没事务,否则抛异常 |
… |
我们只需要关注以下两个:
- REQUIRED(默认值)
- REQUIRES_NEW
案例
需求:新增员工信息时,无论成功与否,都要记录操作日志
步骤:
- 准备日志表emp_log、实体类EmpLog、Mapper接口EmpLogMapper
- 在新增员工时记录日志
准备工作
- 创建数据库表 emp_log 日志表
-- 创建员工日志表
create table emp_log(
id int unsigned primary key auto_increment comment 'ID, 主键',
operate_time datetime comment '操作时间',
info varchar(2000) comment '日志信息'
) comment '员工日志表';
- 引入资料中提供的实体类 EmpLog
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmpLog {
private Integer id; //ID
private LocalDateTime operateTime; //操作时间
private String info; //详细信息
}
- EmpLogMapper
@Mapper
public interface EmpLogMapper {
//插入日志
@Insert("insert into emp_log (operate_time, info) values (#{operateTime}, #{info})")
public void insert(EmpLog empLog);
}
- EmpLogService
public interface EmpLogService {
//记录新增员工日志
public void insertLog(EmpLog empLog);
}
- 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);
}
}
测试:
开启事务
加入已存在的事务
执行加入日志操作
出现异常,整个事务回滚
此时因为 保存员工数据 、 插入日志操作 都在一个事务范围内,所以两个操作都会被回滚
所以此时在 emp_log表中没有记录日志数据
原因分析:
- 当执行save方法时开启了一个事务
- 当执行empLogService.insertLog操作时,insertLog设置的事务传播是默认值REQUIRED,表示有事务就加入,否则创建新事务
- 此时save和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);
}
}
测试:
创建新事务
挂起当前事务,创建一个新事物
内部事务提交,外部事务回滚
此时在emp_log表中可以看到日志信息
- REQUIRED:大部分情况下都是用该传播行为即可
- REQUIRES_NEW:当我们不希望事务之间相互影响时,可以使用该传播行为。比如:下订单前需要记录日志,不论订单保存成功与否,都需要保证日志记录能够成功。
事务四大特性
ACID:
- 原子性 Atomicity :事务是不可分割的最小单元,要么全部成功,要么全部失败
- 一致性 Consistency:事务完成时,必须使所有的数据都保持一致状态
- 如果成功,数据库的所有变化都将生效;如果失败,那么数据库的所有变化都会被回滚
- 隔离性 Isolation :数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行
- 一个事物的成功或失败对于其他的事务没有影响
- 持久性 Durability:事务一旦提交或回滚,它对数据库中的数据改变就是永久的
文件上传
我们的新增员工功能还存在一个问题:没有头像
上述问题需要通过文件上传技术来解决
简介
文件上传,是指将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载的过程。
文件上传在项目中应用非常广泛,我们经常发微博、发微信朋友圈都用到了文件上传功能。
在我们的案例中,在新增员工的时候,要上传员工的头像,此时就会涉及到文件上传的功能。在进行文件上传时,我们点击加号或者是点击图片,就可以选择手机或者是电脑本地的图片文件了。当我们选择了某一个图片文件之后,这个文件就会上传到服务器,从而完成文件上传的操作。
- 前端代码如下
<!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/from-data,普通默认的编码格式是不适合传输大型的二进制数据的。
将资料里的页面文件复制到resources下的static目录里
- 服务端代码
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就可以来接收到上传的文件
如果表单项的名字和方法中形参名不一致怎么办
使用@RequestParam注解进行参数绑定
public Result upload(String username,
Integer age,
@RequestParam("file") MultipartFile image)
本地存储
上面我们已经完成了文件上传最基本的功能实现,已经可以在服务端接收到上传的文件,并将文件保存在本地服务器的磁盘目录中了。 但是我们测试的时候发现,如果上传的文件名相同,后面上传的会覆盖前面上传的文件,那接下来,我们就要来优化这一块的功能。
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();
}
}
:::info
MultipartFile 常见方法:
- String getOriginalFilename(); //获取原始文件名
- void transferTo(File dest); //将接收的文件转存到磁盘文件中
- long getSize(); //获取文件的大小,单位:字节
- byte[] getBytes(); //获取文件内容的字节数组
- InputStream getInputStream(); //获取接收到的文件内容的输入流
:::
测试:
Apifox,注意参数名和controller方法形参名一致
Springboot文件上传时默认单个文件大小最大为1M
可以在application.properties进行如下配置:
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 100MB
但是本地存储还存在很多问题:
- 不安全:磁盘如果损坏,所有的文件都会丢失
- 容量有限:如果存储大量的图片,磁盘空间有限(磁盘不可能无限制扩容)
- 无法直接访问
解决方案:
- 自己搭建存储服务器,如:fastDFS、MinIO
- 使用现成的云服务,如:阿里云、腾讯云、华为云
阿里云OSS
准备
介绍
云服务指的就是通过互联网对外提供的各种各样的服务,比如像:语音服务、短信服务、邮件服务、视频直播服务、文字识别服务、对象存储服务等等。
当我们在项目开发时需要用到某个或某些服务,就不需要自己来开发了,可以直接使用阿里云提供好的这些现成服务就可以了。比如:在项目开发当中,我们要实现一个短信发送的功能,如果我们项目组自己实现,将会非常繁琐,因为你需要和各个运营商进行对接。而此时阿里云完成了和三大运营商对接,并对外提供了一个短信服务。我们项目组只需要调用阿里云提供的短信服务,就可以很方便的来发送短信了。这样就降低了我们项目的开发难度,同时也提高了项目的开发效率。(大白话:别人帮我们实现好了功能,我们只要调用即可)
云服务提供商给我们提供的软件服务通常是需要收取一部分费用的。
在我们使用了阿里云OSS对象存储服务之后,我们的项目当中如果涉及到文件上传这样的业务,在前端进行文件上传并请求到服务端时,在服务器本地磁盘当中就不需要再来存储文件了。我们直接将接收到的文件上传到oss,由 oss帮我们存储和管理,同时阿里云的oss存储服务还保障了我们所存储内容的安全可靠。
那我们学习使用这类云服务,我们主要学习什么呢?其实我们主要学习的是如何在项目当中来使用云服务完成具体的业务功能。而无论使用什么样的云服务,阿里云也好,腾讯云、华为云也罢,在使用第三方的服务时,操作的思路都是一样的。
**SDK:**Software Development Kit 的缩写,软件开发工具包,包括辅助软件开发的依赖(jar包)、代码示例等,都可以叫做SDK。
简单说,sdk中包含了我们使用第三方云服务时所需要的依赖,以及一些示例代码。我们可以参照sdk所提供的示例代码就可以完成入门程序。
账号准备
- 阿里云账户
开通OSS云服务
-
试用一下
-
进入控制台
https://oss.console.aliyun.com/overview
- 创建Bucket
输入名称
这里必须开通公共访问,并且读写权限为公共读
配置AccessKey
- 创建AccessKey
鼠标在头像悬停
创建AccessKey并用记事本保存好
- 配置AccessKey
以**管理员身份**打开CMD命令行,执行如下命令,配置系统的环境变量。
注意:将上述的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%
入门
阿里云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-heima-01";
// 填写Object完整路径,例如exampledir/exampleobject.txt。Object完整路径中不能包含Bucket名称。
String objectName = "1.png";
// 填写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:\\images\\10001.png");
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服务器上。
集成
介绍
阿里云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"
}
实现
- 引入阿里云OSS上传文件工具类(由官方的示例代码改造而来)
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;
}
}
- 修改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);
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("上传失败");
}
}
- 测试
- 前后端联调
可以保存并查询文件,保存地址为阿里云服务器
功能优化
员工管理的新增功能我们已开发完成,但在我们所开发的程序中还一些小问题,下面我们就来分析一下当前案例中存在的问题以及如何优化解决。
在刚才我们制作的AliyunOSS操作的工具类中,我们直接将 endpoint、bucketName参数直接在java文件中写死了。如下所示:
如果后续,项目要部署到测试环境、上生产环境,我们需要来修改这两个参数。 而如果开发一个大型项目,所有用到的技术涉及到的这些个参数全部写死在java代码中,是非常不便于维护和管理的。
那么对于这些容易变动的参数,我们可以将其配置在配置文件中,然后通过 <font style="color:rgb(222,120,2);">@Value</font>
注解来注解外部配置的属性。如下所示:
- application.yml
aliyun:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
bucketName: java-heima-01
region: cn-beijing
- 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
注解就可以。
但是这样要卸载配置文件内,如果配置项多,注入繁琐,不便于维护、管理和复用。
如下所示:
Spring中提供了一种简化方式,可以直接将配置文件中配置项的值自动地注入到对象的属性中。
- 创建一个实体类,且实体类中的属性名和配置文件当中的key名字必须一致。比如:配置文件当中叫endpoint,实体类当中的属性也得叫endpoint,另外实体类当中的属性还需要提供 getter / setter方法
- 需要将实体类交给Spring的IOC容器管理,成为IOC容器当中的bean对象
- 在实体类上添加
@ConfigurationProperties
注解,并通过prefix属性来指定配置参数项的前缀
实现步骤:
- 定义实体类AliyunOSSProperties ,并交给IOC容器管理
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;
}
- 修改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;
}
}