SpringBoot和Axios数据的传递和接收-Restful完全版

SpringBoot和Axios数据的传递和接收-Restful完全版

一、基础知识铺垫

Axios使用

使用axios发送请求,一般有三个最常用的属性。

属性 含义
url 请求的端点 URL
method HTTP 请求方法(如 get, post, put, delete, patch 等)。
params / data 如果 methodgetdelete,使用 params 来传递 URL 查询参数。如果是 post, put, patch,则使用 data 传递请求体数据。通常是一个对象 {}。

HTTP请求方式

Restful风格定义了多种请求方式。

方式 简介 常用场景
GET 请求指定的资源。通常用来获取或查询资源。 读取或查询资源,如获取用户列表或特定用户的详细信息。
POST 向指定资源提交数据,请求服务器进行处理(如创建或修改)。数据包含在请求体中。 创建新资源(如新用户、新帖子),或提交用户数据表单。
PUT 用请求体中的数据替换目标资源的所有当前表示。 更新现有资源的全部内容,如编辑用户的完整个人信息。
PATCH 对资源应用部分修改。 更新资源的一部分,如修改用户的邮箱地址或密码。
DELETE 删除指定的资源。 删除资源,如删除用户账户或帖子。

数据传输方式

方式 介绍
URL路径参数(Path Variables 通过 URL 的路径部分传递数据。在 Spring Boot 中使用 @PathVariable 注解获取。适用于 RESTful 风格的 API,例如获取特定资源的详情。
查询参数(Query Parameters 通过 URL 的查询字符串(?key=value 形式)传递数据。在 Spring Boot 中使用 @RequestParam 注解获取。适用于 GETDELETE 请求。
请求体(Request Body 通过 HTTP 请求的 body 部分传递数据。在 Spring Boot 中使用 @RequestBody 注解获取。适用于 POST , PUTPATCH请求,发送复杂的数据结构。

SpringBoot获取数据的方式

需要提及的是,单单从”获取数据”的角度,我们可以把DeleteGet归为一类,把PutPatchPost归为一类。

  • 前者在axios中使用params传递参数,属于Query Parameters
  • 后者在axios中使用data传递参数,属于Request Body
  • 无论是哪一种,都可以有Path Variables

在 Spring Boot(及一般的 HTTP 服务开发)中,将请求分为”GET 体系”和”POST 体系”可能会导致一些混淆,因为每种 HTTP 方法(GET、POST、PUT、PATCH、DELETE 等)都设计有其独特的用途和语义。不过,如果我们从”如何获取请求中的数据”这个角度来看,可以有一种比较宽泛的分类方式,尤其是关注于数据是通过 URL 还是请求体传递。

体系 获取数据的常用注解
Path Variables @PathVariable
Get、Delete类 @RequestParam@ModelAttribute
Post、Put、Patch类 @RequestBody

二、基础传递代码示例

除了特殊的数据类型,普通的数据传递,默认以axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';为策略。

(一)Path Variables

Path Variables数据在url上,无关乎get还是post

language-javascript
1
2
3
4
5
6
7
8
9
10
return request({

url: '/test/users/123',
method: 'get'
});
return request({

url: '/test/users/345/info',
method: 'post'
});
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/test")
public class CourseTestController {


@GetMapping("/users/{userId}")
public String getUser(@PathVariable String userId) {

return "Received GET request for User ID: " + userId;
}

@PostMapping("/users/{userId}/info")
public String updateUser(@PathVariable String userId) {

return "Received POST request for User ID: " + userId;
}

}

(二)Get、Delete

@RequestParam

@RequestParam 主要用于将单个请求参数绑定到方法的参数上,通过指定 valuename 属性,你可以明确告诉 Spring Boot 请求参数的实际名称。

language-javascript
1
2
3
4
5
6
7
8
9
10
return request({

url: '/users',
method: 'get',
params:{

type:"1",
status:"2",
}
});
language-java
1
2
3
4
5
@GetMapping("/users")
public String getUser(@RequestParam String type, @RequestParam(name = "status") String userStatus) {

return "Received GET request for" + type + " " + userStatus;
}

@ModelAttribute

利用 @ModelAttribute 注解。这个注解会告诉 Spring Boot,应该将请求中的查询参数自动绑定到方法参数对象的属性上。

language-javascript
1
2
3
4
5
6
7
8
9
10
return request({

url: '/users',
method: 'get',
params:{

type:"1",
status:"2",
}
});
language-java
1
2
3
4
5
6
7
8
9
10
11
@GetMapping("/users")
public String getUser(@ModelAttribute Query query) {

return "Received GET request for" + query.toString();
}
@Data
class Query{

String type;
String status;
}

通常情况下,我们会将所有的查询参数封装到一个对象中,而不是分开为两个对象,除非这两个对象在逻辑上代表着完全不同的东西,且您希望显式地区分它们。如果您确实有特定的理由需要这样做,也是可行的。比如第二个Query2表示分页查询时的分页参数

language-javascript
1
2
3
4
5
6
7
8
9
10
return request({

url: '/users',
method: 'delete',
params:{

type:"1",
status:"2",
}
});
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
	@DeleteMapping("/users")
public String deleteUser(@ModelAttribute Query1 query1, @ModelAttribute Query2 query2) {

return "Received GET request for" + query1.toString() + query2.toString();
}
@Data
class Query1{

String type;
}
@Data
class Query2{

String userStatus;
// 如果您希望整个对象通过 @ModelAttribute 来绑定,同时又有个别属性名不匹配
// 您可以在后端对象中添加 setter 方法,并在其中处理名称不匹配的问题
// 注意:Lombok @Data 注解会生成默认的 setter 方法,
// 所以如果使用 Lombok,您需要手动添加一个额外的 setter 方法来处理不匹配的情况
public void setStatus(String status) {

this.userStatus = status;
}
}

(三)Post、Put、Patch

@RequestBody

language-javascript
1
2
3
4
5
6
7
8
9
10
11
12
return request({

url: '/users',
method: 'post',
data:{

userId: 123,
userName: "John Doe",
userAge: 30,
userSex: "Male"
}
});
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
   @PostMapping("/users")
public String getUser(@RequestBody UserVo userVo) {

return userVo.toString();
}
@Data
class UserVo{

Long userId;
String userName;
Long userAge;
String userSex;
}

Spring Boot 后端的 UserVo 类中的属性名和前端传递的 JSON 对象的键名不一致时,可以使用@JsonProperty

language-javascript
1
2
3
4
5
6
7
8
9
10
11
12
return request({

url: '/users',
method: 'put',
data:{

userId: 123,
userName: "John Doe",
userAge: 32,
userSex: "Male"
}
});
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
   @PutMapping("/users")
public String getUser(@RequestBody UserVo userVo) {

return userVo.toString();
}
@Data
class UserVo{

Long userId;
@JsonProperty("userName")
String name;
Long userAge;
String userSex;
}
language-javascript
1
2
3
4
5
6
7
8
9
10
return request({

url: '/users',
method: 'patch',
data:{

userId: 123,
userAge: 34,
}
});
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
   @PatchMapping("/users")
public String getUser(@RequestBody UserVo userVo) {

return userVo.toString();
}
@Data
class UserVo{

Long userId;
@JsonProperty("userName")
String name;
Long userAge;
String userSex;
}

如果你不想额外写一个类作为@RequestBody的参数,你可以选择使用Map或者JsonNode

language-java
1
2
3
4
5
6
7
@PutMapping("/users")
public R<Boolean> getUser(@RequestBody Map<String, Object> body) {

Long id = Long.valueOf(body.get("userId").toString());
String mind = (String) body.get("name");
// 业务逻辑
}
language-java
1
2
3
4
5
6
7
@PutMapping("/users")
public R<Boolean> getUser(@RequestBody JsonNode body) {

Long id = body.get("userId").asLong();
String mind = body.get("name").asText();
// 业务逻辑
}

三、稍微复杂一点的传递

(一)数组

如果用的是Post,那么一般一切安好。

language-javascript
1
2
3
4
5
6
7
8
9
10
return request({

url: '/users',
method: 'post',
data:{

userId: 123,
userOrder: ['1223', '3445', '556'],
}
});
language-java
1
2
3
4
5
6
7
8
9
10
11
   @PostMapping("/users")
public String getUser(@RequestBody UserVo userVo) {

return userVo.toString();
}
@Data
class UserVo{

Long userId;
List<String> userOrder;
}

如果用的是Get,就需要注意默认情况下,Spring Boot 期望列表或数组类型的查询参数以特定的格式传递,例如:userOrder=1223&userOrder=3445&userOrder=556。但在你的例子中,由于是通过 axios 发送请求,并且当你在请求的 params 中包含一个数组时,axios 会将数组转换为 userOrder[0]=1223&userOrder[1]=3445&userOrder[2]=556 的格式,这与 Spring Boot 的默认期望不匹配

language-javascript
1
2
3
4
5
6
7
8
9
10
return request({

url: '/users',
method: 'get',
params:{

userId: 123,
userOrder: ['1223', '3445', '556'].join(','),
}
});
language-java
1
2
3
4
5
@GetMapping("/users")
public String getUser(@RequestParam String userId, @RequestParam List<String> userOrder) {

return userId + "\n" + userOrder.toString();
}

对于数组元素是简单类型,直接用字符’,’拼接即可,变成userOrder=1223,3445,556,Springboot也能匹配。或者可以去参考qs.stringify也就是qs库的用法。

如果数组元素比较复杂呢?如果你仍然坚持使用get,建议去阅读qs使用。一般的做法是用post,可以省去很多麻烦。

language-javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
return request({

url: '/users',
method: 'post',
data:{

userId: 123,
userOrder: [
{
id: '1223', name: 'Order1' },
{
id: '3445', name: 'Order2' },
{
id: '556', name: 'Order3' }
]
}
});
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   @PostMapping("/users")
public String getUser(@RequestBody UserVo userVo) {

return userVo.toString();
}

@Data
class UserVo{

Long userId;
List<Item> userOrder;
}
@Data
class Item{

String id;
String name;
}

(二)GET/POST复合型

对于复合型请求,即在URL中通过查询参数(例如分页信息)传递部分数据,同时在请求体中通过JSON传递更复杂的数据(例如筛选条件),Spring Boot可以通过同时使用@RequestParam@RequestBody注解来接收这两种类型的数据。

language-javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const pageParams = {

page: 1,
size: 10
};
return request({

url: `/users?page=${
pageParams.page}&size=${
pageParams.size}`,
method: 'post',
data:{

userId: 123,
userName: "Jack"
}
});
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
   @PostMapping("/users")
public String getUser(
@RequestBody UserVo userVo,
@RequestParam("page") int page,
@RequestParam("size") int size
) {

return userVo.toString();
}
@Data
class UserVo{

Long userId;
String userName;
}

四、特殊数据

(一)文件

上传文件时,使用的 Content-Type 通常是 multipart/form-data。这种类型允许将请求体中的数据作为一系列部分(parts)发送,每个部分可以包含文件内容或其他数据。这种方式非常适合文件上传,因为它支持在单个请求中发送文件数据及其他表单字段

language-javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const formData = new FormData();
formData.append('file', file);
formData.append('filename', file.name);
formData.append('chunkIndex', i.toString());
formData.append('totalChunks', totalChunks.toString());
formData.append('md5', md5);
const rsp = await addChunk(formData);

export const addChunk = (data: any) => {

return request({

url: '/video/chunk',
method: 'post',
data: data
});
};
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@PostMapping(value = "/video/chunk")
public R<String> handleChunkUpload(
@RequestParam("file") MultipartFile file,
@RequestParam("md5") String md5,
@RequestParam("filename") String filename,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks) {

if (ObjectUtil.isNull(file)) {

return R.fail("上传文件不能为空");
}
Boolean b = mediaFilesService.handleChunkUpload(file, md5);
if (b){

return R.ok();
}else {

return R.fail();
}
}

Vue 3 的框架 Element Plus中,el-upload 组件用于文件上传,它底层使用的也是 multipart/form-data 这种 Content-Type 来上传文件。这是因为 multipart/form-data 允许在一个请求中发送多部分数据,包括文本字段和文件,非常适合文件上传的场景

language-html
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
30
31
32
33
34
35
36
37
<template>
<el-upload
action="http://example.com/upload"
:data="extraData"
:on-success="handleSuccess"
:on-error="handleError"
>
<el-button size="small" type="primary">点击上传</el-button>
</el-upload>
</template>

<script setup>
import {
ElMessage } from 'element-plus';
import {
ref } from 'vue';

// 额外数据
const extraData = ref({

userId: "123",
description: "这是一个文件描述"
});

const handleSuccess = (response, file, fileList) => {

// 文件上传成功的回调
ElMessage.success('文件上传成功');
};

const handleError = (err, file, fileList) => {

// 文件上传失败的回调
ElMessage.error('文件上传失败');
};
</script>

language-java
1
2
3
4
5
6
7
8
9
10
11
12
@RestController
public class FileUploadController {

@PostMapping("/upload")
public String handleFileUpload(
@RequestParam("file") MultipartFile file,
@RequestParam("userId") String userId,
@RequestParam("description") String description) {

return "文件上传成功,用户ID: " + userId + ",描述: " + description;
}
}

(二)Cookies

s
aultValue`指定的默认值 “”会被使用。

视频分块上传Vue3+SpringBoot3+Minio

视频分块上传Vue3+SpringBoot3+Minio

一、简化演示

分块上传、合并分块

前端将完整的视频文件分割成多份文件块,依次上传到后端,后端将其保存到文件系统。前端将文件块上传完毕后,发送合并请求,后端拿取文件块,合并后重新上传到文件系统。

断点续传

前端遍历文件块,每次上传之前,先询问文件块是否存在,只有不存在的情况下,才会上传。

秒传

前端分割视频文件前,先询问此视频是否已经存在,存在则不再上传,后端之间返回视频信息。前端看起来就像是被秒传了。

二、更详细的逻辑和细节问题

  • 视频文件和文件块都通过文件本身计算MD5值作为唯一标志
  • 文件系统使用Minio,只要提供buckerNamepath就可以操作文件
  • 后端合并文件块成功后会删除文件块,并以MD5值为id存入数据库
  • Minio存储文件块时,依据其md5值计算path,比如取前两个字符构建二级文件夹,文件名为md5值,无后缀。所以只需要提供文件块的md5值就可以操作文件块。
  • Minio存储完整视频文件时,依据其md5值计算path,同上,文件名为md5值,携带.mp4等后缀,所以只需要提供视频文件的md5值就可以操作视频文件。
  1. 首先,前端计算视频文件的MD5值,记为fileMd5,传递MD5值来询问后端此视频文件是否存在,后端查询数据库返回结果,如果存在,则前端触发”秒传”。
  2. 如果不存在,则将视频文件分割成文件块,循环上传,每次循环,首先计算文件块的md5值,传递md5值询问后端此文件块是否存在,后端根据md5判断文件块是否存在,如果存在,前端跳过此文件块上传,直接标记为上传成功,如果不存在,则上传至后端,后端将其保存到minio。这其实就是”分块上传,断点续传”。
  3. 最后所有分块文件都上传成功,前端发起合并请求,传递视频文件的md5值和所有文件块的md5值到后端,后端进行文件块合并、文件块的删除、合并文件的上传,将信息存储在mysql数据库,将执行结果告知前端。这就是”合并分块”

可能存在的隐患

一个视频文件的文件块没有全部上传完成就终止,此时文件块将一直保存在minio中,如果之后此视频再也没有发起过上传请求,那么这些文件块都是是一种垃圾。

可以写一个定时任务,遍历Minio没有后缀的文件块,判断其创建时间距离当前是否足够久,是则删除。

三、代码示例

前端代码

language-html
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
<template>
<div class="p-2">
<el-button icon="Plus" plain type="primary" @click="handleAdd">新增</el-button>
<!-- 添加或修改media对话框 -->
<el-dialog v-model="dialog.visible" :title="dialog.title" append-to-body width="500px">
<el-form ref="mediaFormRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="上传视频" prop="originalName" v-show="dialog.title=='添加视频'">
<el-upload
ref="uploadRef"
:http-request="onUpload"
:before-upload="beforeUpload"
:limit="1"
action="#"
class="upload-demo"
>
<template #trigger>
<el-button type="primary">选择视频</el-button>
</template>
<template #tip>
<div class="el-upload__tip">
支持分块上传、端点续传
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item v-show="percentageShow">
<el-progress :percentage="percentage" style="width: 100%"/>
</el-form-item>
</el-form>
</el-dialog>
</div>
</template>

<script lang="ts" name="Media" setup>
import type {
UploadInstance, UploadRawFile, UploadRequestOptions, UploadUserFile} from 'element-plus'
import SparkMD5 from "spark-md5";
import {
HttpStatus} from "@/enums/RespEnum";

const dialog = reactive<DialogOption>({

visible: false,
title: ''
});
//上传视频
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadImgUrl = ref(baseUrl + "/media/media/image"); // 上传的图片服务器地址
const uploadRef = ref<UploadInstance>()
const needUpload = ref(true)
const chunkSize = 5*1024*1024;

const percentage = ref(0)
const percentageShow = ref(false)

/** 新增按钮操作 */
const handleAdd = () => {

dialog.visible = true;
dialog.title = "添加视频";
percentageShow.value = false;
}

//获取文件的MD5
const getFileMd5 = (file:any) => {

return new Promise((resolve, reject) => {

let fileReader = new FileReader()
fileReader.onload = function (event) {

let fileMd5 = SparkMD5.ArrayBuffer.hash(event.target.result)
resolve(fileMd5)
}
fileReader.readAsArrayBuffer(file)
}
)
}

//在上传之前,使用视频md5判断视频是否已经存在
const beforeUpload = async (rawFile: UploadRawFile) => {

needUpload.value = true;
const fileMd5 = await getFileMd5(rawFile);
form.value.id = fileMd5;
const rsp = await getMedia(fileMd5);
if(!!rsp.data && rsp.data['id'] == fileMd5){

needUpload.value = false;
proxy?.$modal.msgWarning("视频文件已存在,请勿重复上传。文件名为"+rsp.data['originalName'])
}
}

//分块上传、合并分块
const onUpload = async (options: UploadRequestOptions) => {

if(!needUpload.value){

//秒传
percentageShow.value = true;
percentage.value = 100;
dialog.visible = false;
return;
}
percentageShow.value = true;
const file = options.file
const totalChunks = Math.ceil(file.size / chunkSize);
let isUploadSuccess = true;//记录分块文件是否上传成功
//合并文件参数
let mergeVo = {

"chunksMd5": [] as string[],
"videoMd5": undefined as string | undefined,
"videoName": file.name,
"videoSize": file.size,
"remark": undefined as string | undefined
}
//循环切分文件,并上传分块文件
for(let i=0; i<totalChunks; ++i){

const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
//计算 chunk md5
const md5 = await getFileMd5(chunk);
mergeVo.chunksMd5.push(md5);
// 准备FormData
const formData = new FormData();
formData.append('file', chunk);
formData.append('filename', file.name);
formData.append('chunkIndex', i.toString());
formData.append('totalChunks', totalChunks.toString());
formData.append('md5', md5);
//上传当前分块
try {

//先判断这个分块是否已经存在
const isExistRsp = await isChunkExist({
"md5": formData.get("md5")});
const isExist = isExistRsp.data;
//不存在则上传
if (!isExist){

const rsp = await addChunk(formData);
console.log(`Chunk ${
i + 1}/${
totalChunks} uploaded`, rsp.data);
}else {

console.log(`Chunk ${
i + 1}/${
totalChunks} is exist`);
}
percentage.value = (i)*100 / totalChunks;
} catch (error) {

isUploadSuccess = false;
console.error(`Error uploading chunk ${
i + 1}`, error);
proxy?.$modal.msgError(`上传分块${
i + 1}出错`);
break;
}
}
//合并分块文件
if(isUploadSuccess){

proxy?.$modal.msgSuccess("分块文件上传成功")
mergeVo.videoMd5 = form.value.id;//beforeUpload已经计算过视频文件的md5
//合并文件
const rsp = await mergeChunks(mergeVo);
if (rsp.code == HttpStatus.SUCCESS){

//合并文件后,实际上媒资已经插入数据库。
percentage.value = 100;
proxy?.$modal.msgSuccess("文件合并成功")
proxy?.$modal.msgSuccess("视频上传成功")
}else{

proxy?.$modal.msgSuccess("文件合并异常")
}
}else {

proxy?.$modal.msgSuccess("文件未上传成功,请重试或联系管理员")
}
}

</script>
language-javascript
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
export const getMedia = (id: string | number): AxiosPromise<MediaVO> => {

return request({

url: '/media/media/' + id,
method: 'get'
});
};

/**
* 分块文件是否存在
* */
export const isChunkExist = (data: any) => {

return request({

url: '/media/media/video/chunk',
method: 'get',
params: data
});
};

/**
* 上传分块文件
* */
export const addChunk = (data: any) => {

return request({

url: '/media/media/video/chunk',
method: 'post',
data: data
});
};

/**
* 合并分块文件
* */
export const mergeChunks = (data: any) => {

return request({

url: '/media/media/video/chunk/merge',
method: 'post',
data: data
});
};

后端代码

language-java
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
@RestController
@RequestMapping("/media")
public class MediaFilesController extends BaseController {

/**
* 获取media详细信息
*
* @param id 主键
*/
@GetMapping("/{id}")
public R<MediaFilesVo> getInfo(@NotNull(message = "主键不能为空")
@PathVariable String id) {

return R.ok(mediaFilesService.queryById(id));
}

@Log(title = "视频分块文件上传")
@PostMapping(value = "/video/chunk")
public R<String> handleChunkUpload(
@RequestParam("file") MultipartFile file,
@RequestParam("md5") String md5,
@RequestParam("filename") String filename,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks) {

if (ObjectUtil.isNull(file)) {

return R.fail("上传文件不能为空");
}
Boolean b = mediaFilesService.handleChunkUpload(file, md5);
if (b){

return R.ok();
}else {

return R.fail();
}
}

@Log(title = "分块文件是否已经存在")
@GetMapping(value = "/video/chunk")
public R<Boolean> isChunkExist(@RequestParam("md5") String md5) {

return R.ok(mediaFilesService.isChunkExist(md5));
}

@Log(title = "合并视频文件")
@PostMapping(value = "/video/chunk/merge")
public R<Boolean> mergeChunks(@RequestBody MediaVideoMergeBo bo) {

bo.setCompanyId(LoginHelper.getDeptId());
Boolean b = mediaFilesService.mergeChunks(bo);
if (b){

return R.ok();
}else {

return R.fail();
}
}
}

关于如何操作Minio等文件系统,不详细写明解释。只需要知道,给Minio提供文件本身、bucketName、path即可完成上传、下载、删除等操作。具体代码不同的包都不一样。

language-java
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
@Service
public class MediaFilesServiceImpl implements MediaFilesService {

@Autowired
private MediaFilesMapper mediaFilesMapper;

/**
* 分块文件上传
* <br/>
* 分块文件不存放mysql信息,同时文件名不含后缀,只有md5
* @param file 文件
* @param md5 md5
* @return {@link Boolean}
*/
@Override
public Boolean handleChunkUpload(MultipartFile file, String md5) {

//只上传至minio
OssClient storage = OssFactory.instance();
String path = getPathByMD5(md5, "");
try {

storage.upload(file.getInputStream(), path, file.getContentType(), minioProperties.getVideoBucket());
} catch (IOException e) {

throw new RuntimeException(e);
}
return true;
}

@Override
public Boolean isChunkExist(String md5) {

OssClient storage = OssFactory.instance();
String path = getPathByMD5(md5, "");
return storage.doesFileExist(minioProperties.getVideoBucket(), path);
}

@Override
public Boolean mergeChunks(MediaVideoMergeBo bo) {

OssClient storage = OssFactory.instance();
String originalfileName = bo.getVideoName();
String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length());
//创建临时文件,用来存放合并文件
String tmpDir = System.getProperty("java.io.tmpdir");
String tmpFileName = UUID.randomUUID().toString() + ".tmp";
File tmpFile = new File(tmpDir, tmpFileName);

try(
FileOutputStream fOut = new FileOutputStream(tmpFile);
) {

//将分块文件以流的形式copy到临时文件
List<String> chunksMd5 = bo.getChunksMd5();
chunksMd5.forEach(chunkMd5 -> {

String chunkPath = getPathByMD5(chunkMd5, "");
InputStream chunkIn = storage.getObjectContent(minioProperties.getVideoBucket(), chunkPath);
IoUtil.copy(chunkIn, fOut);
});
//合并文件上传到minio
String videoMd5 = bo.getVideoMd5();
String path = getPathByMD5(videoMd5, suffix);
storage.upload(tmpFile, path, minioProperties.getVideoBucket());
//删除分块文件
chunksMd5.forEach(chunkMd5->{

String chunkPath = getPathByMD5(chunkMd5, "");
storage.delete(chunkPath, minioProperties.getVideoBucket());
});
} catch (Exception e) {

throw new RuntimeException(e);
}finally {

if (tmpFile.exists()){

tmpFile.delete();
}
}
//上传信息到mysql
MediaFiles mediaFiles = new MediaFiles();
mediaFiles.setId(bo.getVideoMd5());
mediaFiles.setCompanyId(bo.getCompanyId());
mediaFiles.setOriginalName(originalfileName);
mediaFiles.setFileSuffix(suffix);
mediaFiles.setSize(bo.getVideoSize());
mediaFiles.setPath(getPathByMD5(bo.getVideoMd5(), suffix));
mediaFiles.setRemark(bo.getRemark());
mediaFiles.setAuditStatus(MediaStatusEnum.UNREVIEWED.getValue());
return mediaFilesMapper.insert(mediaFiles) > 0;
}

/**
* 通过md5生成文件路径
* <br/>
* 比如
* md5 = 6c4acb01320a21ccdbec089f6a9b7ca3
* <br/>
* path = 6/c/md5 + suffix
* @param prefix 前缀
* @param suffix 后缀
* @return {@link String}
*/
public String getPathByMD5(String md5, String suffix) {

// 文件路径
String path = md5.charAt(0) + "/" + md5.charAt(1) + "/" + md5;
return path + suffix;
}

}
Docker部署MySQL8主从模式

Docker部署MySQL8主从模式

文章目录


Mysql 8.1.0
Docker 24.0.5

关于主从模式,Mysql8.0一些版本开始,有许多变化,这里使用8.1.0

一、运行容器

新建两个MySQL文件夹,分别新建data文件夹和conf/my.cnf文件。
根据需要理解并更改以下脚本。

language-bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash

containerName="MyWeb02-MySQL"
MySQLData="/root/MyWeb02/MySQL/data"
MySQLConf="/root/MyWeb02/MySQL/conf/my.cnf"

containerSlaveName="MyWeb02-MySQL-Slave"
MySQLSlaveData="/root/MyWeb02/MySQL-Slave/data"
MySQLSlaveConf="/root/MyWeb02/MySQL-Slave/conf/my.cnf"

docker run -d --name "$containerName" \
-p 3307:3306 \
-v "$MySQLData":/var/lib/mysql \
-v "$MySQLConf":/etc/mysql/my.cnf \
-e MYSQL_ROOT_PASSWORD=20028888 \
mysql:8

docker run -d --name "$containerSlaveName" \
-p 3308:3306 \
-v "$MySQLSlaveData":/var/lib/mysql \
-v "$MySQLSlaveConf":/etc/mysql/my.cnf \
-e MYSQL_ROOT_PASSWORD=20028888 \
mysql:8

主节点的my.cnf容器如下

language-bash
1
2
3
[mysqld]
server-id=1
log_bin=mysql-bin

从节点的my.cnf容器如下

language-bash
1
2
[mysqld]
server-id=2

运行脚本。

二、配置主从

到主节点命令行,运行以下命令

language-sql
1
2
CREATE USER 'replica'@'%' IDENTIFIED WITH mysql_native_password BY 'replica';
GRANT REPLICATION SLAVE ON *.* TO 'replica'@'%';

到从节点命令行,运行以下命令

language-sql
1
2
3
4
5
6
7
CHANGE REPLICATION SOURCE TO 
SOURCE_HOST='172.17.0.6',
SOURCE_PORT=3306,
SOURCE_USER='replica',
SOURCE_PASSWORD='replica';
START REPLICA; //开启备份
SHOW REPLICA STATUS\G //查看主从情况

其中SOURCE_HOST为主节点容器的ip
查看主从情况时,主要注意下面两个字段是否为Yes。不是的话,就有问题,读docker logs然后去解决它。

language-bash
1
2
Slave_IO_Running: Yes
Slave_SQL_Running: Yes

Navicat等第三方软件可能不支持\G,结果以行显示。

三、测试效果

在主节点新建一个数据库

language-bash
1
create database `test`;

随后可以在从节点也看到效果。

SpringBoot后端Long数据传到前端js精度损失问题

SpringBoot后端Long数据传到前端js精度损失问题

方案一、修改后端

在对应的字段上添加注解,将Long转为String后传输。

language-java
1
2
@JsonFormat(shape = JsonFormat.Shape.STRING)
private Long payNo;

方案二、修改前端

js对应的结果接收上使用BigInt

language-js
1
2
3
4
5
6
xxx().then((res) => {

if(res){

this.payNo = String(BigInt(res.payNo))
}
SpringBoot使用当前类代理类(内部事务)解决方案

SpringBoot使用当前类代理类(内部事务)解决方案

Spring Boot 开发中,我们时常遇到需要在一个类的内部调用自己的其他方法,并且这些方法可能需要事务支持。这种场景通常发生在业务逻辑较为复杂的服务类中,其中一些操作需要确保数据的一致性和完整性。本文将以 MediaFileServiceImpl 类为例,探讨如何在 Spring Boot 中有效地使用当前类的代理类来处理内部事务。

一、场景描述

考虑一个典型的例子:在 MediaFileServiceImpl 服务类中,upload 方法需要调用 upload2Mysql 方法。这里,upload2Mysql 方法是事务性的,意味着它涉及到数据库操作,这些操作需要在一个事务中被处理。如果直接在 upload 方法中调用 upload2Mysql,由于 Spring 的代理方式,事务管理可能不会被正确应用,因为实际上是在同一个实例内部进行方法调用,绕过了 Spring 的代理。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
@Slf4j
public class MediaFileServiceImpl implements MediaFileService {


@Lazy
@Autowired
private MediaFileService mediaFileService;

@Override
public UploadFileResultDto upload() {

// ... 一些业务逻辑 ...
mediaFileService.upload2Mysql();
// ... 其他业务逻辑 ...
}

@Transactional
@Override
public MediaFiles upload2Mysql() {

// ... 事务性操作 ...
}
}

二、解决方案

1. 使用 @Lazy(推荐)

MediaFileServiceImpl 类中使用 @Lazy 注解解决循环依赖问题。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
@Slf4j
public class MediaFileServiceImpl implements MediaFileService {


@Lazy
@Autowired
private MediaFileService mediaFileService;

@Override
public UploadFileResultDto upload() {

// ... 一些业务逻辑 ...
mediaFileService.upload2Mysql();
// ... 其他业务逻辑 ...
}

@Transactional
@Override
public MediaFiles upload2Mysql() {

// ... 事务性操作 ...
}
}

2. 使用方法注入

方法注入是一种允许在运行时动态注入方法实现的技术。这里,我们通过一个简单的例子来说明如何应用方法注入。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
@Slf4j
public abstract class MediaFileServiceImpl implements MediaFileService {


@Override
public UploadFileResultDto upload() {

// ... 一些业务逻辑 ...
getMediaFileService().upload2Mysql();
// ... 其他业务逻辑 ...
}

@Transactional
@Override
public MediaFiles upload2Mysql() {

// ... 事务性操作 ...
}

@Lookup
protected abstract MediaFileService getMediaFileService();
}

3. 使用 ApplicationContext

这种方法通过 ApplicationContext 获取当前类的代理。

language-java
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
@Service
@Slf4j
public class MediaFileServiceImpl implements MediaFileService {


@Autowired
private ApplicationContext context;

private MediaFileService getMediaFileService() {

return context.getBean(MediaFileService.class);
}

@Override
public UploadFileResultDto upload() {

// ... 一些业务逻辑 ...
getMediaFileService().upload2Mysql();
// ... 其他业务逻辑 ...
}

@Transactional
@Override
public MediaFiles upload2Mysql() {

// ... 事务性操作 ...
}
}

4. 分离服务层

将事务性方法移至另一个服务类中。

language-java
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
@Service
@Slf4j
public class MediaFileServiceImpl implements MediaFileService {


@Autowired
private MediaFileTransactionalService transactionalService;

@Override
public UploadFileResultDto upload() {

// ... 一些业务逻辑 ...
transactionalService.upload2Mysql();
// ... 其他业务逻辑 ...
}
}

@Service
@Transactional
class MediaFileTransactionalService {


public MediaFiles upload2Mysql() {

// ... 事务性操作 ...
}
}

5. AspectJ 代理模式

使用 AspectJ 代理模式而不是默认的 JDK 动态代理。

language-java
1
2
3
4
5
6
@EnableAspectJAutoProxy(proxyTargetClass = true)
@Configuration
public class AppConfig {

// ... 其他配置 ...
}

然后,您的 MediaFileServiceImpl 类保持不变。

MybatisPlus二级映射和关联对象ResultMap

MybatisPlus二级映射和关联对象ResultMap

在我们的教程中,我们设计了一个课程内容的数据结构,包含章节和相关资源。这种结构非常适合在线教育平台或电子学习系统,其中课程内容需要被组织成不同的章节和子章节,每个子章节可能关联特定的学习资源。

这将是一个很好的示例来展示 MyBatis 中如何使用一对多(<collection>)和一对一(<association>)映射。

一、业务背景

1. 数据库表结构

  1. 章节表 (chapter)
    这张表包含所有章节的信息,其中包括大章节和小章节。大章节作为容器,可以包含多个小章节。
    • id (章节ID)
    • parent_id (父章节ID,用于区分大章节和小章节)
    • name (章节名称)
    • courseId (课程ID)
language-java
1
2
3
4
5
6
7
public class Chapter {

private Long id;
private Long parentId;
private String name;
private Long courseId;
}
  1. 资源表 (resource)
    这张表包含与小章节相关联的资源信息。
    • id (资源ID)
    • section_id (章节ID,关联到章节表)
    • name (资源名称)
language-java
1
2
3
4
5
6
public class Resource {

private Long id;
private Long sectionId;
private String name;
}

2. 需求

要求根据courseId查询出指定课程的信息,包括大章节、小章节、资源,并以一定结构返回,比如

language-json
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
30
31
32
33
34
35
[
{

"id": 1,
"parentId": null,
"name": "Chapter 1",
"courseId": 100,
"subChapters": [
{

"id": 11,
"parentId": 1,
"name": "Section 1.1",
"courseId": 100,
"resource": {

"id": 101,
"sectionId": 11,
"name": "Introduction Video"
}
},
{

"id": 12,
"parentId": 1,
"name": "Section 1.2",
"courseId": 100,
"resource": null
}
],
"resource": null
}
// other...
]

所以我们定义一个Dto如下

language-java
1
2
3
4
5
6
7
public class ChapterDto extends Chapter {

private List<ChapterDto> subChapters;
private Resource resource;

// 构造器、getter和setter
}

二、使用映射直接得到指定结构

ChapterMapper.xml 文件中,我们定义 SQL 查询以及结果映射。

language-xml
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
30
31
32
33
34
35
36
<mapper namespace="com.example.mapper.ChapterMapper">

<resultMap id="ChapterDtoMap" type="com.example.dto.ChapterDto">
<id column="chapter_id" property="id" />
<result column="parent_id" property="parentId" />
<result column="name" property="name" />
<result column="courseId" property="courseId" />
<collection property="subChapters" ofType="com.example.dto.ChapterDto">
<id column="sub_chapter_id" property="id" />
<result column="sub_parent_id" property="parentId" />
<result column="sub_name" property="name" />
<result column="sub_courseId" property="courseId" />
<association property="resource" javaType="com.example.model.Resource">
<id column="resource_id" property="id" />
<result column="section_id" property="sectionId" />
<result column="resource_name" property="name" />
</association>
</collection>
</resultMap>

<select id="selectChaptersWithResources" resultMap="ChapterDtoMap">
SELECT
c.id AS chapter_id, c.parent_id, c.name, c.courseId,
sc.id AS sub_chapter_id, sc.parent_id AS sub_parent_id, sc.name AS sub_name, sc.courseId AS sub_courseId,
r.id AS resource_id, r.section_id, r.name AS resource_name
FROM
chapter c
LEFT JOIN
chapter sc ON c.id = sc.parent_id
LEFT JOIN
resource r ON sc.id = r.section_id
WHERE
c.courseId = #{courseId} AND c.parent_id IS NULL
</select>

</mapper>

三、其他文件

1. Mapper

language-java
1
2
3
4
public interface ChapterMapper {

List<ChapterDto> selectChaptersWithResources(Long courseId);
}

2. Service

language-java
1
2
3
4
5
6
7
8
9
10
11
@Service
public class ChapterService {

@Autowired
private ChapterMapper chapterMapper;

public List<ChapterDto> getChaptersWithResources(Long courseId) {

return chapterMapper.selectChaptersWithResources(courseId);
}
}

3. Controller

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RestController
@RequestMapping("/chapters")
public class ChapterController {

@Autowired
private ChapterService chapterService;

@GetMapping("/{courseId}")
public ResponseEntity<List<ChapterDto>> getChapters(@PathVariable Long courseId) {

List<ChapterDto> chapters = chapterService.getChaptersWithResources(courseId);
return ResponseEntity.ok(chapters);
}
}

四、概念理解

一级映射

在提供的 resultMap 中,一级映射是针对 ChapterDto类的直接属性的映射。这意味着数据库中的列(如 chapter_id, parent_id等)直接映射到 ChapterDto类的相应属性(如 id, parent_id等),这部分映射是非常直接的。

二级映射

二级映射用于处理复杂的对象关系,比如当一个对象包含其他对象或对象的集合时。这通常在处理一对多关系时出现,例如,一个章节结构(ChapterDto)可能包含多个子章节。

聚合

这种聚合是根据您在 <collection> 标签中定义的规则进行的。MyBatis 会识别哪些行应该被映射为独立的实例,哪些行应该作为子元素聚合到其他实例中。

五、标签使用

1. 标签

用途:用于映射一对多关系。在这个例子中,ChapterDto类包含一个 Chapter 类型的列表,这代表了大章节和小章节之间的一对多关系。

常用属性

  • property:指定要映射到的目标属性名称。
  • ofType:指定集合中元素的类型。

2. 标签

用途:用于映射一对一关系。在您的例子中,ChapterDto包含一个 Resource 类型的属性,这代表了小章节和资源之间的一对一关系。
常用属性

  • property:指定要映射到的目标属性名称。
  • javaType:指定关联对象的类型。
spring-boot-starter-validation常用注解

spring-boot-starter-validation常用注解

一、使用

要使用这些注解,首先确保在你的 Spring Boot 应用的 pom.xml 文件中添加了 spring-boot-starter-validation 依赖。然后,你可以将这些注解应用于你的模型类字段上。在你的控制器方法中,你可以使用 @Valid@Validated 注解来触发验证,例如:

language-java
1
2
3
4
5
6
@PostMapping("/users")
public ResponseEntity<?> createUser(@Valid @RequestBody User user) {

// 如果存在验证错误,会抛出异常
// 正常业务逻辑
}

在这个例子中,如果 User 对象的字段不满足注解定义的验证规则,Spring 将抛出一个异常,你可以通过全局异常处理或控制器层的异常处理来处理这些异常,并向用户返回适当的响应。

二、常用注解

spring-boot-starter-validation 依赖包引入了 Java Bean Validation API(通常基于 Hibernate Validator 实现),提供了一系列注解来帮助你对 Java 对象进行验证。以下是一些常用的验证注解及其含义和使用方式:

  1. @NotNull : 确保字段不是 null

    language-java
    1
    2
    3
    4
    5
    6
    public class User {

    @NotNull(message = "用户名不能为空")
    private String username;
    // 其他字段和方法
    }
  2. @NotEmpty : 确保字段既不是 null 也不是空(对于字符串意味着长度大于0,对于集合意味着至少包含一个元素)。

    language-java
    1
    2
    3
    4
    5
    6
    public class User {

    @NotEmpty(message = "密码不能为空")
    private String password;
    // 其他字段和方法
    }
  3. @NotBlank : 确保字符串字段不是 null 且至少包含一个非空白字符。

    language-java
    1
    2
    3
    4
    5
    6
    public class User {

    @NotBlank(message = "邮箱不能为空且不能只包含空格")
    private String email;
    // 其他字段和方法
    }
  4. @Size: 确保字段(字符串、集合、数组)符合指定的大小范围。

    language-java
    1
    2
    3
    4
    5
    6
    public class User {

    @Size(min = 2, max = 30, message = "用户名长度必须在2到30之间")
    private String username;
    // 其他字段和方法
    }
  5. @Min@Max: 对数值类型字段设置最小值和最大值。

    language-java
    1
    2
    3
    4
    5
    6
    7
    public class User {

    @Min(value = 18, message = "年龄必须大于等于18")
    @Max(value = 100, message = "年龄必须小于等于100")
    private int age;
    // 其他字段和方法
    }
  6. @Email: 确保字段是有效的电子邮件地址。

    language-java
    1
    2
    3
    4
    5
    6
    public class User {

    @Email(message = "无效的邮箱格式")
    private String email;
    // 其他字段和方法
    }
  7. @Pattern: 确保字符串字段匹配正则表达式。

    language-java
    1
    2
    3
    4
    5
    6
    public class User {

    @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "用户名只能包含字母和数字")
    private String username;
    // 其他字段和方法
    }
  8. @Positive@PositiveOrZero: 确保数值字段是正数或者正数和零。

    language-java
    1
    2
    3
    4
    5
    6
    public class Product {

    @Positive(message = "价格必须是正数")
    private BigDecimal price;
    // 其他字段和方法
    }

三、@Valid or @Validated ?

@Valid@Validated 注解都用于数据验证,但它们在使用和功能上有一些差异:

  1. @Valid:

    • 来源于 JSR 303/JSR 380 Bean Validation API。
    • 可以用在方法参数上,以触发对传递给该方法的对象的验证。这通常在 Spring MVC 中用于验证带有 @RequestBody@ModelAttribute 注解的参数。
    • 不支持验证组的概念,这意味着不能控制验证的顺序或验证特定的子集。

    示例:

    language-java
    1
    2
    3
    4
    5
    @PostMapping("/users")
    public ResponseEntity<?> createUser(@Valid @RequestBody User user) {

    // 业务逻辑
    }
  2. @Validated(推荐):

    • 是 Spring 的特有注解,不是 JSR 303/JSR 380 的一部分。
    • 支持验证组,允许您更灵活地指定在特定情况下应用哪些验证约束。例如,可以根据不同的操作(如创建、更新)定义不同的验证规则。
    • 可以用在类型级别(在类上)和方法参数上。在类型级别使用时,它会触发该类中所有带有验证注解的方法的验证。

    示例:

    language-java
    1
    2
    3
    4
    5
    @PostMapping("/users")
    public ResponseEntity<?> createUser(@Validated @RequestBody User user) {

    // 业务逻辑
    }

在实际使用中,如果你需要简单的验证功能,@Valid 是一个很好的选择。如果你需要更复杂的验证逻辑,比如验证组,那么 @Validated 更适合。此外,@Validated 可以应用在类级别,从而对一个类的多个方法进行验证,这在使用 Spring 服务层时非常有用。

四、分组校验

分组校验(Group Validation)是一种在 Java Bean Validation 中用于在不同上下文中应用不同验证规则的方法。这对于那些在不同情况下(例如,创建 vs 更新)需要不同验证规则的对象特别有用。

1. 分组校验的基本概念

在分组校验中,你可以定义多个接口(通常为空)来表示不同的验证组。然后,你可以在验证注解中指定这些接口,以表明该注解仅在验证特定组时应用。

例如,你可能有一个User类,其中某些字段在创建用户时是必需的,但在更新用户时可能是可选的。

2. 定义验证组

首先,定义两个空接口作为验证组:

language-java
1
2
3
4
public interface OnCreate {
}
public interface OnUpdate {
}

3. 应用分组到模型

然后,在你的模型类中使用这些接口作为验证注解的参数:

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class User {


@NotNull(groups = OnCreate.class)
private Long id;

@NotBlank(groups = {
OnCreate.class, OnUpdate.class})
private String username;

@Email(groups = {
OnCreate.class, OnUpdate.class})
private String email;

// 其他字段和方法
}

在这个例子中,id 字段仅在创建用户时需要验证(OnCreate组),而 usernameemail 字段在创建和更新用户时都需要验证。

4. 在控制器中使用分组

最后,在你的控制器方法中,使用 @Validated 注解指定要应用的验证组:

language-java
1
2
3
4
5
6
7
8
9
10
11
@PostMapping("/users")
public ResponseEntity<?> createUser(@Validated(OnCreate.class) @RequestBody User user) {

// 创建用户的业务逻辑
}

@PutMapping("/users")
public ResponseEntity<?> updateUser(@Validated(OnUpdate.class) @RequestBody User user) {

// 更新用户的业务逻辑
}

在这个例子中,createUser 方法只会验证属于 OnCreate 组的字段,而 updateUser 方法则只会验证属于 OnUpdate 组的字段。这样,你就可以根据不同的操作自定义验证逻辑了。

5. 默认组如何总是被校验

当字段没有指定groups时,属于默认组,当@Validated(OnUpdate.class)指定了特定的组后,属于默认组的字段将不再被校验,一个个都加上groups太麻烦,如何让默认组字段总是被校验呢?
如下

language-java
1
2
3
4
5
import jakarta.validation.groups.Default;
public interface OnCreate extends Default{
}
public interface OnUpdate extends Default{
}

6. 总结

通过使用分组校验,你可以为同一个对象的不同操作设置不同的验证规则,这在复杂应用中非常有用。这种方法提高了代码的灵活性和可维护性。

SpringBoot自定义全局异常处理器

SpringBoot自定义全局异常处理器

一、介绍

Springboot框架提供两个注解帮助我们十分方便实现全局异常处理器以及自定义异常

  • @ControllerAdvice@RestControllerAdvice(推荐)
  • @ExceptionHandler

二、实现

1. 定义全局异常处理器

定义GlobalExceptionHandler类,拦截所有异常。
@RestControllerAdvice注解使得你可以在GlobalExceptionHandler 中处理异常,@ExceptionHandle注解用于将指定异常绑定到处理的函数上。如下使用@ExceptionHandler(Exception.class)即对所有异常进行捕获处理。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {


@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse exception(Exception e){

//record log
log.error("系统异常{}", e.getMessage(),e);
//decode errorException
String errMessage = "系统异常";
return new RestErrorResponse(errMessage);
}
}
language-java
1
2
3
4
5
6
@Data
@AllArgsConstructor
public class RestErrorResponse implements Serializable {

private String errMessage;
}

事实上,写到这里已经可以用了,RestErrorResponse 用来承载错误信息到前端,因为@RestControllerAdvice已经包含了@ResponseBody

2. 自定义异常类

继承RuntimeException 异常类写一个自定义的异常类。这么做主要是能够使用自定义的枚举类来更优雅的抛出错误。

language-java
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
@Data
public class XueChengPlusException extends RuntimeException {


private String errMessage;

public XueChengPlusException() {

super();
}

public XueChengPlusException(String errMessage) {

super(errMessage);
this.errMessage = errMessage;
}

public static void cast(CommonError commonError){

throw new XueChengPlusException(commonError.getErrMessage());
}
public static void cast(String errMessage){

throw new XueChengPlusException(errMessage);
}

}
language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Getter
public enum CommonError {

UNKOWN_ERROR("执行过程异常,请重试。"),
PARAMS_ERROR("非法参数"),
OBJECT_NULL("对象为空"),
QUERY_NULL("查询结果为空"),
REQUEST_NULL("请求参数为空");

private String errMessage;

private CommonError( String errMessage) {

this.errMessage = errMessage;
}
}

同时,对于GlobalExceptionHandler 也要做一些修改,一方面处理自定义异常,另一方处理其余异常。

language-java
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
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {


@ExceptionHandler(XueChengPlusException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse customException(XueChengPlusException e){

//record log
log.error("系统异常{}", e.getErrMessage(),e);
//decode errorException
String errMessage = e.getErrMessage();
return new RestErrorResponse(errMessage);
}

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse exception(Exception e){

//record log
log.error("系统异常{}", e.getMessage(),e);
//decode errorException
String errMessage = CommonError.UNKOWN_ERROR.getErrMessage();
return new RestErrorResponse(errMessage);
}
}

三、使用

在程序中任意地方抛出异常,controllerservicedao层都可以,比如

language-java
1
throw new RuntimeException("价格不能为空且必须大于0");

这时走的就是

language-java
1
2
@ExceptionHandler(Exception.class)
public RestErrorResponse exception(Exception e)

除此之外,可以这样抛出自定义异常,比如

language-java
1
XueChengPlusException.cast(CommonError.PARAMS_ERROR);
language-java
1
XueChengPlusException.cast("其他的消息");
language-java
1
throw new XueChengPlusException(CommonError.OBJECT_NULL.getErrMessage());
language-java
1
throw new XueChengPlusException("其他的消息");

这时走的就是

language-java
1
2
@ExceptionHandler(XueChengPlusException.class)
public RestErrorResponse customException(XueChengPlusException e)

四、疑问

Q:疑问,XueChengPlusException异常类继承自RuntimeException ,而RuntimeException 继承自Exception,为什么触发customException而不是exception?

在这个全局异常处理器中,当抛出一个XueChengPlusException异常时,它会被customException(XueChengPlusException e)方法处理,而不是exception(Exception e)方法。

这是因为Spring框架的异常处理机制会优先匹配最具体的异常类型。在您的代码中,XueChengPlusExceptionRuntimeException(以及Exception)的子类,因此它更具体。所以,当抛出一个XueChengPlusException异常时,Spring会优先调用处理XueChengPlusException的方法,而不是处理Exception的方法。

这种行为确实表明全局异常处理器有一定的优先级和覆盖逻辑。具体来说,处理器会优先处理更具体的异常类型,如果没有找到匹配的处理器,那么它会寻找处理更一般异常类型的处理器。

SpringBoot3整合MyBatisPlus

SpringBoot3整合MyBatisPlus

一、起因

随着SpringBoot3的发布,mybatisplus也在不断更新以适配spirngboot3 。目前仍然处于维护升级阶段,最初2023.08时,官方宣布对SpringBoot3的原生支持,详情看这里

但是对于较新版本的SpringBoot3,仍然有很多bug,甚至无法启动,摸爬滚打又游历社区后,实践后得到一套成功的版本搭配,具体如下

Version
Java 17
Spring Boot 3.2.1
Spring Cloud 2023.0.0
Mybatis Plus 3.5.5

二、引入依赖

language-xml
1
2
3
4
5
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>

同时官方也提供了Demo项目,详情看这里,里面使用的版本搭配如下

Version
Java 17
Spring Boot 3.2.0
Mybatis Plus 3.5.5-SNAPSHOT
SpringBoot3整合OpenAPI3(Swagger3)

SpringBoot3整合OpenAPI3(Swagger3)

swagger2更新到3后,再使用方法上发生了很大的变化,名称也变为OpenAPI3

官方文档

一、引入依赖

language-xml
1
2
3
4
5
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc-openapi.version}</version>
</dependency>
language-yml
1
2
3
4
5
6
7
8
9
10
server:
servlet:
context-path: /content
springdoc:
api-docs:
enabled: true
path: /v3/api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html

openapi3使用十分方便,做到这里后,你可以直接通过以下网址访问swagger页面。

language-html
1
http://<ip>:<port>/content/swagger-ui/index.html

二、使用

1. @OpenAPIDefinition + @Info

用于定义整个 API 的信息,通常放在主应用类上。可以包括 API 的标题、描述、版本等信息。

language-java
1
2
3
4
5
6
7
8
9
10
@SpringBootApplication
@Slf4j
@OpenAPIDefinition(info = @Info(title = "内容管理系统", description = "对课程相关信息进行管理", version = "1.0.0"))
public class ContentApplication {

public static void main(String[] args) {

SpringApplication.run(ContentApplication.class, args);
}
}

2. @Tag

用于对 API 进行分组。可以在控制器类或方法级别上使用。

language-java
1
2
3
4
5
6
@Tag(name = "课程信息编辑接口")
@RestController("content")
public class CourseBaseInfoController {

}

3. @Operation

描述单个 API 操作(即一个请求映射方法)。可以提供操作的摘要、描述、标签等。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
@Operation(summary = "课程查询接口")
@PostMapping("/course/list")
public PageResult<CourseBase> list(
PageParams params,
@RequestBody(required = false) QueryCourseParamsDto dto){


CourseBase courseBase = new CourseBase();
courseBase.setCreateDate(LocalDateTime.now());

return new PageResult<CourseBase>(new ArrayList<CourseBase>(List.of(courseBase)),20, 2, 10);
}

4. @Parameter

用于描述方法参数的额外信息,例如参数的描述、是否必需等。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
@Operation(summary = "课程查询接口")
@PostMapping("/course/list")
public PageResult<CourseBase> list(
@Parameter(description = "分页参数") PageParams params,
@Parameter(description = "请求具体内容") @RequestBody(required = false) QueryCourseParamsDto dto){


CourseBase courseBase = new CourseBase();
courseBase.setCreateDate(LocalDateTime.now());

return new PageResult<CourseBase>(new ArrayList<CourseBase>(List.of(courseBase)),20, 2, 10);
}

5. @Schema

描述模型的结构。可以用于类级别(标注在模型类上)或字段级别。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageParams {

//当前页码
@Schema(description = "页码")
private Long pageNo = 1L;

//每页记录数默认值
@Schema(description = "每页条目数量")
private Long pageSize =10L;
}

6. @ApiResponse

描述 API 响应的预期结果。可以指定状态码、描述以及返回类型。

language-java
1
2
3
4
@ApiResponse(responseCode = "200", description = "Successfully retrieved user")
public User getUserById(@PathVariable Long id) {

}