ruoyi-cloud-plus添加一个不要认证的公开新页面

ruoyi-cloud-plus添加一个不要认证的公开新页面

版本
RuoYiCloudPlus v2.1.2
plus-ui Vue3 ts

以新增一个公开的课程搜索页面为例。

一、前端

1. 组件创建

在view目录下创建一个页面的vue代码,比如

language-bash
1
src/views/customer/searchPage/index.vue

2. src/router/index.ts

为其编制一个路由。在constantRoutes中添加一组dict信息。比如

language-typescript
1
2
3
4
5
6
{

path: '/courseSearch',
component: () => import('@/views/customer/searchPage/index.vue'),
hidden: true
},

3. src/permission.ts

把页面加入前端的whiteList

language-typescript
1
2
3
4
5
6
const whiteList = [
'/login',
'/register',
'/social-callback',
'/courseSearch'
];

在浏览器输入http://localhost/courseSearch,至此这个页面已经不用登录就可以访问了。

二、后端

但是后端是有网关和认证模块的,虽然前端页面可以不用登陆了,但是如果这个页面还需要从后端获取数据,那么后端对应的controller也应该被open。

1. 设计思想

不同模块有不同的url前缀,比如

language-txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
routes:
# 认证中心
- id: ruoyi-auth
uri: lb://ruoyi-auth
predicates:
- Path=/auth/**
filters:
- StripPrefix=1
# 代码生成
- id: ruoyi-gen
uri: lb://ruoyi-gen
predicates:
- Path=/tool/**
filters:
- StripPrefix=1

并且每个模块都有可能需要open一些controller,不需要认证。那么我们进行统一设定,比如课程模块,url前缀为course,那么/course/open/**就都是被公开的端点。于是在gateway只需要把/*/open/**加入白名单即可。

2. ruoyi-gateway.yml

在nacos中修改gateway的配置文件,把/*/open/**加入whites。

language-yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
security:
ignore:
whites:
- /auth/code
- /auth/logout
- /auth/login
- /auth/binding/*
- /auth/social/callback
- /auth/register
- /auth/tenant/list
- /resource/sms/code
- /*/v3/api-docs
- /*/error
- /csrf
- /*/open/**

3. 开发Controller

在course模块中,新建一个CourseOpenController.java,内容示例如下

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
package org.dromara.course.controller;

import org.dromara.course.domain.bo.CourseCategoryBo;
import org.dromara.course.domain.vo.CourseCategoryVo;
import org.dromara.course.service.CourseCategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/open")
public class CourseOpenController {


@Autowired
CourseCategoryService categoryService;


@GetMapping("/category/list")
public List<CourseCategoryVo> list(CourseCategoryBo bo) {

return categoryService.queryList(bo);
}


}

重启网关和课程模块即可。

Vue3炫酷商品卡牌 组件设计

Vue3炫酷商品卡牌 组件设计

感谢来自BinaryMoon-CSS 艺术之暗系魔幻卡牌的博文。💕

演示

代码

接口类型

language-typescript
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
export interface CourseBaseVO {

/**
* 主键
*/
id: string | number;

/**
* 机构ID
*/
companyId: string | number;

/**
* 课程名称
*/
name: string;

/**
* 大分类
*/
mt: string;

/**
* 小分类
*/
st: string;

/**
* 课程图片
*/
pic: string;

/**
* 是否收费
*/
charge: boolean;

/**
* 原价
*/
originalPrice: number;

/**
* 现价
*/
price: number;

/**
* 评分
*/
star: number;

/**
* UNPUBLISHED(1, "未发布"), UNDER_REVIEW(2, "审核中"), REVIEW_FAILED(3, "审核不通过"), REVIEW_PASSED(4, "审核通过")
*/
status: number;

/**
* 审核意见
*/
mind: string;

}
interface CourseBaseExtraHotVo extends CourseBaseVO {

isHot: boolean;
}

外部资源wave_orange.svg

