day13 员工管理实战-事务管理,文件上传

day13

计划

  1. 新增员工功能(保存emp表 + 批量保存工作经历)
  2. 事务管理(@Transactional、rollbackFor、propagation)
  3. 文件上传(前端三要素、本地存储、阿里云OSS)

笔记

1.新增员工

需求分析

新增员工时,表单中既要录入员工基本信息,又要录入工作经历信息:

  • 员工基本信息 → 存入 emp
  • 工作经历信息 → 存入 emp_expr 表(一个员工可有多段经历)

接口要求: 请求路径 /emps、请求方式 POST、请求参数 JSON格式(@PostMapping + @RequestBody

Emp实体类增加工作经历属性
1
2
3
4
5
6
@Data
public class Emp {
...
private String deptName; // 部门名称
private List<EmpExpr> exprList; // 员工工作经历信息(新增)
}
保存员工基本信息(主键返回)

EmpMapper:

1
2
3
4
5
// 主键返回:将数据库自动生成的主键回填到emp对象的id属性中
@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);

为什么需要主键返回? 保存员工后还要保存其工作经历,而工作经历表的 emp_id 字段需要关联员工的ID。@Options(useGeneratedKeys = true, keyProperty = "id") 会在insert执行后,把自增主键值回填到 emp.getId()

批量保存工作经历(<foreach>标签)

EmpExprMapper接口:

1
2
3
4
@Mapper
public interface EmpExprMapper {
void insertBatch(List<EmpExpr> exprList); // 批量插入
}

EmpExprMapper.xml:

1
2
3
4
5
6
<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>

<foreach> 标签常用属性:

属性 说明
collection 集合名称(方法参数名)
item 遍历出来的元素/项
separator 每次遍历之间的分隔符(如逗号)
open 遍历开始前拼接的片段
close 遍历结束后拼接的片段
Service完整逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void save(Emp emp) {
// 1. 补全基础属性
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
// 2. 保存员工基本信息(insert后emp.getId()有值)
empMapper.insert(emp);
// 3. 保存工作经历 - 批量
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if (!CollectionUtils.isEmpty(exprList)) {
exprList.forEach(expr -> expr.setEmpId(empId)); // 给每段经历补上empId
empExprMapper.insertBatch(exprList);
}
}

2.事务管理

为什么需要事务

新增员工涉及两次数据库操作(插emp表 + 插emp_expr表)。如果保存员工成功、保存经历失败,就会造成数据不完整不一致。事务能让多个操作要么全部成功,要么全部失败

事务概念与操作
  • 概念: 一组操作的集合,是不可分割的工作单位,要么同时成功,要么同时失败
  • SQL层面三步操作:
    • 开启事务:start transaction; / begin;
    • 全部成功:commit;
    • 有失败:rollback;

注意:MySQL默认事务是自动提交的,执行一条DML语句会立即隐式提交。

Spring事务管理:@Transactional注解

作用: 方法执行前开启事务,执行完毕提交事务,出现异常则回滚事务。

1
2
3
4
5
6
7
@Transactional
@Override
public void save(Emp emp) {
empMapper.insert(emp);
int i = 1/0; // 异常→回滚
empExprMapper.insertBatch(exprList);
}

可加的位置:

位置 作用
方法上 当前方法交给Spring事务管理
类上 当前类所有方法都进行事务管理
接口上 接口下所有实现类的所有方法都进行事务管理

为什么放在业务层? 一个业务功能可能包含多个数据访问操作,在业务层控制事务可以将多个操作控制在同一个事务范围内。

事务日志配置
1
2
3
logging:
level:
org.springframework.jdbc.support.JdbcTransactionManager: debug
rollbackFor属性(异常回滚)

默认规则: 只有出现**运行时异常(RuntimeException)**才回滚事务;抛出编译时异常(Exception)不会回滚。

1
2
3
4
5
6
// 模拟编译时异常:事务没有回滚,数据保存成功
@Transactional
public void save(Emp emp) throws Exception {
empMapper.insert(emp);
throw new Exception("出现异常了~~~"); // 不会回滚!
}

解决: 通过 rollbackFor 指定回滚的异常类型。

1
2
3
4
5
@Transactional(rollbackFor = Exception.class)  // 所有异常都回滚
public void save(Emp emp) throws Exception {
empMapper.insert(emp);
throw new Exception("出现异常了~~~"); // 现在会回滚
}

