缘博客
发布日期

员工管理:新增员工、事务管理与文件上传

作者
  • 姓名
    社交账号

员工管理模块概述

本节围绕 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 管理
@RequiredArgsConstructorLombok 为 final 字段生成构造器
@RestController声明 REST Controller
@Slf4jLombok 提供日志对象

总结

本节完整串联了一个后端业务从数据库到文件存储的开发过程:

员工数据
Controller
Service
   ├── emp
   └── emp_expr
Spring事务保证一致性

头像文件则通过:

MultipartFile
OSS Java SDK V2
Bucket
Object
URL

新增员工解决的是业务数据保存问题,事务解决的是多次数据库操作的一致性问题,而 OSS 解决的是文件持久化和独立存储问题。