language-txt
1
<svg width="100%" height="100%" id="svg" viewBox="0 0 1440 490" xmlns="http://www.w3.org/2000/svg" class="transition duration-300 ease-in-out delay-150"><defs><linearGradient id="gradient" x1="0%" y1="51%" x2="100%" y2="49%"><stop offset="5%" stop-color="#fcb900"></stop><stop offset="95%" stop-color="#ff6900"></stop></linearGradient></defs><path d="M 0,500 L 0,0 C 90.96650717703349,54.02870813397129 181.93301435406698,108.05741626794259 268,115 C 354.066985645933,121.94258373205741 435.23444976076553,81.79904306220095 535,84 C 634.7655502392345,86.20095693779905 753.129186602871,130.7464114832536 867,132 C 980.870813397129,133.2535885167464 1090.248803827751,91.2153110047847 1185,62 C 1279.751196172249,32.78468899521531 1359.8755980861245,16.392344497607656 1440,0 L 1440,500 L 0,500 Z" stroke="none" stroke-width="0" fill="url(#gradient)" fill-opacity="0.53" class="transition-all duration-300 ease-in-out delay-150 path-0"></path><defs><linearGradient id="gradient" x1="0%" y1="51%" x2="100%" y2="49%"><stop offset="5%" stop-color="#fcb900"></stop><stop offset="95%" stop-color="#ff6900"></stop></linearGradient></defs><path d="M 0,500 L 0,0 C 111.98086124401911,108.89952153110048 223.96172248803822,217.79904306220095 335,271 C 446.0382775119618,324.20095693779905 556.133971291866,321.7033492822967 626,309 C 695.866028708134,296.2966507177033 725.5023923444976,273.3875598086125 820,274 C 914.4976076555024,274.6124401913875 1073.8564593301435,298.7464114832536 1188,257 C 1302.1435406698565,215.25358851674642 1371.0717703349283,107.62679425837321 1440,0 L 1440,500 L 0,500 Z" stroke="none" stroke-width="0" fill="url(#gradient)" fill-opacity="1" class="transition-all duration-300 ease-in-out delay-150 path-1"></path></svg>

组件源码

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
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
<template>
<div
id="card"
style="padding: 5px;margin: 20px"
>
<el-card
shadow="hover"
style="width: 350px; border-radius: 10px"
>
<div class="wave-orange-card"></div>
<div style="display: flex;flex-direction: column;justify-content: center;align-items: center;">
<el-image
:src="fileBaseUrl+courseBase.pic"
fit="fill"
style="width: 200px"
/>
</div>
<div style="height: 30px; font-size: 20px;margin-top: 10px">
<span>{
{ courseBase.name }}</span>
</div>
<div style="height: 40px; ">
<el-rate
v-model="courseBase.star"
size="large"
show-score
text-color="#ff9900"
:score-template="courseBase.star.toString() + 'points'"
disabled
/>
</div>
<div style="height: 40px; ">
<el-tag v-if="courseBase.charge" type="warning" size="large" effect="light">
<span style="font-size: 20px;font-weight: bold">¥{
{ courseBase.price }}</span>
&nbsp;&nbsp;
<span class="slash-deleted-text" style="font-size: 14px;color: #909399">{
{ courseBase.originalPrice }}</span>
</el-tag>
<el-tag v-else type="success" size="large"><span style="font-size: 20px">免费</span></el-tag>
<span>&nbsp;&nbsp; 6w人报名</span>
</div>
</el-card>
</div>
</template>

<script lang="ts" setup>

import {
CourseBaseVO} from "@/api/course/types";
import {
PropType} from "vue";

const fileBaseUrl = import.meta.env.VITE_APP_MINIO_FILE_URL;
interface CourseBaseExtraHotVo extends CourseBaseVO {

isHot: boolean;
}
const props = defineProps({

courseBase: Object as PropType<CourseBaseExtraHotVo>,
});
const emit = defineEmits(['update:courseBase'])


</script>

<style scoped>

/* 卡片图片背景 */
:deep(.wave-orange-card){

background-image: url("src/assets/svg/wave_orange.svg");
background-repeat: no-repeat;
background-size: cover; /* 或使用 100% 100% 来确保完全覆盖 */
background-position: center; /* 根据需要调整 */
overflow: hidden; /* 避免内容溢出 */
position: absolute; /* 固定定位,不随滚动条移动 */
width: 310px; /* card的宽度为350 */
height: 200px; /*pic的大小为200*200*/
opacity: 0.6;
}

/* 删除线 */
:deep(.slash-deleted-text) {

position: relative;
overflow: hidden; /* 防止斜线溢出容器 */
}

