[TOC]
伙伴匹配项目-readme
项目关键词:标签、帮助找到志同道合的朋友、移动端H5网页。
沿用 用户中心项目 的后端,增加新功能。
新建一个移动端的前端项目。
需求分析
- 用户标签:每个用户给自己打上标签,标签有种类、分类
- 主动搜索其他用户(根据标签)
- Redis缓存
- 组队:
- 创建队伍
- 加入队伍
- 根据标签查询
- 邀请加入
- 推荐
- 相似度计算算法 + 本地分布式计算
技术栈
前端
- Vue3 开发框架
- Vant UI(基于Vue的移动端组件库)
- Vite(初始化工具,打包工具)
- Nginx 来单机部署
后端
- Springboot
- SpringMVC + Mybatis + Mybatis-plus
- MySQL
- Redis 缓存库
- Swagger + Knife4j 接口文档
Vue 与 Vue Router 的知识点
将开发过程中学习到的有关 Vue 和 Vant 的知识点集中写在这里
Vue
响应式API:
ref
与computed
简单介绍:ref()
:接受一个内部值,返回一个可读可写的响应式对象(响应式对象下面简写为 ref 对象)computed()
:接受一个 getter 函数,返回一个只读的 ref 对象。传进去的数据
data
保存在 ref 对象的.value
中,在 js 中使用.value
属性来访问数据,但是在 template 中,不需要加上.value
,ref 对象会自动解包。ref 与 computed 的区别:如果传进来的内部值也是一个响应式对象,那么:
ref()
返回的 ref 对象不会随内部值的变化而更新computed()
返回的 ref 对象会随内部值的变化而更新
示例:创建一个布尔属性 ref 对象 show
1
const show = computed(() => list.includes(route.fullPath));
这里 route.fullPath 也是一个响应式对象,
computed 的特点就是实时追踪内部响应式的状态并更新返回数据。
相比之下,ref 仅仅是赋一个初始值,如果写成:
1
const show = ref(list.includes(route.fullPath))
那么 show 的值将不会随 route.fullPath 的值变化而更新。
Vue Router
两个核心对象:
- 路由器实例:
const router=useRouter();
- 当前路由:
const route=useRoute();
- 路由器实例:
router.push()
的params
与query
params
是构建动态路由相关的参数:1
router.push({ name: 'user', params: { username: 'eduardo' } }) //结果是:/user/eduardo
query
是路径传参相关的参数:1
router.push({ path: '/register', query: { plan: 'private' } }) //结果是:/register?plan=private
前端初始化
**Vite **是一种新型的前端构建工具,支持快速构建不同类型的前端脚手架,例如:React、Vue等等
使用 Vite 参考官方文档。
版本选择:该项目选择对应Vue3的版本。
Vant UI
参考官方文档,安装 Vant。
引入 Vant
Vant 组件有多种引入方式:
- 全局引入、局部引入
- 全量引入、按需引入
开发时选择全局全量引入,方便开发。优化项目体积时再改成按需引入。
前端开发
页面开发 | 通用布局
很多页面有通用的样式,重复写会很麻烦,所以需要抽象一个通用布局
设计
导航条:展示当前页面名称
首页有搜索框 => 搜索页(含标签项)
tab栏
主页(推荐页+广告)
- 搜索框
- banner
- 推荐信息流
队伍页
用户页
开发
新建目录
src/layouts
用于存放样式。通用布局:
layouts/BasicLayout.vue
- 引入组件
NavBar
:导航栏 - 引入组件
Tabbar
:标签栏
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17<template>
<van-nav-bar title="标题" left-text="返回" left-arrow @click-left="onClickLeft"
@click-right="onClickRight">
<template #right>
<van-icon name="search" size="18" />
</template>
</van-nav-bar>
<van-tabbar v-model="active" @change="onChange">
<van-tabbar-item name="index" icon="home-o">主页</van-tabbar-item>
<van-tabbar-item name="team" icon="search">队伍</van-tabbar-item>
<van-tabbar-item name="user" icon="friends-o">个人</van-tabbar-item>
</van-tabbar>
</template>- 引入组件
写几个简单页面供使用:
src/pages/Index.vue
src/pages/Team.vue
src/pages/User.vue
用
<v-if>
实现页面切换,试试效果:1
2
3
4
5
6
7
8
9
10
11<div id="content">
<template v-if="active === 'index'">
<Index />
</template>
<template v-if="active === 'team'">
<Team />
</template>
<template v-if="active === 'user'">
<User />
</template>
</div>BasicLayout.vue
代码: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<script setup >
import { ref } from 'vue'
import Index from "../pages/Index.vue";
import Team from "../pages/Team.vue";
import Index from "../pages/User.vue";
const onClickLeft = () => alert("左");
const onClickRight = () => alert("右")
const active = ref('index');
const onChange = (index) => showToast(`标签 ${index}`);
</script>
<template>
<van-nav-bar title="标题" left-text="返回" left-arrow @click-left="onClickLeft"
@click-right="onClickRight">
<template #right>
<van-icon name="search" size="18" />
</template>
</van-nav-bar>
<div id="content">
<template v-if="active === 'index'">
<Index />
</template>
<template v-if="active === 'team'">
<Team />
</template>
<template v-if="active === 'user'">
<User />
</template>
</div>
<van-tabbar v-model="active" @change="onChange">
<van-tabbar-item name="index" icon="home-o">主页</van-tabbar-item>
<van-tabbar-item name="team" icon="search">队伍</van-tabbar-item>
<van-tabbar-item name="user" icon="friends-o">个人</van-tabbar-item>
</van-tabbar>
</template>
<style scoped>
</style>效果:
引入前端路由
**Vue Router **是 Vue 官方的客户端路由解决方案。
客户端路由的作用是:在单页应用 (SPA) 中将浏览器的 URL 和用户看到的内容绑定起来。当用户在应用中浏览不同页面时,URL 会随之更新,但页面不需要从服务器重新加载,而是根据不同的 URL 展示不同的内容。
在上一节,使用的是原始的<v-if>
实现页面的切换,现在使用 Vue Router 优化页面切换:
安装 Vue Router
在
main.ts
中引入路由器,为Index
Search
User
组件配置路由1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20import { createMemoryHistory, createRouter } from 'vue-router'
import Index from "../pages/Index.vue";
import Team from "../pages/Team.vue";
import User from "../pages/User.vue";
const routes = [
{ path: '/index', component: Index },
{ path: '/team', component: Team },
{ path: '/user', component: User },
]
const router = createRouter({
history: createMemoryHistory(),
routes,
})
const app = createApp(App)
app.use(router)
app.mount('#app')将上面的路由配置提出来,创建
src/config/routes.ts
专门配置路由:1
2
3
4
5
6
7
8
9
10
11import Index from "../pages/Index.vue";
import Team from "../pages/Team.vue";
import User from "../pages/User.vue";
const routes = [
{ path: '/index',name='index', component: Index },
{ path: '/team', name='team',component: Team },
{ path: '/user', name='user',component: User },
]
export default routes在
main.ts
引用routes.ts
:import routes from "./config/routes.ts";
标签栏
Tabbar
支持有专门支持 Vue Router 的模式 :1
2
3
4
5
6
7
8
9<div>
<RouterView />
</div>
<van-tabbar route>
<van-tabbar-item replace to="/index" icon="home-o">index</van-tabbar-item>
<van-tabbar-item replace to="/team" icon="search">team</van-tabbar-item>
<van-tabbar-item replace to="/user" icon="user">user</van-tabbar-item>
</van-tabbar>to
:点击标签,会自动匹配路径下的组件<RouterView>
:组件的显示区域
如果 Vant 组件没有专门支持 Vue Router,则需要使用 Vue Router 的常规用法,如
<RouterLink>
router.push()
等。具体参考官方文档。
优化后的
BasicLayout.vue
代码: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<script setup>
import {ref} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import { computed } from 'vue';
// router 与 route 都是响应式数据
const router = useRouter();
const route = useRoute();
// ref() :将数据声明为一个响应式数据,并赋以默认值。详细可查看官方文档
const active = ref('index');
// computed():自动响应内部响应式数据的变化,并返回一个响应式数据
const list = ['/index','/team','/user'];
const show = computed(() => list.includes(route.fullPath));
const onChange = (index) => showToast(`标签 ${index}`);
const onClickLeft = () => router.back();
const onClickRight = () => router.push('/search')
</script>
<template>
<van-nav-bar title="标题" left-text="返回" :left-disabled="show" left-arrow @click-left="onClickLeft"
@click-right="onClickRight">
<template #right>
<van-icon name="search" size="18"/>
</template>
</van-nav-bar>
<div>
<h1>Hello App!</h1>
<main>
<RouterView />
</main>
</div>
<van-tabbar v-model="active" route @change="onChange">
<van-tabbar-item replace to="/index" icon="home-o">index</van-tabbar-item>
<van-tabbar-item replace to="/team" icon="search">team</van-tabbar-item>
<van-tabbar-item replace to="/user" icon="user">user</van-tabbar-item>
</van-tabbar>
</template>
<style scoped>
</style>
页面开发 | 搜索页面
根据标签搜索用户 Search.vue
设计
选用:搜索框Search
+ 标签 Tag
+ 分类选择TreeSelect
- 搜索框在顶部:
Search
- 中间是已选标签列:
Tag
- 下面是可供选择的标签:
TreeSelect
开发
- 引入组件:
Search
Tag
TreeSelect
- 主要逻辑:
- 可以在搜索框
Search
中直接搜索标签关键字 - 也可以在分类选择
TreeSelect
中选择标签关键字 - 列出选中的标签
- 点击按钮发送搜索请求
- 收到返回,跳转到搜索结果页面
- 可以在搜索框
页面开发 | 搜索结果页面
页面开发 | 用户信息页
设计
Cell
单元格
开发
- 引入组件
- 主要逻辑:
- 展示信息
- 点击信息项可进行编辑
页面开发 | 用户信息编辑页面
设计
选用:Form
表单
页面开发 | 登录页面
Pinia | 维护用户登录态
Vue 可以用响应式 API reactive()
做简单状态管理:如果你有一部分状态需要在多个组件实例间共享,你可以使用 reactive()
来创建一个响应式对象,并将它导入到多个组件。
另外,官方文档介绍了 pinia 作为大规模应用下的状态管理工具,出于学习目的,在这里大材小用一下,引入 pinia 进行用户登录状态管理:
安装 pinia 。
定义用户登录状态
useUserStateStore
,这个Store
可以在全局每个组件中通过const userState=useUserStateStore()
引用:currentUser
就是维护的数据,他是一个 ref 对象。queryCurrentUser()
和logout()
是数据相关的方法。1
2
3
4
5
6
7
8
9
10
11
12
13
14export const useUserStateStore = defineStore('userState', () => {
const currentUser = ref<UserType | null>(null);
async function queryCurrentUser() {
const response = await getCurrentUser();
currentUser.value = response.data;
console.log(currentUser.value);
}
function logout() {
console.log("正在清空session");
// 删除浏览器中的 cookie
currentUser.value = null;
}
return {currentUser, queryCurrentUser, logout}
})Axios 默认,发生跨域请求时,请求不会携带 cookie,这里配置开启 cookie。
src/plugins/myAxios.ts
:1
2
3
4
5
6
7
8import axios from 'axios';
const myAxios = axios.create({
baseURL: 'http://localhost:8080/api',
})
// 全局设置允许跨域携带cookie
myAxios.defaults.withCredentials = true;
export default myAxios;此时就有了一个可以全局共享的用户状态
userState
,还用 cookie 维护了用户的登录态。在用户登录后,调用
queryCurrentUser()
来向后端获取当前用户信息,并保存在currentUser
中。在其他组件中获取用户登录态:
1
2
3
4
5
6
7
8<script>
const userState=useUserStataStore();
</script>
<template>
{{userState.currentUser.userAccount}}
</template>
<style>
</style>
路由导航守卫
完成登录功能以及维护用户登录态后,所有未登录的访问全部跳转到登录页
src/main.ts
:
1 | // 代码省略 |
页面开发 | 主页(推荐页)
后端开发
数据库设计
tags
表
性别:男、女
语言:java、C++、python、go
正在学:Spring、Mybatis、SpringCloud
目标:考研、求职、竞赛、考公、秋招、春招、社招
段位:初级、中级、高级
身份:大一、大二、大三、大四、学生、待业、研究生
状态:乐观、emo、单身、有对象
【用户自定义标签】
字段:
- id int
- 标签名 varchar 非空
- 上传标签的用户 userId int
- 父标签 parentId int
- 是否为父标签 isParent tinyint
- 创建时间,更新时间,是否删除、
user
表
沿用“用户中心项目”的user
表
若应用于该项目,有两种选择:
- 建立一张关联表,记录
user
与tags
的映射关系- 优点:查询灵活、可以正查反查
- 缺点:要新建一个关联表、维护这个表
- 在
user
表中加入tags
字段,类型为varchar
,存储内容的格式为JSON格式:['java','c++']
- 优点:查询方便、不需要新建关联表、且标签也是用户的一个常用属性,可能适用于多个项目
- 缺点:需要修改用户表
选择方案二:企业开发中会尽量减少关联查询,这既影响扩展性,也影响查询性能
修改并沿用“用户中心项目”的user
表
Mybatis-plus
开启设置,会打印出每次查询执行的SQL语句,有助于学习
1 | #mybatis-plus配置控制台打印完整带参数SQL语句 |
优化
- 给
tagName
加上唯一索引,加快查询
Java8 StreamAPI
在 Java 8 中,Stream API 提供了一种函数式编程风格来处理集合数据,避免繁琐的循环,提高代码的可读性和可维护性。
map
:映射1
2
3List<User> safeUserList=return list.stream().map(user -> {
return this.makeSafeUser(user);
}).collect(Collectors.toList());1
List<User> safeUserList=users.stream().map(this::makeSafeUser).collect(Collectors.toList());
1
2
3
4
5List<String> names = Arrays.asList("Alice", "Bob");
List<Integer> nameLengths = names.stream()
.map(String::length)
.collect(Collectors.toList());
System.out.println(nameLengths); // 输出: [5, 3]filter
:筛选1
2
3
4
5
6
7
8
9
10List<User> list=userList.stream().filter(user -> {
String tagStr=user.getTags();
List<String> tempTagNameList=gson.fromJson(tagStr,new TypeToken<String>(){}.getType());
for(String tagName:tagNameList){
if(!tempTagNameList.contains(tagName)){
return false;
}
}
return true;
}).collect(Collectors.toList());distinct
:去重sorted
:排序上述方法也可以组合使用:
filter
+map
1
2
3
4
5
6
7
8
9
10List<User> list=userList.stream().filter(user -> {
String tagStr=user.getTags();
List<String> tempTagNameList=gson.fromJson(tagStr,new TypeToken<String>(){}.getType());
for(String tagName:tagNameList){
if(!tempTagNameList.contains(tagName)){
return false;
}
}
return true;
}).map(this::makeSafeUser).collect(Collectors.toList());
关于Stream的并行能力:ParellelStream
https://blog.csdn.net/lsx2017/article/details/105749984
配置跨域请求
新建配置类:com/michael/usercenter/config/WebMvcConfig
1 | package com.michael.usercenter.config; |
功能开发 | 搜索用户
选择标签,搜索符合的用户。
根据标签搜索用户,分为两种情况:
- 允许输入多个标签,多个标签全部存在才能搜索出来
- 允许输入多个标签,只要有一个标签存在就能搜索出来(to do)
具体实现方式有两种:SQL查询与内存查询,两种方式的性能表现有差异:
SQL查询:将条件写到SQL语句当中,直接进行筛选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public List<User> selectUserByTag(List<String> tagNameList) {
if (CollectionUtils.isEmpty(tagNameList)) {
throw new BusinessException(ErrorCode.PARAM_NULL);
}
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
for (String tagName : tagNameList) {
queryWrapper = queryWrapper.like("tags", tagName);
}
List<User> users = userMapper.selectList(queryWrapper);
if (users == null) {
throw new BusinessException(ErrorCode.UNKNOWN_ERROR);
}
return users.stream().map(this::makeSafeUser).collect(Collectors.toList());
}内存查询:先将所有用户加载到内存当中,再根据条件进行筛选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public List<User> selectUserByTag(List<String> tagNameList) {
if (CollectionUtils.isEmpty(tagNameList)) {
throw new BusinessException(ErrorCode.PARAM_NULL);
}
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
List<User> userList=userMapper.selectList(queryWrapper);
Gson gson=new Gson();
return userList.stream().filter(user -> {
String tagStr=user.getTags();
List<String> tempTagNameList=gson.fromJson(tagStr,new TypeToken<List<String>>(){}.getType());
for(String tagName:tagNameList){
if(!tempTagNameList.contains(tagName)){
return false;
}
}
return true;
}).map(this::makeSafeUser).collect(Collectors.toList());
}GSON:解析json字符串的类库
性能测试 | 搜索用户 | 并发搜索
可以通过实际运行测试两种查询的消耗时间来比较性能:
1 | long startTime = System.currentTimeMillis(); |
测试细节:
设计数据库连接时,数据库第一次连接也需要时间,需要排除这个耗时
如果多次测试,并没有哪种方式的性能总是占优,则:
如果参数可以分析,则根据参数去选择查询方式,比如标签数
如果参数不能分析,且数据库连接足够,内存空间足够,则可以同时并发两种查询,谁先返回用谁。
并发用到 CompleTableFuture ,下面有提到。
实现代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public List<User> selectUserByTag(List<String> tagNameList) {
//创建内存查询任务
CompletableFuture<List<User>> listCompletableFuture1 = CompletableFuture.supplyAsync(() -> this.selectUserByTagByMemory(tagNameList));
//创建SQL查询任务
CompletableFuture<List<User>> listCompletableFuture2 = CompletableFuture.supplyAsync(() -> this.selectUserByTagBySQL(tagNameList));
//执行,如果无需打印,则直接返回anyOf的结果
CompletableFuture<Object> objectCompletableFuture = CompletableFuture.anyOf(listCompletableFuture1, listCompletableFuture2) .thenApply(res -> {
if (res == listCompletableFuture1.getNow(null)) {
log.info("memory completed first");
} else {
log.info("SQL completed first");
}
return res;
});
List<User> userList=null;
try {
userList= (List<User>)objectCompletableFuture.get();
} catch (Exception e) {
throw new BusinessException(ErrorCode.UNKNOWN_ERROR);
}
if(userList!=null){
return userList.stream().map(this::makeSafeUser).collect(Collectors.toList());
}
throw new BusinessException(ErrorCode.UNKNOWN_ERROR);
}
接口文档 | Knife4j(Swagger)
Knife4j是一个集成了swagger的增强解决方案
后端整合Knife4j 自动化生成接口文档,并且还提供了 Http 测试工具
版本:Spingboot2.6.X Knife4j3.0.3
集成knife4j的bug很多是版本问题,参考此处时尽量保持版本一致
引入依赖:knife4j是一个集成了swagger的增强解决方案
1
2
3
4
5<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>SwaggerConfig
配置类: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
public class SwaggerConfig {
private static final String SWAGGER_TITLE = "XXX项目 API 接口文档";
private static final String VERSION = "3.0.3";
public Docket createRestApi() {
return new Docket(DocumentationType.OAS_30)
.enable(true)
.apiInfo(apiInfo())
.groupName("3.X 版本")
.select()
.apis(RequestHandlerSelectors.withClassAnnotation(RestController.class))
.paths(PathSelectors.any())
.build();
}
private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title(SwaggerConfig.SWAGGER_TITLE)
.description("# XXX项目API接口文档简介")
.termsOfServiceUrl("http://127.0.0.1/#/login")
.contact(new Contact("michael", "", ""))
.version(SwaggerConfig.VERSION)
.build();
}
}定义需要生成接口文档的代码位置:
只有在指定包下的代码才生成接口文档
.apis(RequestHandlerSelectors.basePackage("com.michael.controller"))
只有类上有注解
@RestController
的才生成接口文档.apis(RequestHandlerSelectors.withClassAnnotation(RestController.class))
只有方法上有注解
@ApiOperation
的才生成接口文档.apis(RequestHandlerSelectors.withMethodAnnotation(ApiOperation.class))
@EnableKnife4j
@EnableSwagger2
@Import(BeanValidatorPluginsConfiguration.class)
这三个注解可能没什么用,可以试试删除
在上面的配置类中加入这一段(因为不清楚这段代码的作用,所以单独列出,可能可以删除)
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
public static BeanPostProcessor springfoxHandlerProviderBeanPostProcessor() {
return new BeanPostProcessor() {
public Object postProcessAfterInitialization( Object bean, String beanName)throws BeansException {
if (bean instanceof WebMvcRequestHandlerProvider || bean instanceof WebFluxRequestHandlerProvider) {
customizeSpringfoxHandlerMappings(getHandlerMappings(bean));
}
return bean;
}
private <T extends RequestMappingInfoHandlerMapping> void customizeSpringfoxHandlerMappings(List<T> mappings) {
List<T> copy = mappings.stream()
.filter(mapping -> mapping.getPatternParser() == null)
.collect(Collectors.toList());
mappings.clear();
mappings.addAll(copy);
}
private List<RequestMappingInfoHandlerMapping> getHandlerMappings(Object bean) {
try {
Field field = ReflectionUtils.findField(bean.getClass(),"handlerMappings");
assert field != null;
field.setAccessible(true);
return (List<RequestMappingInfoHandlerMapping>) field.get(bean);
} catch (IllegalArgumentException | IllegalAccessException e) {
throw new IllegalStateException(e);
}
}
};
}配置
application.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18spring:
mvc:
pathmatch:
matching-strategy: ANT_PATH_MATCHER
springfox:
documentation:
swagger:
v2:
path: /api-docs
use-model-v3: true
knife4j:
# 开启knife4j的增强模式
enable: true
# 开启登录认证
basic:
enable: true
username: admin
password: 123456springfox
的配置可能没什么用,可以试试删除
最后通过
localhost:8080/doc.html#/
访问
扩展知识 | 网页抓取知识流程
easyExcel:java处理excel表格的库
看上了网页内容,怎么抓取?
- 分析原网站是怎么获取信息的?是哪个接口?
功能开发 | 修改用户信息
用户中心项目实现的用户信息修改是供管理员使用的:
- 只有管理员能够修改
- 管理员之间不能相互修改
这里我们实现普通用户修改自己的信息。
目前只有一个 UserController ,包含了所有的后端接口。现在进行一点小重构:
- AdminController:管理员可以调用的接口,所有接口内部都要进行权限鉴定(该后端都在 controller 进行鉴权)。
- UserController:所有用户都能调用的接口,例如登录。不需要鉴权。
功能开发 | 分布式登录
将用户中心的单机登录改为分布式登录。
核心思想:共享存储,所有后端服务器从一台共享服务器上读写 session
Redis 是一个快速读写的 K-V 式存储系统,在共享服务器上用 Redis 存储 session,能够满足我们的需求。
实现:
选择一台服务器,安装 Redis 服务,作为数据共享服务器,即下面的 Redis 服务器。
开发时选择本机作为 Redis 服务器即可。
- QuickRedis : 管理 Redis 的工具,类似于 Navicat
在项目中引入 Redis:
引入 Spring Data Redis 依赖:
spring-boot-starter-data-redis
版本要与Springboot的版本保持一致
application.yml
添加 Redis 服务器配置:1
2
3
4
5
6redis:
# Redis 服务器的主机地址,开发时可以将本机作为 Redis 服务器
host: localhost
# Redis 服务器的端口
port: 6379
database: 0引入 Spring Session Redis 依赖:
spring-session-data-redis
版本要与Springboot的版本保持一致
这个依赖配合下面的配置,会自动实现从 Redis 中读写session
application.yml
添加配置:1
2
3session:
# 默认为 none 表示存在单台服务器中
store-type: redis
完成,不用修改任何一行登录逻辑。
功能开发 | 批量导入 | 并发编程
我们需要向数据库中插入大量用户信息(例如10w个用户)来模拟应用场景。
CompletableFuture
是 Java 8 引入的一个非常强大的工具类,它提供了异步编程的能力。
基本概念:
异步执行,CompleTableFuture 允许创建并发任务执行,而不阻塞主线程。
组合任务,可以创建多个并发任务,并将他们分组执行。
默认使用 Java8 提供的 ForkJoinPool 线程池,其线程池的大小是确定的,由CPU的核心决定。
如果 ForkJoinPool 不满足需求,可以使用自定义的线程池。
自定义线程池:
ThreadPoolExecutor
是 Java 中非常强大的线程池实现,提供灵活的线程池配置。
ThreadPoolExecutor 构造器可传入的参数:
int corePoolSize
, // 核心池大小int maximumPoolSize
, // 最大池大小long keepAliveTime
, // 线程空闲时的最大存活时间TimeUnit
unit, // 时间单位BlockingQueue<> workQueue
, // 任务队列ThreadFactory threadFactory
, // 创建新线程的工厂RejectedExecutionHandler
handler // 拒绝策略
1 | void testInsertUser(){ |
并发编程要注意:
- 执行的任务是先后无所谓的。
- 不要使用不支持多线程的集合,例如 List
功能开发 | 推荐用户 | 分页查询
在首页,展示与用户相似度高的用户。
查询的用户数量多且不用一次性返回给前端,所以使用分页查询:
MybatisPlusConfig
配置类,配置分页插件。参考 Mybatis-plus 文档。1
2
3
4
5
6
7
8
9
10
public class MybatisPlusConfig {
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}分页查询:
userService.page()
1
Page<User> userList=userService.page(new Page<>(pageNum,pageSize),queryWrapper);
- pageNum:当前页目
- pageSize:页面大小
- 推荐用户的算法暂时是没有的,先按顺序返回用户,打通逻辑先。
下面要介绍 Redis,用来提高推荐用户接口的性能。
Redis 入门
Redis:基于内存的快速读写的 K-V 式存储系统。
- 安装 Redis,引入 Spring Data Redis 依赖。
- RedisTemplate:Spring Data Redis的核心类,封装了 Redis 操作的基本功能。
Redis的数据结构与方法:
String 字符串类型:name:“Michael”
List 列表:names:[“Michael”,”zmx”]
Set 集合:names:[“Michael”,”zmx”](值不能重复)
Hash 哈希:nameAge:{“Michael”:1,”zmx”:2}
Zset 集合:names:{“Michael” - 10,”zmx” - 12} (给某个值关联一个数字)
RedisTemplate 为每种数据结构,都提供了相应的操作方法。以字符串类型为例,redisTemplate.opsForValue()
的返回值是一个字符串类型的操作类ValueOperations
,提供了增删改查等方法。
RedisTemplate 在存储数据时会将数据先序列化:
RedisTemplate 默认使用 JdkSerializationRedisSerializer
作为序列化器,但是大多数情况下,使用它是会有乱码问题的:
- 这个默认选择是出于灵活性的考虑。上面提到,Redis 有五种数据结构,每种数据结构的序列化是有细节差异的。
JdkSerializationRedisSerializer
是一个通用的序列化器,适用于多种类型的数据,并且能够支持任意 Java 对象。而代价是乱码问题。 - 我们确实需要存储不同数据结构的
Value
,但是我们的Key
一般都是字符串类型的。
**因此,我们需要根据实际存储的数据类型,自定义 RedisTemplate,单独为Key
配置字符串序列化器。 **
RedisTemplate
配置类:
1 |
|
当然,乱码问题不影响数据的正确性,从代码中取出数据时的反序列化也会把乱码消除。乱码只是影响我们从 Redis 工具如 QuickRedis 查阅数据。
功能开发 | 数据缓存 | 定时任务
前面的分布式登录就已经用到了Redis,进行了引入依赖以及配置。但存储的数据是session,而我们通过引入 Spring Session Redis 依赖就自动实现了功能。
现在就用代码真正操作 Redis。
场景:
还是推荐用户场景。这个功能使用的频次很高,响应时间要求很高,对数据时效性的要求不算高。
如果能将推荐用户的数据保存在缓存中,可以有效提高查询的效率,并且通过定时更新(推荐用户可以一天一更新,根据需求而定)来保证时效性。
使用 Redis 进行缓存:
- 先查询 Redis 中是否有缓存数据。
- 如果没有,则从数据库中查询,并保存到 Redis 中
- 设置过期时间(很重要,别把Redis爆了)。
以上逻辑,显然对部分用户不友好(查询前缓存刚好失效,只能从数据库中查询,耗时长)。所以我们可以加入定时任务,定时更新缓存数据(这种方式也叫缓存预热)。
使用定时任务进行缓存的更新:
定时任务有多种实现方式:
- Spring Scheduler(Springboot默认整合了)
- Quartz(独立的定时任务框架)
- XXL-job(开源项目,有学习价值,分布式的任务调度平台,界面+SDK)
这里就使用第一种:
- Springboot 的启动类上加上
@EnableScheduling
开启定时任务功能。 - 定时任务方法上加上
@Schduled
,注解有多个参数,用来配置任务的时间、次数等等。
功能开发 | 定时任务控制方案 | 分布式锁
在上一节,使用了定时任务来实现了缓存更新。但这又导致了新的问题:在多台服务器部署时,每个服务器都会执行一次定时任务更新缓存,而这是多余的,因为 Redis 的数据是共享的。
如何实现同一时间只有一台服务器执行定时任务?
方案一:将定时任务从项目分离出来,专门在一台服务器上运行(成本太高)。
方案二:在代码中通过 IP 限制任务执行,只有一个 IP 下的服务器可以执行更新操作,其他的服务器直接返回(成本低)。
- 缺点:把代码写死了,如果要修改 IP ,还得改代码重新上线;而且如果这台服务器崩了那任务直接不执行了)
- 解决方法:将这个 IP 存在数据库或者配置中心,方便修改
- 数据库
- Redis
- Nacos,Apollo,Spring Cloud Config
- 无论如何还是人工配置,人为修改
注意:这两种方案只是不适合解决当前的问题,但体现出的思路是可以应用在其他场景下的,这也是将他们写出来的原因。
第三种解决方案:分布式锁
关键概念:锁是共享存储的,服务器抢锁,抢到后加上自己的标识,别人不能再用。用完后释放锁。
- 选择共享服务器作为锁共享存储的地方。
- 锁的操作要有同步异步机制。
- 请求服务器要手动释放锁,如果请求服务器挂了,也要保证共享服务器能根据设置的过期时间,到期自动释放锁。
以上要求我们使用 Redis 的setIfAbsent()
和delete()
就可以完成。
Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit);
- 底层是是 Redis 的
setnx
命令,如果key
不存在则返回1,若存在则返回0,保证Redis内是单线程操作; key
是锁的名称,value
可以用来标识请求线程。
Redis的分布式锁实现示例:
1 | String lock = "lock_key"; |
但是这样还不能满足复杂场景
试想,如果过期时间设置为10min,但是我们的任务执行了10min还没执行完,那么是不是应该有一个自动延期机制?如果不自动延期,那么锁被释放了,但是任务还在执行,另一个线程拿到锁又开始执行任务,这就破坏锁的机制了。
我们自己实现自动延期有点复杂而且没必要,因为Redisson就具备这样的功能。
功能开发 | 引入Redisson
Redisson 是一个基于 Redis 的 Java 客户端,提供了大量的分布式数据集来简化对 Redis 的操作和使用,可以让开发者像使用本机集合一样使用 Redis,完全感知不到 Redis 的存在。
引入依赖,有2种引入方式:
- 引入 redisson-springboot-starter依赖,即 redisson 与 springboot 的整合版,能简化很多操作。(不推荐,redisson 更新快,引入整合包容易造成冲突)
- 直接引入 redisson 的依赖,自定义客户端。(推荐)
RedissonConfig
配置类:1
2
3
4
5
6
7
8
9
10
11
public RedissonClient redissonClient(){
// 创建配置
Config config=new Config();
String redissonAddress="redis://127.0.0.1:7181";
config.useSingleServer().setAddress(redissonAddress).setDatabase(3);
// 创建 redisson 实例
RedissonClient redisson=Redisson.create(config);
return redisson;
}Redisson提供的分布式数据集:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public testRedisson(){
RedissonClient redisson=new RedissonClient();
// list,本地集合
List<String> list=new ArrayList<>();
list.add("michael");
list.get(0);
list.remove(0);
// RList,数据存在 Redis
RList<String> rList=redisson.getList("aRedissonList");
rList.add("michael");
rList.get(0);
rList.remove(0);
}可以看到,RList 提供的接口与 List 完全一致,可以让开发者像使用本机集合一样使用 Redis。这是因为 RList 的实现是完全继承了 List 接口。 RMap、RSet 同样如此。
Redisson 还可以实现分布式锁,下面开始实现。
功能开发 | 缓存更新 | Redisson分布式锁实现
使用 Redisson + 定时任务 实现实现分布式锁,执行缓存更新任务。
核心逻辑的伪代码:
1 | getLock |
- tryLock 是原子操作
- isHeldByCurrentThread与unlock一起也是原子操作,这在底层是通过 lua 脚本实现的。
实现:
1 |
|
cron 表达式,安排任务的执行时间。
getLock(key):Redisson 会根据传入的锁名称构造一个 Redis 键。调用
SETNX
命令,检查该键是否存在。如果该键不存在,SETNX
会创建这个键并为其值设置为一个唯一标识符(通常是当前客户端的UUID
),表示锁已经被当前客户端占用。如果键已存在,表示锁已被其他客户端持有,当前客户端无法获取锁。
tryLock(waitTime,leaseTime,TimeUnit):尝试加锁,等待指定的时间。
waitTime
:等待时间,设置为0,抢不到锁就溜。leaseTime
:锁的过期时间。
isHeldByCurrentThread():是否是自己加的锁。
lock.unlock():一定要写在 finally 中,保证即使抛出异常,锁也会被释放。
为什么要划分 getLock() 与 tryLock() ?
在上面的代码中,我们先执行了getLock()
得到lock
,再执行了lock.tryLock()
,这不禁让人疑惑——为什么要这样划分?难道不能将tryLock 的功能合并到 getLock 中吗?
在回答之前,我们还要介绍lock
的另一个方法:lock.lock()
lock()
与tryLock()
的区别:
- 前者阻塞加锁,如果没有得到锁,就会阻塞进程,直到获取到锁
- 后者尝试加锁,如果在
waitTime
时间内没有得到锁,就返回;如果得到锁,就进一步执行。
基于这点,就理解了设计getLock()
和trylock()
是出于程序灵活性的考虑:
- getLock() 返回一个锁对象,如果不存在就创建锁
- 如果是一定要执行的任务,就执行lock()
- 如果不是,就执行tryLock(),避免进程一直被阻塞。
这样的设计可以处理更多应用场景。
Redisson 锁的自动续期:Watch Dog 看门狗机制:
在tryLock()
中,如果执行任务的耗时大于锁的过期时间,就导致锁提前释放,其他任务也可以执行,锁的机制就被破坏了。
Redisson 提供了一种 看门狗 机制来解决这种情况:
- 将
leaseTime
设置为 -1,看门狗机制就会生效。 - 内部自动将
leaseTime
设置为30s,每隔10s,如果任务没有完成,则续到30s。 - 如果监听到任务线程宕机(开发时debug也会被认为是宕机!!),则不会再续时间,锁被释放。
功能开发 | 组队功能 | 需求分析
需求分析:
用户能创建队伍,队伍名称,队长,描述,人数限制,时间限制
规定创建时队长只能是自己。
队长可以修改队伍信息
用户可以根据队伍信息搜索
队伍的状态:公开、仅自己可见、加密(字段要加一个密码)
用户加入队伍(需要申请?同意?)
邀请别人加入队伍
用户可以退出队伍
队长可以解散队伍
用户同时加入的队伍数量有上限
队伍人满之后要有消息通知
数据库设计
team
表
队伍ID id bigint
队伍名 teamName varchar
队伍描述 description varchar
队长id userId bigint
最大人数 maxNum int
招募期限 expireTime
队伍状态 teamStatus 0-公开 1-私密 2-加密
入队密码 enterPassword varchar
createTime
updateTime
isDelete
user 与 team 的关系如何维护?
- 方案一:新建 user-team 的关系表
- 方案二:在 user 表中维护已加入队伍字段,在 team 表中维护已有队员字段
如何选择需要考虑很多因素,这里不深究,选择更教科书的方案一。
user_team
表
id bigint
队伍ID teamId bigint
用户ID userId bigint
加入时间 joinTime datetime
createTime
updateTime
isDelete
功能开发 | 创建队伍 | 接口设计
接口的逻辑有一定的复杂度,开发时先写好接口设计,检查是否有错误,再写代码会很快。
接口设计
接收的参数:名称,描述,最大人数,招募期限,队长,队伍状态,密码
封装一个请求参数接收类:TeamCreateRequest。
接口逻辑:
检验登录态。
参数判空:名称,描述,最大人数,队长,队伍状态。(招募期限和密码可以为空)
校验参数:
名称长度。
描述长度。
校验最大人数
校验招募期限:
为空则视为永久可加入。
不为空则要大于当前时间
前端要传这样的数据:”2025-02-14T01:40:54.689Z”
如果只传”2025-02-14”会有8小时问题,是前端的问题。
校验队伍状态,如果加密,则密码不能为空
队长ID是否存在(创建时队长只能是当前登录用户)
查询用户已加入的队伍数,最多同时加入5支队伍。
开启事务:两个表要么都插入成功要么都失败。
- 在 service 层的方法上加上注解:
@Transactional
- 插入到用户表
- 插入信息关系表
- 在 service 层的方法上加上注解:
功能开发 | 创建接口 | @Transactional
实际上,每一条数据库执行的语句都要经过事务管理。默认情况下,数据源底层为每一次SQL语句都自动进行 commit 操作(包括查询操作,即使它不修改数据),调用teamMapper
的方法,我们不用写任何写有关 commit 的代码,就已经被自动提交。
所以,如果我们想要参与事务管理,实际上就是要把这个底层的自动提交机制给关掉,在代码中显式的 commit。
@Transactional 做了什么:
1 | 关闭数据源的自动提交 |
关于这个注解的原理分析,这篇文章中介绍的很好
功能开发 | 创建队伍 | 并发处理
模拟这样的并发场景:用户已加入四个队伍(最多五个),但是创建第五个队伍时猛点100下创建,导致100个请求在检查用户已创建队伍数量时都是4个,所以这100个队伍都被允许创建了。
加锁 @Synchronized
在 service 层 createTime() 方法上加上 @Synchronized 注解,实现该方法在并发多线程下的原子性。
(额,后来发现 @Transactional 和 @Synchronized一起使用会出大问题,这里不删除这段是为了留个犯错记录)
单元测试 | 创建队伍
- 测试创建接口逻辑
- 测试并发多线程下的问题。
功能开发 | 查询队伍 | 接口设计
接口返回的信息包括:队伍信息以及队伍中所有成员的信息。
接口设计:
封装一个 DTO 类用来接收参数:TeamQuery
接口逻辑:
参数校验:
可选参数:参数可以为空,代表不作为查询条件。
1
2
3
4
5
6private Long teamId;
private String teamName;
private String description;
private Long userId;
private int maxNum;
private int teamStatus;已过招募期限的队伍不展示
私密的队伍只有管理员才能查看。
根据参数查询出队伍信息。
根据队伍查询出队友成员信息。(涉及 TeamUser 与 User 的多表联查)
将队伍信息与成员信息封装成一个 VO 返回
上述使用分页查询。
功能开发 | 多表联查 | Mybatis-Plus-Join
Mybatis-Plus-Join 是一个 Mybatis-Plus 的增强工具,主要增强多表联查方面的功能,能够以类似 Mybatis-plus 中 QueryWrapper 的方式来进行联表查询(mybatis-plus本身是没有的),而不用写 SQL 语句。
- 引入 Mybatis-Plus-Join 依赖
- 如果 TeamUser 与 User 的要实现多表联查,只需要将二者的 mapper 改为继承 MPJBaseMapper。
- 这样就可以使用多表联查的 API,具体用法参考官方文档。
使用 Mybatis-Plus-Join 实现查询队伍接口部分代码:
1 | // 2. 根据参数查询出队伍信息。 |
单元测试 | 查询接口
测试查询接口逻辑
功能开发 | 校验参数 | AOP+自定义注解
开发进行到这里,发现有很多重复的参数校验代码,例如几乎所有接口都要校验参数是否为空或者空字符串。
最简单的参数校验的方法,就是在 controller 方法中进行参数校验。但是这样做在 controller 方法中会有大量重复、没有太大意义的代码。
为了保证 controller 中的代码有更好的可读性,我们可以借助 AOP 的思想来进行统一的参数校验。
实现思路:
自定义注解
@Verify
,用来标注需要进行校验的参数。自定义切面类
aspect
,在里面配置切入类的切入点,以及切入后执行的操作,我们对参数的校验逻辑就是写在这里。举个例子,在这个场景下,切入点就是 controller 的方法,切入后执行的操作就是参数校验。
对于不满足的参数直接抛出自定义异常,交由全局异常处理来处理并返回友好的提示信息。
因为这个功能具有泛用性,所以可以把实现代码独立写在一个 paramValidator 包中,日后完善还可以打包成依赖。
paramValidator 目录结构
1 | ├─paramValidator |
- Verify:注解类,包含
name
required
notNull
maxLength
minLength
regular
等属性 - RegexOption:正则表达式的枚举类,用于注解的
regular
属性 - EntityValidatorAspect:切面类,进行对注解属性的逻辑校验。
- ParamAssert:断言工具类,在代码的执行过程中,经常需要在满足条件时抛出异常,此时需要加入抛异常、返回状态码、错误信息、记录日志等操作,此操作是大量重复的操作,所以借助 Juni t中 Assert 的思想,创建了的断言工具类。
- ParamExceptionHandler:统一处理抛出的异常,进行打印日志、生成通用返回类等。
- ParamException:自定义的参数异常类
- Result:自定义的通用返回类
功能开发 | 修改队伍 | 接口设计
接口设计:
封装请求参数接收类:TeamUpdateRequest
maxNum 不允许修改。
接口逻辑:
- 检查登录态
- 校验参数:
- 只有队长或管理能修改
- 新队长要存在(如果有)
- 新队长必须已经在队伍中
- 招募期限不能先于当前时间
- 最多人数不允许修改
- 更新队伍信息
功能开发 | 修改队伍 | 并发处理
该方法似乎只会有一种并发情况:多个管理员同时修改信息,而且这样的情况也很少见,并发量不会很大,应该可以不处理。
出于学习目的,还是实现一下乐观锁。
功能开发 | 修改队伍 | 乐观锁
概念:乐观锁总是假设最好的情况,即在读取数据和提交更新之间,其他事务不会修改数据。因此,它不会在读取数据时加锁,而是在更新数据时检查是否有其他事务已经修改了数据。
如何检查?
- 给数据的表和实体类均新增一个字段
version
- 对数据进行修改时:
- 先进行一次查询,得到此时数据的
version
- 将这个
version
值赋给新的数据 - 将新数据插入时,检查新数据的
version
与表中数据的version
是否相等:- 如果相等,则说明期间没有其他事务修改数据,继续更新。
- 反之则有其他事务已经修改数据,此次更新终止。
- 先进行一次查询,得到此时数据的
使用Mybatis-plus提供的乐观锁插件
插件能自动完成上述步骤中的第2.3步 ,其他的步骤还需要自己完成。参考官方的乐观锁插件文档。
功能开发 | 加入队伍 | 接口设计
接口设计:
封装请求参数接收类:TeamJoinRequest
接口逻辑:
- 检查登录态
- 校验参数:
- 加入的队伍存在
- 加入的队伍是非私密的
- 如果是加密的队伍,则要校验密码
- 加入的队伍是未满的
- 加入的队伍是非过期的
- 最多只能同时加入上限个队伍
- 不能重复加入已加入的队伍
- 更新数据
功能开发 | 加入队伍 | 并发处理
模拟这样的并发场景:用户在加入队伍时,猛点100下加入,导致100个请求并发,校验全部通过,导致数据库中 TeamUser 表新增100条重复数据。
解决方案:
方案一:用 @Synchronized 注解。我们在创建队伍时就是这样做的。
但是这样做在分布式场景下不能生效:分布式场景下,存在多个用户同时申请加入队伍,而 @Synchronized 只限制单机的并发。
(创建队伍不存在这样的场景,所以可以用 @Synchronized 应对并发)
方案二:分布式锁。因为涉及新增数据,乐观锁不适用,所以采用悲观锁思路。
功能开发 | 加入队伍 | 数据库分布式锁方案
实现分布式锁要考虑的细节:
抢锁是一个原子操作。
锁加过期时间。
如果执行时间过长,锁过期了?:
- A锁过期,B抢到锁,A、B同时执行。
- A还在执行,但锁过期了,B上锁;A执行完成,以为这个锁还是自己的,所以释放掉了,C抢到锁,引起连锁反应
要用锁延期来解决这个问题。
还要给锁加上自己的标识,只能释放自己锁。
从判断是否是自己锁到释放自己锁的这一段操作也要是原子操作。
实现:基于数据库的分布式锁方案:
锁的表结构
1
2
3
4
5
6
7
8
9
10
11
12
13CREATE TABLE `resource_lock`
(
`keyResource` varchar(45) COLLATE utf8_bin NOT NULL DEFAULT '资源主键',
`lockStatus` char(1) COLLATE utf8_bin NOT NULL DEFAULT '' COMMENT 'S-成功,F-失败,P-过期',
`lockFlag` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '1是已经锁 0是未锁',
`beginTime` datetime DEFAULT NULL COMMENT '开始时间',
`endTime` datetime DEFAULT NULL COMMENT '结束时间',
`UUID` char(36) COLLATE utf8_bin NOT NULL DEFAULT '抢到锁的节点的UUID',
`time` int(10) unsigned NOT NULL DEFAULT '60' COMMENT '方法生命周期内只允许一个结点获取一次锁,单位:分钟',
PRIMARY KEY (`keyResource`) USING BTREE
) ENGINE = InnoDB
DEFAULT CHARSET = utf8
COLLATE = utf8_binResourceMapper.xml 自定义 SQL 语句 :
使用
select for ... update
:查询数据的同时为数据加写锁,直到事务提交时释放。注意,在Springboot+Mybatis中,数据源底层为每一次数据库操作(包括查询操作,即使它不修改数据)都自动执行了事务提交操作,所以在下面的 service层 要使用 @Transactional 关闭自动提交,否则一加上锁就会被立刻释放。
**getLock(Param:keyResource)**:
1
2
3
4
5<select id="getLock" parameterType="java.lang.String" resultType="com.michael.usercenter.model.domain.ResourceLock">
select * from 'resource_lock'
where keyResource = #{keyResource,jdbcType=VARCHAR}
for update
</select>
ResourceLockService 接口设计:(仿照Redisson,设计 getLock 和 tryLock 的原因在上面 Redisson分布式锁小节有解释)
**ResourceLock: getLock(String keyResource)**:
@Transactional:主要的作用是关闭自动提交。
如果不关闭自动提交,在下面调用
mapper.getLock()
中,select for update本身也会触发事务提交,导致锁立刻失效。调用
mapper.getLock()
,返回数据库中资源的锁lock
如果
lock
为空,则创建lock
:keyResource
设为当前资源keystatus
设为 SlockFlag
设为1beginTime
设为当前时间time
数据库默认为60min,表示锁的生效时长endTime
=beginTime
+time
- 生成UUID并填入
- 向数据库中更新
lock
返回
lock
boolean: tryLock(String keyResource, int waitTime, int leaseTime):
- @Transactional
- 开始
waitTime
计时:- 调用
mapper.getLock()
,获取lock
- 检查
lock
的字段:- 如果
lockFlag
为1且status
为P,表示正在被占用,如果计时还未结束,则重复上一步 - 如果
lockFlag
为1且status
为F,表示正在被占用但是已经过期,如果计时还未结束,则重复上一步(TODO 锁的过期管理机制) - 如果
lockFlag
为0且status
为S,表示锁空闲,进行下一步
- 如果
- 如果计时结束,则直接返回false
- 调用
- 如果锁空闲:
beginTime
设为当前时间time
设为leaseTime
的值endTime
=beginTime
+time
lockFlag
设为1status
设为P- 生成UUID并填入
- 向数据库中更新
lock
- 返回true
boolean: lock(String keyResource, int leaseTime):
TODO
void: unlock(String keyResource, String UUID):
unlock() 相当于 Redisson 的 isHeldByCurrentThread() + unlock(),但是 Redisson 用 lua 脚本保证了这一组合的原子性。这里不打算引入 lua 脚本,所以整合为一个方法,配合行级锁来保证原子性。
- @Transactional
- 调用
mapper.getLock()
,得到lock
- 将
lock.UUID
与UUID
比较。 - 如果相等:
lockFlag
设为0status
设为S- 向数据库更新
lock
- 如果不等,则直接返回
功能开发 | 偷懒
组队相关的功能还应该实现的有:
- 用户退出队伍
- 队长解散队伍
但是这两个功能的实现都是写业务逻辑,没有涉及新的知识点,所以就偷懒不实现了,目前为止写的业务逻辑已经够多了。
再别说。
功能开发 | 随机匹配算法
前后端开发
前后端联调 | 引入 Axios
使用 Axios 发送网络请求。
在用户中心项目中,前端使用了 Ant Design Pro 框架,Ant Design Pro自身集成了 Axios 库。
而本项目前端是使用 Vite 生成的 Vue 脚手架,没有集成 Axios。
引入 Axios 库:
npm install Axios
创建
src/plugins/myAxios.ts
,为请求设置公共的baseURL
:1
2
3
4
5import axios from 'axios';
const myAxios = axios.create({
baseURL: 'http://localhost:8080/api',
})
export default myAxios;这样,在项目中就使用
myAxios
来发送请求。前面有提到配置 Axios 的跨域问题。
创建
src/services/api.ts
,集中封装 API在
api.ts
中编写接口API调用接口进行测试
总结
还未查询了解的概念:
- Redis 的分布式,多台Redis服务器,Redis 集群 红锁
- 缓存雪崩?缓存穿透?Redis连接池?
bug:
修改了包路径后提示找不到类,java.lang.ClassNotFoundException,看日志发现最外层是 redis 的包抛出的错误。
在这篇帖子找到了同样的问题并解决。原因大概是:由于Redis缓存路径依赖于对象的原始类名,即使类名已更改,仍试图在旧路径下寻找。解决方法是清理Redis的数据并重新调用接口。
学到的 Java 知识点
Optional.ofNullable(teamQuery.getTeamStatus()).orElse(-1);
BeanUtils.copyProperties();
GSON 类库:提供处理 JSON 格式的数据的API,
例如 把 JSON 格式的数组转成
List<String>
:List<String> tempTagNameList=gson.fromJson(tagStr,new TypeToken<List<String>>(){}.getType());
并发编程 CompleTableFuture,如果默认的线程池满足不了需要,还可以自定义线程池。
Java8 StreamAPI
学到的后端知识点:
- 养成写接口设计的习惯,先设计,再开发。
- 整合接口文档 Knife4j (是Swagger的增强版),能够自动生成文档,还提供了简洁好用的调试功能,GOOD!
- 引入 Redis,快速将单机登录改为分布式登录
- 使用 Redis,对经常使用的数据进行缓存。
- 理解定时任务的概念,使用 定时任务+redis 实现对数据的缓存预热
- 引入 Redisson,基于 Redisson 实现分布式锁对分布式下的定时任务的进行管理。
- 通过 AOP+自定义注解 方式,对重复的参数校验代码进行优化。
- 使用 Mybatis-Plus 的分页插件
- 使用 Mybatis-Plus-Join 插件优化多表联查的操作。
- 线程锁的关键字 synchronized 以及注解 @Synchronized 的区别。
- 事务管理的注解@Transactional,实际上进行了:关闭自动提交、处理异常并回滚、重置自动提交、自动提交等一系列操作。
- @Transactional和 synchronized 放在一起使用会出大问题,这篇文章讲的很好。
- 理解 Mybatis-plus 的乐观锁思想,使用乐观锁处理并发的数据修改操作,保证数据正确。
- 设计了一套基于数据库的分布式锁方案。
学到的前端知识点:
- Vite、Vue、Vant、Vue Router的使用
- 使用 Pinia 进行前端的状态管理(自己拓展的)。