- 发布日期
员工管理:新增员工、事务管理与文件上传
- 作者
- 姓名
- 缘
- 社交账号
文章目录
员工管理模块概述
本节围绕 Tlias 员工管理中的三个核心问题展开:
新增员工
↓
事务管理
↓
文件上传
其中新增员工不仅要保存员工基本信息,还要保存员工工作经历;文件上传则从本地磁盘存储进一步扩展到阿里云 OSS。
新增员工
需求分析
新增一名员工时,需要同时保存两部分数据:
员工基本信息
↓
emp 表
员工工作经历
↓
emp_expr 表
前端提交的员工数据中,除了用户名、姓名、性别、手机号、职位、薪资、头像、入职日期、部门等基本属性,还包含工作经历列表:
{
"username": "linpingzhi",
"name": "林平之",
"gender": 1,
"job": 1,
"entryDate": "2022-09-18",
"deptId": 1,
"phone": "18809091234",
"salary": 8000,
"image": "https://example.com/avatar.jpg",
"exprList": [
{
"company": "百度科技股份有限公司",
"job": "Java开发",
"begin": "2012-07-01",
"end": "2019-03-03"
}
]
}
整体调用关系:
Controller
↓
Service
↓
Mapper
↓
Database
Controller 负责接收请求,Service 负责完成完整的新增员工业务,Mapper 负责执行具体 SQL。
保存员工基本信息
Controller
@PostMapping
public Result save(@RequestBody Emp emp) {
log.info("请求参数 emp:{}", emp);
empService.save(emp);
return Result.success();
}
@RequestBody 用于接收前端发送的 JSON 数据,并封装为 Emp 对象。
Service
新增员工时,需要补全创建时间和修改时间:
@Override
public void save(Emp emp) {
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
empMapper.insert(emp);
}
Mapper
@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);
获取新增员工主键
保存员工基本信息以后,还需要保存该员工对应的工作经历,因此必须先拿到刚刚生成的员工 id。
可以在 Mapper 方法上添加:
@Options(useGeneratedKeys = true, keyProperty = "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.getId();
批量保存员工工作经历
一名员工可能存在多段工作经历:
员工
├── 工作经历1
├── 工作经历2
└── 工作经历3
因此需要进行批量插入。
Service
@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(expr -> expr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
关键点:
先保存 emp
↓
获取生成的 emp.id
↓
把 empId 设置到每一条工作经历
↓
批量插入 emp_expr
Mapper
void insertBatch(List<EmpExpr> exprList);
XML:
<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>
最终生成的 SQL 类似:
insert into emp_expr(emp_id, begin, end, company, job)
values
(?, ?, ?, ?, ?),
(?, ?, ?, ?, ?),
(?, ?, ?, ?, ?);
<foreach>常用属性
| 属性 | 作用 |
|---|---|
collection | 要遍历的集合或数组 |
item | 当前遍历到的元素 |
separator | 每一项之间拼接的分隔符 |
open | 遍历开始前拼接的内容 |
close | 遍历结束后拼接的内容 |
事务管理
为什么需要事务
新增员工实际上包含两次数据库操作:
保存员工基本信息
↓
保存员工工作经历
如果第一步成功、第二步失败:
emp
✅ 数据存在
emp_expr
❌ 数据不存在
就会造成数据不完整。
因此这两个操作应该作为一个整体:
全部成功
或者
全部失败
这就是事务。
MySQL事务控制
事务是一组操作的集合,是一个不可分割的工作单位。
常见控制语句:
-- 开启事务
start transaction;
或者:
begin;
全部成功:
commit;
出现问题:
rollback;
完整流程:
开启事务
↓
执行操作1
↓
执行操作2
↓
全部成功 → commit
出现失败 → rollback
MySQL 默认情况下执行一条 DML 语句后会自动提交,因此在需要多个操作保持一致时,需要显式进行事务控制,或者交给 Spring 管理。
Spring事务管理
Spring 提供:
@Transactional
用于声明事务。
例如:
@Transactional
@Override
public void save(Emp emp) {
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
empMapper.insert(emp);
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if (!CollectionUtils.isEmpty(exprList)) {
exprList.forEach(expr -> expr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
一般将事务控制放在 Service 层,因为 Service 层表示一个完整的业务操作。
rollbackFor
默认情况下,Spring 主要对运行时异常进行回滚。
如果希望出现普通 Exception 时也回滚,可以写:
@Transactional(rollbackFor = Exception.class)
例如:
@Transactional(rollbackFor = Exception.class)
@Override
public void save(Emp emp) {
emp.setCreateTime(LocalDateTime.now());
emp.setUpdateTime(LocalDateTime.now());
empMapper.insert(emp);
Integer empId = emp.getId();
List<EmpExpr> exprList = emp.getExprList();
if (!CollectionUtils.isEmpty(exprList)) {
exprList.forEach(expr -> expr.setEmpId(empId));
empExprMapper.insertBatch(exprList);
}
}
在实际业务中,如果希望业务过程中的异常都能够触发回滚,常见写法就是:
@Transactional(rollbackFor = Exception.class)
事务传播行为
事务传播行为用于解决:
一个事务方法调用另一个事务方法时,两个方法应该使用同一个事务,还是分别创建事务?
常见传播行为:
| 传播行为 | 含义 |
|---|---|
REQUIRED | 默认值,有事务则加入,没有则创建 |
REQUIRES_NEW | 无论当前是否存在事务,都创建一个新事务 |
SUPPORTS | 有事务则加入,没有事务则非事务运行 |
NOT_SUPPORTED | 非事务运行,如果当前有事务则挂起 |
MANDATORY | 必须存在事务,否则抛异常 |
NEVER | 必须不存在事务,否则抛异常 |
最常见的是:
REQUIRED
如果希望两个方法的事务互不影响,可以使用:
REQUIRES_NEW
REQUIRES_NEW应用
例如新增员工时,不管员工保存成功还是失败,都希望记录操作日志。
如果员工保存和日志记录共用同一个事务:
新增员工失败
↓
事务回滚
↓
日志也被回滚
日志就失去了意义。
此时可以让日志使用独立事务:
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void insertLog(EmpLog empLog) {
empLogMapper.insert(empLog);
}
这样:
员工事务
↓
成功 / 失败
日志事务
↓
独立提交
两个事务互不影响。
事务四大特性
事务具有 ACID 四大特性:
| 特性 | 英文 | 含义 |
|---|---|---|
| 原子性 | Atomicity | 一个事务中的操作要么全部成功,要么全部失败 |
| 一致性 | Consistency | 事务完成后,数据保持正确一致的状态 |
| 隔离性 | Isolation | 并发事务之间尽量互不干扰 |
| 持久性 | Durability | 事务提交后的修改被永久保存 |
文件上传
文件上传是指将本地图片、视频、音频等文件发送到服务器,由服务器进行保存或进一步处理。
典型流程:
浏览器选择文件
↓
multipart/form-data
↓
Spring Boot
↓
MultipartFile
↓
保存文件
服务端接收文件
Controller 可以直接使用 MultipartFile 接收上传文件:
@Slf4j
@RestController
public class UploadController {
@PostMapping("/upload")
public Result upload(MultipartFile file) {
log.info("文件上传:{}", file.getOriginalFilename());
return Result.success();
}
}
前端上传时,表单字段名需要和后端参数名对应:
file
请求类型:
multipart/form-data
MultipartFile常用方法
常用方法:
file.getOriginalFilename();
获取上传文件原始名称。
file.getInputStream();
获取文件输入流。
file.getBytes();
获取文件字节数组。
file.transferTo(...);
将文件保存到本地磁盘。
本地存储
本地存储版本可以直接使用:
file.transferTo(
new File("E:/web-code/images/" + newFileName)
);
为了避免文件名重复,一般重新生成文件名:
String originalFilename = file.getOriginalFilename();
String extension =
originalFilename.substring(
originalFilename.lastIndexOf(".")
);
String newFileName =
UUID.randomUUID().toString().replace("-", "")
+ extension;
本地保存:
file.transferTo(
new File("E:/web-code/images/" + newFileName)
);
上传文件大小配置
Spring Boot 对上传文件大小有限制,可以在 application.yml 中配置:
spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 100MB
其中:
max-file-size
表示单个文件最大大小。
max-request-size
表示一次请求允许的最大总大小,包括文件和普通表单参数。
为什么不直接使用本地磁盘
文件保存在应用服务器本地会出现一些问题:
- 应用和文件强耦合
- 多台服务器之间文件难以共享
- 磁盘容量有限
- 磁盘损坏可能导致文件丢失
- 文件访问和扩容比较麻烦
因此实际项目通常会使用:
对象存储 OSS
MinIO
FastDFS
其他云存储
Tlias 项目使用阿里云 OSS。
阿里云OSS
OSS 全称:
Object Storage Service
即对象存储服务。
OSS 中几个核心概念:
Region
↓
Bucket所在地域
Endpoint
↓
访问OSS服务的地址
Bucket
↓
存储对象的容器
Object
↓
真正存储的文件
Object Key
↓
Object在Bucket中的唯一名称
例如:
Region:
cn-beijing
Endpoint:
https://oss-cn-beijing.aliyuncs.com
Bucket:
java-web-yuanmie
Object Key:
images/abc123.jpg
对应:
java-web-yuanmie
└── images/abc123.jpg
OSS 本质上采用对象存储结构,images/ 主要表现为 Object Key 的前缀,并不是传统文件系统中的真实目录。
使用第三方服务的一般流程
接入阿里云 OSS 的基本流程:
注册阿里云
↓
实名认证
↓
开通OSS
↓
创建Bucket
↓
准备访问凭证
↓
引入官方SDK
↓
编写代码
↓
集成到项目
开发第三方服务时,应以对应平台的最新官方 SDK 和官方文档为准。
阿里云OSS Java SDK V2
V1与V2区别
旧版教程常见依赖:
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
</dependency>
这是 Java SDK V1。
现在使用 Java SDK V2:
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>alibabacloud-oss-v2</artifactId>
<version>请填写当前最新版本</version>
</dependency>
V2 主要使用:
com.aliyun.sdk.service.oss2.*
配置访问凭证
推荐通过环境变量提供访问凭证:
OSS_ACCESS_KEY_ID
OSS_ACCESS_KEY_SECRET
Windows PowerShell 可以配置:
[Environment]::SetEnvironmentVariable(
"OSS_ACCESS_KEY_ID",
"YOUR_ACCESS_KEY_ID",
[EnvironmentVariableTarget]::User
)
[Environment]::SetEnvironmentVariable(
"OSS_ACCESS_KEY_SECRET",
"YOUR_ACCESS_KEY_SECRET",
[EnvironmentVariableTarget]::User
)
配置完成后,如果 IDEA 在配置环境变量之前已经启动,需要重新启动 IDEA,让新的 Java 进程重新继承环境变量。
不要把真实 AccessKey 直接提交到 Git 仓库。
OSS配置
application.yml:
aliyun:
oss:
region: cn-beijing
endpoint: https://oss-cn-beijing.aliyuncs.com
bucket-name: java-web-yuanmie
含义:
region
↓
Bucket所在地域
endpoint
↓
OSS访问地址
bucket-name
↓
要操作的Bucket
@ConfigurationProperties
将 OSS 配置批量绑定到 Java 对象:
@Data
@Component
@ConfigurationProperties(prefix = "aliyun.oss")
public class AliyunOSSProperties {
private String region;
private String endpoint;
private String bucketName;
}
配置文件中的:
bucket-name:
可以自动绑定到 Java 的:
bucketName
@Value与@ConfigurationProperties
如果配置项较少,可以使用 @Value:
@Value("${aliyun.oss.endpoint}")
private String endpoint;
如果一组配置项较多,并且需要统一管理,更适合:
@ConfigurationProperties(prefix = "aliyun.oss")
对比:
| 方式 | 特点 |
|---|---|
@Value | 一个属性一个属性注入,适合少量配置 |
@ConfigurationProperties | 批量绑定,结构清晰,更适合一组相关配置 |
创建OSSClient
创建配置类:
@Configuration
public class OSSConfig {
@Bean(destroyMethod = "close")
public OSSClient ossClient(
AliyunOSSProperties properties) {
CredentialsProvider credentialsProvider =
new EnvironmentVariableCredentialsProvider();
return OSSClient.newBuilder()
.region(properties.getRegion())
.endpoint(properties.getEndpoint())
.credentialsProvider(credentialsProvider)
.build();
}
}
核心 import:
import com.aliyun.sdk.service.oss2.OSSClient;
import com.aliyun.sdk.service.oss2.credentials.CredentialsProvider;
import com.aliyun.sdk.service.oss2.credentials.EnvironmentVariableCredentialsProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
这里将 OSSClient 注册为 Spring Bean,后续其他组件可以直接注入使用。
OSSClient 适合复用,不需要每上传一个文件就创建一次。
测试OSSClient
可以先测试能否正常访问 Bucket:
@SpringBootTest
public class OssTest {
@Autowired
private OSSClient ossClient;
@Autowired
private AliyunOSSProperties properties;
@Test
public void testOSSClient() {
GetBucketInfoRequest request =
GetBucketInfoRequest.newBuilder()
.bucket(properties.getBucketName())
.build();
GetBucketInfoResult result =
ossClient.getBucketInfo(request);
System.out.println(
"状态码:" + result.statusCode()
);
System.out.println(
"Bucket:" + result.bucketInfo().name()
);
}
}
如果出现:
Credentials is null or empty
首先检查:
OSS_ACCESS_KEY_ID
OSS_ACCESS_KEY_SECRET
是否已经正确配置。
如果环境变量是 IDEA 启动以后才新增或修改的,需要重新启动 IDEA。
OSS上传工具类
建议将 OSS 操作封装到:
AliyunOSSOperator
而不是直接全部写在 Controller 中。
结构:
UploadController
↓
AliyunOSSOperator
↓
OSSClient
↓
阿里云OSS
类结构
@Component
@RequiredArgsConstructor
public class AliyunOSSOperator {
private final OSSClient ossClient;
private final AliyunOSSProperties properties;
}
@RequiredArgsConstructor 会为 final 字段生成构造器,Spring 可以通过构造器完成依赖注入。
生成Object Key
不要直接使用用户原始文件名,否则同名 Object 可能互相覆盖。
例如:
String originalFilename = file.getOriginalFilename();
String extension = "";
if (originalFilename != null
&& originalFilename.contains(".")) {
extension = originalFilename.substring(
originalFilename.lastIndexOf(".")
);
}
String objectName =
"images/"
+ UUID.randomUUID().toString().replace("-", "")
+ extension;
例如:
原文件:
avatar.jpg
上传后:
images/6d1f0ec8c49244f7b47ac8761f43658e.jpg
使用PutObject上传文件
完整上传方法:
public String upload(MultipartFile file)
throws IOException {
// 1. 获取原始文件名
String originalFilename =
file.getOriginalFilename();
// 2. 获取扩展名
String extension = "";
if (originalFilename != null
&& originalFilename.contains(".")) {
extension = originalFilename.substring(
originalFilename.lastIndexOf(".")
);
}
// 3. 生成唯一Object Key
String objectName =
"images/"
+ UUID.randomUUID()
.toString()
.replace("-", "")
+ extension;
// 4. 上传到OSS
try (InputStream inputStream =
file.getInputStream()) {
PutObjectRequest request =
PutObjectRequest.newBuilder()
.bucket(
properties.getBucketName()
)
.key(objectName)
.body(
BinaryData.fromStream(
inputStream
)
)
.build();
ossClient.putObject(request);
}
// 5. 返回Object访问地址
String endpoint =
properties.getEndpoint()
.replace("https://", "")
.replace("http://", "");
return "https://"
+ properties.getBucketName()
+ "."
+ endpoint
+ "/"
+ objectName;
}
核心 import:
import com.aliyun.sdk.service.oss2.models.PutObjectRequest;
import com.aliyun.sdk.service.oss2.transport.BinaryData;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.util.UUID;
PutObjectRequest
上传文件时主要指定三个信息:
PutObjectRequest.newBuilder()
.bucket(...)
.key(...)
.body(...)
.build();
对应:
bucket
↓
上传到哪个Bucket
key
↓
Object叫什么
body
↓
真正上传的文件内容
真正执行上传:
ossClient.putObject(request);
UploadController
原来的本地磁盘上传代码:
file.transferTo(
new File("E:/web-code/images/" + newFileName)
);
可以全部移除。
Controller 只负责调用 OSS 工具类:
@Slf4j
@RestController
@RequiredArgsConstructor
public class UploadController {
private final AliyunOSSOperator aliyunOSSOperator;
@PostMapping("/upload")
public Result upload(MultipartFile file)
throws IOException {
log.info(
"文件上传:{}",
file.getOriginalFilename()
);
String url =
aliyunOSSOperator.upload(file);
return Result.success(url);
}
}
此时整个上传链路:
前端
↓
POST /upload
↓
MultipartFile
↓
UploadController
↓
AliyunOSSOperator
↓
PutObjectRequest
↓
OSSClient.putObject()
↓
阿里云OSS
↓
返回Object URL
接口测试
使用 Apifox 或 Postman:
POST http://localhost:8080/upload
Body:
form-data
参数:
Key: file
Type: File
Value: 选择本地图片
成功后接口会返回类似:
{
"code": 1,
"msg": "success",
"data": "https://java-web-yuanmie.oss-cn-beijing.aliyuncs.com/images/xxxxxxxx.jpg"
}
Object访问URL
默认 OSS 域名下,常见访问地址形式为:
https://BucketName.Endpoint/ObjectKey
例如:
https://java-web-yuanmie.oss-cn-beijing.aliyuncs.com/images/abc123.jpg
如果 Bucket 为私有读权限,直接访问普通 URL 可能返回 403,此时需要使用签名 URL 或其他授权访问方式。
如果项目使用公共读 Bucket,则公开文件可以通过对应 URL 直接访问。
常见问题
请求地址出现//upload
错误:
{
"status": 404,
"error": "Not Found",
"path": "//upload"
}
说明最终请求地址中出现了两个 /。
正确:
http://localhost:8080/upload
例如:
Base URL:
http://localhost:8080
接口路径:
/upload
不要拼成:
http://localhost:8080//upload
Credentials is null or empty
错误:
CredentialsException:
Credentials is null or empty
优先检查环境变量:
OSS_ACCESS_KEY_ID
OSS_ACCESS_KEY_SECRET
如果刚刚修改 Windows 环境变量,而 IDEA 之前已经打开,需要重新启动 IDEA。
上传成功但访问URL返回403
可能原因:
Bucket为私有
普通 URL 无法匿名访问。
可以根据项目需求选择:
公开资源
↓
公共读 / 自定义域名等
私有资源
↓
签名URL / 授权访问
正式项目不应为了省事把敏感文件设置为公共读。
最终项目结构
OSS 部分可以整理为:
src/main/java
└── com.yuanmie
├── config
│ └── OSSConfig.java
│
├── controller
│ └── UploadController.java
│
├── properties
│ └── AliyunOSSProperties.java
│
└── utils
└── AliyunOSSOperator.java
职责:
AliyunOSSProperties
↓
保存OSS配置
OSSConfig
↓
创建OSSClient Bean
AliyunOSSOperator
↓
封装OSS上传操作
UploadController
↓
提供HTTP上传接口
本节核心流程
新增员工
接收Emp
↓
保存emp
↓
获取生成的员工id
↓
为工作经历设置empId
↓
批量保存emp_expr
事务
Service业务方法
↓
@Transactional
↓
多次数据库操作
↓
全部成功 → 提交
出现异常 → 回滚
文件上传
MultipartFile
↓
AliyunOSSOperator
↓
生成Object Key
↓
PutObjectRequest
↓
OSSClient.putObject()
↓
阿里云OSS
↓
返回URL
常用注解总结
| 注解 | 作用 |
|---|---|
@PostMapping | 接收 POST 请求 |
@RequestBody | 将 JSON 请求体封装为 Java 对象 |
@Transactional | 声明 Spring 事务 |
@Options | 配置 MyBatis 操作,例如获取自增主键 |
@ConfigurationProperties | 批量绑定外部配置 |
@Value | 注入单个配置项 |
@Configuration | 声明配置类 |
@Bean | 将方法返回对象注册到 IOC 容器 |
@Component | 将普通类交给 Spring 管理 |
@RequiredArgsConstructor | Lombok 为 final 字段生成构造器 |
@RestController | 声明 REST Controller |
@Slf4j | Lombok 提供日志对象 |
总结
本节完整串联了一个后端业务从数据库到文件存储的开发过程:
员工数据
↓
Controller
↓
Service
├── emp
└── emp_expr
↓
Spring事务保证一致性
头像文件则通过:
MultipartFile
↓
OSS Java SDK V2
↓
Bucket
↓
Object
↓
URL
新增员工解决的是业务数据保存问题,事务解决的是多次数据库操作的一致性问题,而 OSS 解决的是文件持久化和独立存储问题。