:deep(.slash-deleted-text::after) {

content: '';
position: absolute;
left: 0;
top: 10%; /* 调整为文本高度的一半 */
width: 100%; /* 与容器同宽 */
border-bottom: 1px solid #F56C6C; /* 删除线的样式 */
transform: rotate(25deg); /* 调整角度为倾斜 */
transform-origin: left bottom;
}

/* 卡片背景 */
:deep(:root) {

--margin: 100px;
/* 上演一出黄金分割率的好戏 */
--card-width: 360px;
/* 上演一出黄金分割率的好戏 */
--card-height: calc(var(--card-height) * 1.618);
}

#card{

width: var(--card-width);
height: var(--card-height);
position: relative;
cursor: pointer;
transition: transform 0.4s ease; /* 设置放大动画的过渡效果为2秒 */
}

/* 定义自定义属性 --rotate */
@property --rotate{

/* 自定义属性的默认值 */
initial-value: 90deg;
/*
定义自定义属性允许的语法结构,
此处定义该元素仅接受角度值。
*/
syntax: '<angle>';
/* 定义该自定义属性是否允许被其他元素所继承 */
inherits: false;
}

/* 定义动画 */
@keyframes edge{

from{

--rotate: 0deg;
}
to{

--rotate: 360deg;
}
}

#card::before{

content: '';
width: 104%;
height: 102%;
background: linear-gradient(var(--rotate),
rgb(44, 251, 255), rgb(81, 154, 255), rgb(97, 57, 242));
position: absolute;
z-index: -1;
top: -1%;
left: -2%;
/* 设置边框圆角半径 */
border-radius: 0.5vw;
/*
为当前元素指定使用的动画,并将该动画的
持续时间设置为 3.5s,动画速度保持不变,
动画播放次数为无限次。
*/
animation: edge 10s linear infinite;
}

#card::after{

content: '';
width: 80%;
height: 100%;
background: linear-gradient(var(--rotate),
rgb(44, 251, 255), rgb(81, 154, 255), rgb(97, 57, 242));
position: absolute;
top: 5%;
left: 10%;
filter: blur(2vw);
z-index: -1;
/* 使用动画 */
animation: edge 3.5s linear infinite;
}

/* 卡片悬浮变化背景 */
#card:hover {

transform: scale(1.02); /* 鼠标悬浮时放大到1.1倍 */
}

#card::before, #card::after {

transition: background 1s ease; /* 将过渡应用于background,确保背景渐变的平滑变化 */
}

#card:hover::before, #card:hover::after {

background: linear-gradient(var(--rotate), #f82747, #fc5c7c, #ffc3d3); /* 渐变为淡红色 */
}

</style>

使用示例

language-html
1
2
3
4
5
6
7
8
9
<template>
<CourseCard
v-for="(course, index) in hotList"
:course-base="course"
/>
</template>
<script lang="ts" setup>
const hotList = ref<CourseBaseExtraHotVo[]>([]);
</script>
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;
}

}
Redis基础文档-01-安装

Redis基础文档-01-安装

参考文档-Redis快速入门指南-中文
参考文档-Redis 教程

一、启动并连接

本地启动一个redis或者用redis cloud免费账户,都可以。

language-bash
1
docker run --name CommonTrain -p 6379:6379 -itd redis:7.2

然后下载REDISINSIGHT