结论:

  • 默认只有 RuntimeException 会回滚
  • 需要回滚其他异常时,用 rollbackFor 属性指定
propagation属性(事务传播行为)

概念: 一个事务方法被另一个事务方法调用时,这个事务方法应该如何进行事务控制。

属性值 含义 使用场景
REQUIRED(默认) 需要事务,有则加入,无则新建 大部分情况
REQUIRES_NEW 总是创建新事务 不希望事务相互影响(如记日志)
SUPPORTS 有则加入,无则无事务运行 少用
NOT_SUPPORTED 无事务状态下运行 少用
MANDATORY 必须有事务,否则抛异常 少用
NEVER 必须没有事务,否则抛异常 少用

案例:新增员工时无论成功失败都记录日志

问题:save方法有事务,insertLog默认REQUIRED会加入同一事务 → 主事务回滚时日志也被回滚,日志没记上。

解决: 让insertLog独立开启新事务。

1
2
3
4
5
6
7
8
@Service
public class EmpLogServiceImpl implements EmpLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW) // 独立新事务
@Override
public void insertLog(EmpLog empLog) {
empLogMapper.insert(empLog);
}
}

两个必须掌握的传播行为:

  • REQUIRED: 大部分情况下使用,加入已有事务
  • REQUIRES_NEW: 不希望事务互相影响时使用,如记录日志(无论业务成功与否日志都要记上)
事务四大特性(ACID)
特性 英文 说明
原子性 Atomicity 事务是不可分割的最小单元,要么全部成功,要么全部失败
一致性 Consistency 事务完成时,数据都必须处于一致状态
隔离性 Isolation 多个并发事务之间相互隔离,互不干扰
持久性 Durability 事务提交或回滚后,对数据的改变是永久的

3.文件上传

简介

将本地图片、视频、音频等文件上传到服务器,供其他用户浏览或下载。

前端上传页面三要素
要素 要求 原因
file域 表单必须有 <input type="file"> 用于选择文件
POST方式 method="post" 上传文件通常较大,POST才能传输
enctype enctype="multipart/form-data" 普通编码格式不适合传输二进制大文件
1
2
3
4
5
<form action="/upload" method="post" enctype="multipart/form-data">
姓名: <input type="text" name="username"><br>
头像: <input type="file" name="file"><br>
<input type="submit" value="提交">
</form>
后端接收(MultipartFile)
1
2
3
4
5
6
7
8
9
10
11
12
@Slf4j
@RestController
public class UploadController {
@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();
}
}

注意: 方法形参名必须与请求参数名一致。不一致时用 @RequestParam("file") MultipartFile image 绑定。

MultipartFile常用方法:

方法 作用
getOriginalFilename() 获取原始文件名
transferTo(File dest) 将接收的文件转存到磁盘
getSize() 获取文件大小(字节)
getBytes() 获取文件内容的字节数组
getInputStream() 获取文件内容的输入流
本地存储

问题1:文件名重复会覆盖。 解决:用UUID生成唯一文件名。

1
2
3
4
// 生成唯一文件名:UUID + 原文件后缀
String originalFilename = file.getOriginalFilename();
String extName = originalFilename.substring(originalFilename.lastIndexOf("."));
String uniqueFileName = UUID.randomUUID().toString().replace("-", "") + extName;

问题2:大文件上传报错。 SpringBoot默认单个文件最大1M,需配置:

1
2
3
4
5
spring:
servlet:
multipart:
max-file-size: 10MB # 单个文件最大
max-request-size: 100MB # 一次请求最大

本地存储的缺点: 不安全(磁盘损坏文件丢失)、容量有限、无法直接访问。

阿里云OSS(云存储)

OSS是什么: 阿里云对象存储服务(Object Storage Service),海量、安全、低成本、高可靠的云存储服务,通过网络随时存储和调用文件。

SDK: Software Development Kit(软件开发工具包),包含依赖jar包和代码示例。

使用第三方服务的通用思路:

  1. 准备工作:注册账号、开通服务、创建Bucket、创建AccessKey
  2. 参照官方SDK示例编写入门程序
  3. 集成到项目中

关键概念:

概念 说明
Bucket 存储空间,存储文件的容器,所有文件必须隶属于某个Bucket
Object 存储的对象(就是文件)
endpoint Bucket对应的访问域名
region Bucket所属地域(如cn-beijing)
AccessKey 访问凭证(ID + Secret),通过环境变量配置