二、支持的数据类型

  • 字符串string
  • 哈希hash
  • 列表list
  • 集合set
  • 有序集合sorted set
  • 位图bitmaps
  • 基数统计hyperLogLogs
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`;

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

Java - JVM

Java - JVM

一、JVM

1. JVM的作用

Java代码编译成java字节码后,运行在JVM中,只要针对不同的系统都开发JVM后,java就实现了跨平台。

2. JVM、JRE、JDK的关系

3. JVM的组成

  1. 类加载器(ClassLoader)
  2. 运行时数据区(Runtime Data Area)
  3. 执行引擎(Execution Engine)
  4. 本地库接口(Native Interface)

4. JVM工作流程

ClassLoader负责加载字节码文件,至于是否可以运行,由Execution Engine决定。
Execution Engine把指令和数据信息加载到内存中,并且负责把命令解释到操作系统,将JVM指令集翻译成操作系统指令集。
Execution Engine执行指令时,可能会调用一些本地的接口,这时就需要用到Native Interface,主要负责调用本地的接口给java程序使用,会在本地方法栈中记录对应的本地方法。

5. 运行时方法区Runtime Data Area

是JVM最重要的部分,运行时数据区的组成主要包含以下

  1. PC Register(程序计数器)
    程序计数器是程序控制流的指示器,循环,跳转,异常处理,线程的恢复等工作都需要依赖程序计数器去完成。程序计数器是线程私有的,它的生命周期是和线程保持一致的,我们知道,N 个核数的 CPU 在同一时刻,最多有 N个线程同时运行,在我们真实的使用过程中可能会创建很多线程,JVM 的多线程其实是通过线程轮流切换,分配处理器执行时间来实现的。既然涉及的线程切换,所以每条线程必须有一个独立的程序计数器。
  2. Stack (Java虚拟机栈)
    虚拟机栈,其描述的就是线程内存模型,也可以称作线程栈,也是每个线程私有的,生命周期与线程保持一致。在每个方法执行的时候,jvm 都会同步创建一个栈帧去存储局部变量表,操作数栈,动态连接,方法出口等信息。一个方法的生命周期就贯彻了一个栈帧从入栈到出栈的全部过程。
  3. Native Method Stack (本地方法栈)
    本地方法栈的概念很好理解,我们知道,java底层用了很多c的代码去实现,而其调用c端的方法上都会有native,代表本地方法服务,而本地方法栈就是为其服务的。
  4. Heap(堆)
    可以说是 JVM 中最大的一块儿内存区域了,它是所有线程共享的,不管你是初学者还是资深开发,多少都会听说过堆,毕竟几乎所有的对象都会在堆中分配。
  5. Method Area(方法区)
    方法区也是所有线程共享的区域,它存储了被 jvm 加载的类型信息、常量、静态变量等数据。运行时常量池就是方法区的一部分,编译期生成的各种字面量与符号引用就存储在其中。

💡 随着Java 7及以后版本的发布,虽然字符串常量池被移至堆内存,运行时常量池仍然是方法区(或Java 8中的元空间)的一部分。
💡 在Java 8之前,方法区的实现被称为永久代(PermGen),并且是堆的一部分。所以,当我们说”方法区”时,从概念上讲,它是JVM的一个独立逻辑部分,但在HotSpot JVM的具体实现中,直到Java 7为止,它是作为堆内存结构的一个部分(即永久代)来实现的,永久代是堆的一个物理部分。
💡从Java 8开始,HotSpot JVM去除了永久代的概念,引入了元空间(Metaspace),并且元空间是在本地内存中,而不是在堆内存中。因此,在Java 8及以后的版本中,方法区的实现从永久代变为了元空间,方法区(现在通常指的是元空间)与堆内存是完全分开的。

💡 元空间在本地内存中,只要内存足够,就不会出现OOM(Out of Memory)。元空间的概念仍然在JVM内存模型中。

二、深入JVM内存模型(JMM)

。。。待续

JavaSE-多线程

JavaSE-多线程

1.创建线程

创建线程有三种方式,都需要子类,然后在主程序中使用它。

其一

继承Thread类,重写run方法,这种方式简单,但是没法继承其他类。

language-java
1
2
3
4
5
6
7
8
9
10
11
public class Test01_01 extends Thread{

@Override
public void run() {

for (int i = 0; i < 5; i++) {

logger.info("thread sout:"+i);
}
}
}

运行方式如下

language-java
1
2
Thread t = new Test01_01();
t.start();

其二

实现Runnable接口,重写run方法,这种方式避免了无法继承别的类的缺陷。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
public class Test02_01 implements Runnable{

@Override
public void run() {

Thread cut = Thread.currentThread();
for (int i = 0; i < 5; i++) {

System.out.println("thread ["+cut.getName()+"]"+i);
}
}
}

运行方式如下

language-java
1
2
Runnable target = new Test02_01();
new Thread(target).start();

或者使用lambda表达式

language-java
1
2
3
4
5
6
7
8
new Thread(()->{

Thread cut = Thread.currentThread();
for (int i = 0; i < 5; i++) {

System.out.println("thread ["+cut.getName()+"]"+i);
}
}).start();

其三

实现Callable接口,重写call方法,这种方式可以取得线程的返回值。

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static class Test04_01 implements Callable<Long>{

@Override
public Long call() throws Exception {

Thread cut = Thread.currentThread();
System.out.println("当前线程:"+cut.getName());
long res = 0;
for (int i = 0; i < 100; i++) {

for (int j = 0; j < 100; j++) {

res += j;
}
}
return res;
}
};

运行方式如下

language-java
1
2
3
4
5
Callable<Long> call = new Test04_01();
FutureTask<Long> task = new FutureTask<>(call);
new Thread(task).start();
Long l = task.get();
System.out.println(l);

2.线程常用方式

3.线程同步

三种办法

其一

同步代码块,在类中使用

language-java
1
2
3
4
synchronized (LockObject){

//your code
}

对于示例方法,使用this作为LockObject,
对于静态方法,使用类名.class作为LockObject

其二

同步方法,在方法前加上synchronized 关键字
同步方法其实底层也是有隐式锁对象的,只是锁的范围是整个方法代码。
如果方法是实例方法:同步方法默认用this作为的锁对象。
如果方法是静态方法:同步方法默认用类名.class作为的锁对象。

其三

Lock锁,定义一把锁,需要加锁时lock,结束时unlock,

language-java
1
2
3
4
5
6
7
8
9
10
11
12
private final Lock lk = new ReentrantLock();
try {

lk.lock();
money -= 1;
} catch (Exception e) {

throw new RuntimeException(e);
} finally {

lk.unlock();
}

4.线程池

创建线程的开销是很大的。使用线程池可以复用线程,迭代任务不迭代线程,这样意味着,我们只能只用Runnable和Callable的方式开多线程。

创建线程池

language-java
1
2
3
4
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(3, 5, 8, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(4),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy());

七个参数的意思分别是:

  1. 核心线程数(一直存活的线程数)
  2. 最大线程数(最大-核心=允许存在的临时线程数)
  3. 临时线程存活时间(如果临时线程超过这个时间没有被分配任务就消亡)
  4. 存活时间单位
  5. 等待队列(ArrayBlockingQueue对象,需指定长度,被线程拿走的任务不算在等待队列)
  6. 线程工厂(使用Executors工具类即可)
  7. 拒绝策略(线程都在忙,等待队列满了,新的任务怎么处理)

临时线程的开启条件为,一个新任务来了,核心线程都在忙,同时等待队列已满,同时线程数还没达到最大线程数时,临时线程被创建,拿走新任务。

线程池常用方法

执行Runnable任务如下

language-java
1
2
Runnable target1 = new MyRunnable();
threadPool.execute(target1);// core 1

执行Callable任务如下

language-java
1
2
Callable<Integer> callableTask = new MyCallable();
Future<Integer> future = threadPool.submit(callableTask);

拒绝策略

线程池工具类

这样就不用自己去设计ThreadPoolExecutor了,Executors底层是封装ThreadPoolExecutor的。

一个经验之谈
计算密集型任务:核心线程数量=CPU核数 + 1
IO密集型任务:核心线程数量=CPU核数 * 2

5.并行并发

并发 并行
CPU能同时处理线程的数量有限,为了保证全部线程都能往前执行,CPU会轮巡为系统的每个线程服务,由于CPU切换的速度很快,给我们的感觉这些线程在同时执行,这就是并发。 在同一个时刻上,同时有多个线程在被CPU调度执行。

多线程是并发和并行同时进行的!

6.悲观乐观锁

悲观锁 乐观锁
一上来就加锁,没有安全感。每次只能一个线程进入访问完毕后,再解锁。线程安全,性能较差! 一开始不上锁,认为是没有问题的,大家一起跑,等要出现线程安全问题的时候才开始控制。线程安全,性能较好。

上述三种线程同步默认都是悲观锁。以下是一个悲观锁和乐观锁比较代码

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
public class PostLock01 {

private static class MyRunnable01 implements Runnable{

private int number;
@Override
public void run() {

for (int i = 0; i < 1000; i++) {

synchronized (this){

++number;
}
}
}
};
private static class MyRunnable02 implements Runnable{

// private int number;
private AtomicInteger number = new AtomicInteger();
@Override
public void run() {

for (int i = 0; i < 1000; i++) {

number.incrementAndGet();
}
}
};

public static void main(String[] args) {

long timeStart, timeEnd;
timeStart = System.currentTimeMillis();
pessLock();
timeEnd = System.currentTimeMillis();
System.out.println("pessimism : "+(timeEnd - timeStart)+" ms");

timeStart = System.currentTimeMillis();
optiLock();
timeEnd = System.currentTimeMillis();
System.out.println("optimism : "+(timeEnd - timeStart)+" ms");
}

public static void pessLock(){

Runnable target = new MyRunnable01();
for (int i = 0; i < 32; i++) {

new Thread(target).start();
}
}

public static void optiLock(){

Runnable target = new MyRunnable02();
for (int i = 0; i < 32; i++) {

new Thread(target).start();
}
}

}
Java - 锁

Java - 锁

乐观锁

分为三个阶段:数据读取、写入校验、数据写入。

假设数据一般情况下不会造成冲突,只有在数据进行提交更新时,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回错误信息,让用户决定如何去做。fail-fast机制。

悲观锁

正如其名,它指对数据被外界(可能是本机的其他事务,也可能是来自其它服务器的事务处理)的修改持保守态度。在整个数据处理过程中,将数据处于锁定状态。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。如果加锁的时间过长,其他用户长时间无法访问,影响程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是长事务而言,这样的开销往往无法承受。

分布式锁

分布式集群中,对锁接口QPS性能要求很高,单台服务器满足不了要求,可以考虑将锁服务部署在独立的分布式系统中,比如借助分布式缓存来实现。

可重入锁

可重入锁,也叫做递归锁,是指在同一个线程在调外层方法获取锁的时候,再进入内层方法会自动获取锁。ReentrantLocksynchronized 都是 可重入锁。可重入锁的一个好处是可一定程度避免死锁。

这对于设计复杂的程序或库来说是非常重要的。考虑下面这种情况:

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
class SomeClass {

private final Lock lock = new ReentrantLock();

public void methodA() {

lock.lock();
try {

// 一些代码
methodB(); // 在 methodA 中调用 methodB
// 更多代码
} finally {

lock.unlock();
}
}

public void methodB() {

lock.lock();
try {

// 一些代码
} finally {

lock.unlock();
}
}
}

在这个例子中,methodA()methodB() 都使用了同一把锁。如果 ReentrantLock 不是可重入的,那么当线程在 methodA() 中已经获取了锁后,再次尝试在 methodB() 中获取锁时,就会导致死锁,因为锁已经被同一个线程持有,而不是其他线程。

因此,”可重入”锁允许在同一线程中多次获取同一把锁,这样就能避免死锁,并且简化了程序设计,因为无需担心方法之间的调用顺序是否会导致死锁。

尽管在您提出的例子中看起来似乎并没有太大意义,但在实际的程序设计中,”可重入”锁确实是一个非常重要的概念。

自旋锁

自旋锁是一种基于忙等待(busy-waiting)的锁,它在获取锁时会不断地循环尝试获取锁,而不是让线程进入睡眠状态。自旋锁的主要特点是在锁被其他线程占用时,当前线程会不断地尝试获取锁,直到获取到锁为止,而不会释放 CPU 控制权。

自旋锁适用于以下情况:

  1. 锁被持有的时间短:如果锁被持有的时间很短,那么等待锁的线程不需要进入睡眠状态,使用自旋锁可以避免线程切换的开销

  2. 多核处理器:在多核处理器上,一个线程在自旋等待锁的同时,其他线程可以继续执行,因此自旋锁在多核处理器上能够充分利用 CPU 时间,提高并发性能。

  3. 高并发场景:在高并发的情况下,锁的竞争可能会很激烈,自旋锁可以减少线程的阻塞时间,提高系统的响应速度。

然而,自旋锁也有一些缺点:

  1. 等待时间过长可能会浪费 CPU 资源:如果锁的竞争很激烈,导致线程不断自旋等待锁的释放,可能会浪费大量的 CPU 时间。

  2. 不适用于长时间持有锁的情况:如果锁被持有的时间较长,自旋锁会导致其他线程长时间等待,影响系统的响应性能。

因此,在使用自旋锁时需要权衡利弊,根据具体的场景来决定是否使用自旋锁。通常情况下,自旋锁适用于锁被持有时间短、锁的竞争不激烈的情况下,能够有效提高并发性能。

当然可以。以下是一个简单的示例,演示了如何使用自旋锁来保护一个共享资源:

language-java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.concurrent.atomic.AtomicBoolean;

public class SpinLock {

private AtomicBoolean locked = new AtomicBoolean(false);

public void lock() {

// 不断尝试获取锁,直到成功为止
while (!locked.compareAndSet(false, true)) {

// 自旋等待,不做其他事情,持续尝试获取锁
}
}

public void unlock() {

// 释放锁
locked.set(false);
}
}

在这个示例中,SpinLock 类实现了一个自旋锁。AtomicBoolean 类被用作锁状态的标记,初始时为 false 表示未锁定状态。lock() 方法使用了自旋等待的方式来尝试获取锁,它会不断地尝试将 locked 的值从 false 设置为 true,直到成功获取到锁为止。在 unlock() 方法中,锁会被释放,将 locked 的值重新设置为 false

以下是一个使用 SpinLock 的示例:

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
public class SpinLockExample {

private static int counter = 0;
private static SpinLock spinLock = new SpinLock();

public static void main(String[] args) throws InterruptedException {

Thread t1 = new Thread(() -> {

for (int i = 0; i < 1000; i++) {

spinLock.lock();
counter++;
spinLock.unlock();
}
});

Thread t2 = new Thread(() -> {

for (int i = 0; i < 1000; i++) {

spinLock.lock();
counter++;
spinLock.unlock();
}
});

t1.start();
t2.start();
t1.join();
t2.join();

System.out.println("Counter: " + counter);
}
}

在这个示例中,两个线程 t1t2 分别对 counter 执行了 1000 次增加操作,每次增加操作都在获取自旋锁后执行,并在执行完毕后释放自旋锁。最后输出 counter 的值,由于自旋锁的保护,counter 的增加操作是线程安全的。

独享锁

独享锁是指该锁一次只能被一个线程所持有。

共享锁

共享锁是指该锁可被多个线程所持有。ReentrantReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写、写读、写写的过程是互斥的。独享锁与共享锁也是通过AQSAbstractQueuedSynchronizer)来实现的,通过实现不同的方法,来实现独享或者共享。

互斥锁

独享锁/共享锁就是一种广义的说法,互斥锁/读写锁指具体的实现。

读写锁

读写锁在Java中的具体实现就是ReentrantReadWriteLock

阻塞锁

阻塞锁,可以说是让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间) 时,才可以进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争进入运行状态。

JAVA中,能够进入\退出、阻塞状态或包含阻塞锁的方法有 ,synchronized 关键字(其中的重量锁),ReentrantLock,Object.wait()/notify(),LockSupport.park()/unpark()

公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁

非公平锁

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。

可能造成优先级反转或者饥饿现象。对于Java ReentrantLock而言,通过构造函数 ReentrantLock(boolean fair) 指定该锁是否是公平锁,默认是非公平锁。

非公平锁的优点在于吞吐量比公平锁大。对于Synchronized而言,也是一种非公平锁。

分段锁

分段锁其实是一种锁的设计,目的是细化锁的粒度,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMapJDK7HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLockSegment继承了ReentrantLock)

当需要put元素的时候,并不是对整个HashMap加锁,而是先通过hashcode知道要放在哪一个分段中,然后对这个分段加锁,所以当多线程put时,只要不是放在同一个分段中,可支持并行插入。

对象锁

一个线程可以多次对同一个对象上锁。对于每一个对象,java虚拟机维护一个加锁计数器,线程每获得一次该对象,计数器就加1,每释放一次,计数器就减 1,当计数器值为0时,锁就被完全释放了。

synchronized修饰非静态方法、同步代码块的synchronized (this)synchronized (非this对象),锁的是对象,线程想要执行对应同步代码,需要获得对象锁。

使用 synchronized 加锁 this 时,只有同一个对象会使用同一把锁,不同对象之间的锁是不同的。 ​

当需要同步访问对象的实例方法或实例变量时,应该使用 this 作为 synchronized 的参数。
例如,在一个多线程环境中,多个线程需要同时访问对象的实例方法或实例变量时,可以使用 synchronized(this) 来确保线程安全。

language-java
1
2
3
4
5
6
7
public void synchronizedBlock() {

synchronized (this) {

// 同步代码块
}
}

类锁

synchronized修饰静态方法或者同步代码块的synchronized (类.class),线程想要执行对应同步代码,需要获得类锁。
使用 synchronized 加锁 class 时,无论共享一个对象还是创建多个对象,它们用的都是同一把锁
当需要同步访问类的静态方法或静态变量时,应该使用 MyClass.class 作为 synchronized 的参数。
例如,在一个多线程环境中,多个线程需要同时访问类的静态方法或静态变量时,可以使用 synchronized(MyClass.class) 来确保线程安全。

language-java
1
2
3
4
5
6
7
public static void synchronizedStaticBlock() {

synchronized (MyClass.class) {

// 同步代码块
}
}

锁升级

锁升级是Java虚拟机中的一种优化策略,它是针对 synchronized 关键字进行的优化,并不是像 ReentrantLock 这样的锁类库,可以在代码逻辑中直接使用的锁。

(无锁、偏向锁、轻量级锁、重量级锁)是指在Java虚拟机中对锁的状态进行优化和调整的过程。这些状态反映了对象的锁定状态以及锁的竞争情况。这种锁升级的目的是为了在不同情况下提供不同的锁定机制,以减少锁的竞争、提高性能。

下面是对这几种锁状态的简要介绍:

  1. 无锁状态

    • 当线程尝试获取锁时,对象的锁状态为无锁状态,表示该对象没有被任何线程锁定。
    • 在无锁状态下,线程会通过CAS(Compare and Swap)等原子操作尝试直接修改对象的指针或标记位,来尝试获取锁。
  2. 偏向锁

    • 当只有一个线程访问同步块时,对象的锁状态会升级为偏向锁状态。
    • 偏向锁会将线程的ID记录在对象头中,表示该线程拥有对象的偏向锁。当其他线程尝试获取锁时,会检查偏向锁的线程ID是否与当前线程ID相同,如果相同则表示获取成功。
  3. 轻量级锁

    • 当有多个线程竞争同步块时,对象的锁状态会升级为轻量级锁状态。
    • 轻量级锁使用CAS操作来避免传统的互斥量操作,尝试在用户态下通过自旋来获取锁。如果自旋获取锁失败,则升级为重量级锁。
  4. 重量级锁

    • 当轻量级锁竞争失败时,对象的锁状态会升级为重量级锁状态。
    • 重量级锁会使得竞争失败的线程进入阻塞状态,从而让出CPU资源,减少竞争。

锁升级是Java虚拟机对锁状态的动态调整过程,旨在根据实际的锁竞争情况和线程行为来选择最适合的锁策略,以提高程序的并发性能。

Redis之缓存击穿问题解决方案

Redis之缓存击穿问题解决方案

一、书接上文

Redis之缓存雪崩问题解决方案

二、介绍

缓存击穿就是大量并发访问同一个热点数据,一旦这个热点数据缓存失效,则请求压力都来到数据库。

三、解决方案

1. 单例双检锁

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
@Override
public CoursePublish getCoursePublishCache(Long courseId) {

String key = "content:course:publish:" + courseId;
//布隆过滤器
boolean contains = bloomFilter.contains(key);
if (!contains){

return null;
}
//先查询redis
Object object = redisTemplate.opsForValue().get(key);
if (object != null){

String string = object.toString();
CoursePublish coursePublish = JSON.parseObject(string, CoursePublish.class);
return coursePublish;
}else {

//后查询数据库
//加锁,防止缓存击穿
synchronized (this){

//单例双检锁
object = redisTemplate.opsForValue().get(key);
if (object != null){

String string = object.toString();
CoursePublish coursePublish = JSON.parseObject(string, CoursePublish.class);
return coursePublish;
}
CoursePublish coursePublish = getCoursePublish(courseId);
if (coursePublish != null) {

bloomFilter.add(key);
redisTemplate.opsForValue().set(key, JSON.toJSONString(coursePublish));
} else {

int timeout = 10 + new Random().nextInt(20);
redisTemplate.opsForValue().set(key, JSON.toJSONString(coursePublish), timeout, TimeUnit.SECONDS);
}
return coursePublish;
}
}
}

2. 缓存预热和定时任务

使用缓存预热,把数据提前放入缓存,然后根据过期时间,发布合理的定时任务,主动去更新缓存,让热点数据永不过期。