1. 引入依赖:

1
2
3
4
5
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>

2. 环境变量配置AccessKey:

1
2
3
4
set OSS_ACCESS_KEY_ID=你的ID
set OSS_ACCESS_KEY_SECRET=你的Secret
setx OSS_ACCESS_KEY_ID "%OSS_ACCESS_KEY_ID%"
setx OSS_ACCESS_KEY_SECRET "%OSS_ACCESS_KEY_SECRET%"

3. 集成工具类AliyunOSSOperator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@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 {
// 从环境变量获取凭证
EnvironmentVariableCredentialsProvider credentialsProvider =
CredentialsProviderFactory.newEnvironmentVariableCredentialsProvider();
// 按日期分目录 + UUID唯一文件名
String dir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
String newFileName = UUID.randomUUID() + originalFilename.substring(originalFilename.lastIndexOf("."));
String objectName = dir + "/" + newFileName;
// 创建客户端
OSS ossClient = OSSClientBuilder.create()
.endpoint(endpoint)
.credentialsProvider(credentialsProvider)
.region(region)
.build();
try {
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(content));
} finally {
ossClient.shutdown();
}
// 拼接返回可访问的URL
return endpoint.split("//")[0] + "//" + bucketName + "." + endpoint.split("//")[1] + "/" + objectName;
}
}

4. UploadController改造:

1
2
3
4
5
6
7
8
@PostMapping("/upload")
public Result upload(MultipartFile file) throws Exception {
if (!file.isEmpty()) {
String url = aliyunOSSOperator.upload(file.getBytes(), file.getOriginalFilename());
return Result.success(url); // 返回图片访问URL给前端
}
return Result.error("上传失败");
}

4.配置参数优化(@Value vs @ConfigurationProperties)

问题: endpoint、bucketName等参数写死在Java代码中,测试/生产环境切换不便维护。

方式一:@Value注解逐个注入
1
2
3
4
@Value("${aliyun.oss.endpoint}")
private String endpoint;
@Value("${aliyun.oss.bucketName}")
private String bucketName;
1
2
3
4
5
aliyun:
oss:
endpoint: https://oss-cn-beijing.aliyuncs.com
bucketName: java-ai
region: cn-beijing

适用: 一两个属性、不需要复用。

方式二:@ConfigurationProperties批量绑定(推荐)

三个步骤:

  1. 创建实体类,属性名与配置key一致
  2. 将实体类交给IOC容器管理(@Component
  3. 实体类上添加 @ConfigurationProperties(prefix="...") 指定前缀
1
2
3
4
5
6
7
8
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss") // 批量绑定配置
public class AliyunOSSProperties {
private String endpoint;
private String bucketName;
private String region;
}
1
2
3
4
5
6
7
8
9
10
@Component
public class AliyunOSSOperator {
@Autowired
private AliyunOSSProperties aliyunOSSProperties; // 直接注入整个配置对象

public String upload(...) {
String endpoint = aliyunOSSProperties.getEndpoint();
...
}
}

对比:

方式 优点 缺点
@Value 简单直接 配置项多时繁琐,不便维护复用
@ConfigurationProperties 批量注入,便于维护复用 需要额外建实体类

5.核心知识点总结

知识点 说明
@PostMapping + @RequestBody 接收JSON格式的新增请求
@Options(useGeneratedKeys, keyProperty) 主键返回,自增主键回填到对象id属性
<foreach> 批量插入,collection/item/separator/open/close
CollectionUtils.isEmpty 判断集合是否为空
事务 一组操作的集合,要么全部成功要么全部失败
@Transactional 开启事务管理,异常自动回滚
事务注解位置 业务层方法/类/接口上
rollbackFor 默认只回滚RuntimeException,rollbackFor=Exception.class回滚所有异常
REQUIRED 默认传播行为,有事务则加入
REQUIRES_NEW 总是创建新事务,互不影响(如记日志)
ACID 原子性、一致性、隔离性、持久性
上传三要素 file域 + POST + enctype=”multipart/form-data”
MultipartFile 接收上传文件,transferTo转存磁盘
UUID文件名 解决文件名重复覆盖问题
max-file-size SpringBoot默认单文件最大1M,需配置调大
阿里云OSS 对象存储服务,解决本地存储的缺点
SDK 软件开发工具包,含依赖和示例代码
@Value 单个属性注入配置
@ConfigurationProperties 批量绑定配置到实体类,便于维护复用