提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
前言
1. 退出登录
1.1 后端
后端直接拷贝代码就可以了
但是我们点击了退出登录,用户还是可以查看竞赛和题目列表的,但是不能答题,怎么实现这种可以一些操作的功能呢–》网关—》配置白名单之类的就可以了
1.2 前端
先是home.vue
<template>
<div class="oj-main-layout">
<div class="oj-main-layout-header">
<div class="oj-main-layout-nav">
<Navbar></Navbar>
</div>
</div>
<div >
<img src="@/assets/images/log-banner.png" class="banner-img">
</div>
</div>
<RouterView />
</template>
<script setup>
import Navbar from '@/components/Navbar.vue'
</script>
<style lang="scss" scoped>
.el-main {
padding: 0;
}
.oj-main-layout {
// background-color: #f7f7f7;
padding-top: 20px;
.banner-img {
max-width: 1520px;
margin: 0 auto;
border-radius: 16px;
width: "100%"
}
.oj-main-layout-header {
height: 60px;
position: absolute;
width: 100%;
background: #fff;
left: 0;
top: 0;
z-index: 3;
overflow: hidden;
}
.oj-main-layout-nav {
max-width: 1520px;
min-width: 100%;
margin: 0 auto;
height: 60px;
background: #fff;
}
// banner 图
.oj-ship-banner {
max-width: 1520px;
min-width: 1520;
margin: 0 auto;
width: 100%;
height: 100%;
height: 350px;
// width: 1677px;
color: #ffffff;
background: url("@/assets/index_bg.png") left top no-repeat;
background-size: cover;
overflow: hidden;
}
}
</style>
然后是我们自定义的组件Navbar
放在components
<template>
<div class="oj-navbar">
<div class="oj-navbar-menus">
<img class="oj-navbar-logo" src="@/assets/logo.png" />
<el-menu router class="oj-navbar-menu" mode="horizontal">
<el-menu-item index="/c-oj/home/question">题库</el-menu-item>
<el-menu-item index="/c-oj/home/exam">竞赛</el-menu-item>
</el-menu>
</div>
<div class="oj-navbar-users">
<img v-if="isLogin" class="oj-message" @click="goMessage" src="@/assets/message/message.png" />
<el-dropdown v-if="isLogin">
<div class="oj-navbar-name">
<img class="oj-head-image" v-if="isLogin" :src="userInfo.headImage" />
<span>{{ userInfo.nickName }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="goUserDetail">
<div class="oj-navabar-item">
<span>个人中心</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="goMyExam">
<div class="oj-navabar-item">
<span>我的竞赛</span>
</div>
</el-dropdown-item>
<el-dropdown-item>
<div class="oj-navabar-item">
<span @click="handleLogout">退出登录</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="oj-navbar-login-btn" v-if="!isLogin" @click="goLogin">登录</span>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue';
import router from '@/router';
import { getToken, removeToken } from '@/utils/cookie';
const isLogin = ref(false)
const userInfo = reactive({
nickName: '',
headImage: ''
})
</script>
<style lang="scss" scoped>
.oj-navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
box-sizing: border-box;
max-width: 1520px;
margin: 0 auto;
.oj-navbar-menus {
display: flex;
align-items: center;
height: 50px;
.el-menu-item {
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 20px;
color: #222222;
line-height: 28px;
text-align: center;
width: 42px;
text-align: left;
margin-right: 25px;
}
}
.oj-navbar-logo {
width: 38px;
height: 38px;
background: #32C5FF;
border-radius: 8px;
cursor: pointer;
object-fit: contain;
margin-right: 59px;
}
.oj-navbar-menu {
// margin-left: 18px;
width: 600px;
border: none;
.el-menu-item {
font-size: 16px;
font-weight: 500;
background-color: transparent !important;
transition: none;
border: none;
line-height: 60px;
}
}
.oj-navbar-users {
display: flex;
align-items: center;
}
.oj-navbar-login-btn {
line-height: 60px;
display: inline-block;
font-family: PingFangSC, PingFang SC;
font-weight: 400;
font-size: 18px;
color: #222222;
text-align: center;
cursor: pointer;
.line {
display: inline-block;
width: 25px;
}
}
.oj-message {
cursor: pointer;
margin-top: 15px;
}
.oj-head-image {
width: 30px;
height: 30px;
border-radius: 30px;
margin-right: 10px;
}
.oj-navbar-name {
cursor: pointer;
margin-top: 15px;
font-weight: 400;
color: #000;
margin-left: 15px;
font-size: 20px;
width: 100px;
display: flex;
align-items: center;
}
.oj-navabar-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0 32px;
}
}
</style>
然后是配置登录按钮
function goLogin(){
router.push("/c-oj/login")
}
登录成功以后还要修改登录状态了isLogin
我们先要判断用户状态,第一token存不存在,存在了也不能说明登录了,因为可能过期了,后端过期了,不会删除token,所以还要请求后端,判断token是否过期
<div class="oj-navbar-name">
<img class="oj-head-image" v-if="isLogin" src="@/assets/images/headImage.jpg" />
<span>CK</span>
</div>
我们先把这个写死
这个也要请求后端,顺便就可以判断是否过期了
所以这个请求用户头像和昵称的接口就可以判断是否过期了
function checkToken(){
if(getToken()){
//判断是否过期,获取用户信息
isLogin.value = true;
}
}
checkToken()
这样就成功了,获取头像和昵称的接口还没实现
最后是退出登录的逻辑实现了
export function logoutService() {
return service({
url: "/user/logout",
method: "delete",
});
}
header已经在请求拦截器中添加了
async function handleLogout(){
await ElMessageBox.confirm(
'退出登录',
'温馨提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}
)
await logoutService();
removeToken();
isLogin.value=false;
}
2. 获取当前用户信息
这个还可以判断是否过期
在request.js里面,这个方法中的router.push(‘/c-oj/login’)不要了,没有登录就不要跳转到登录页面了
如果已经过期了–》网关就过不去
@Data
public class LoginUserVO {
private String nickName;
private String headImage;
}
这个是返回的用户数据,core中,那么这样的话,就要在登录的时候就把用户昵称存入redis
@Data
public class LoginUser {
//存储在redis中的用户信息
private Integer identity;
private String nickName;
private String headImage;
}
public String createToken(Long userId, String secret,Integer identity,String nickName,String headImage){
Map<String, Object> claims = new HashMap<>();
String userKey = UUID.fastUUID().toString();
claims.put(JwtConstants.LOGIN_USER_ID, userId);
claims.put(JwtConstants.LOGIN_USER_KEY, userKey);
String token = JwtUtils.createToken(claims, secret);
LoginUser loginUser = new LoginUser();
loginUser.setIdentity(identity);//2表示管理员,1表示普通用户
loginUser.setNickName(nickName);
loginUser.setHeadImage(headImage);
redisService.setCacheObject(getTokenKey(userKey), loginUser, CacheConstants.EXPIRED, TimeUnit.MINUTES);
return token;
}
然后就是在登录的时候存入图片
这样就成功了
管理员那里的createToken传入null就可以了
然后就是前端了
export function infoService() {
return service({
url: "/user/info",
method: "get",
});
}
async function checkToken(){
if(getToken()){
//判断是否过期,获取用户信息
const ret = await infoService()
Object.assign(userInfo,ret.data)
isLogin.value = true;
}
}
<div class="oj-navbar-name">
<img class="oj-head-image" v-if="isLogin" :src="userInfo.headImage" />
<span>{{ userInfo.nickName }}</span>
</div>
注意加了冒号的src是针对响应式数据的,没有加冒号的就是真对字符串进行处理了
这样以后就可以了
3. C端用户竞赛列表功能
3.1 后端
C端列表只展示已经发布的竞赛
C端列表功能可以不登录就使用了—》网关配置,m还有题目列表也不需要登录,配一个统一前缀表示不用登录就可以使用,比如semiLogin
未完赛指的是比赛还没有开始和已经开始的,历史竞赛指的是比赛已结束了
用一个type字段来区分,这也是一个过滤条件,type为0表示没有结束的竞赛,type为1表示已经结束的竞赛
开始写代码
先直接复制管理员的列表接口,然后在做一些修改
@Data
public class ExamQueryDTO extends PageQueryDTO {
private String title;
private String startTime;
private String endTime;
private Integer type;//0表示未结束的竞赛,1表示已经结束的竞赛
}
@Data
public class ExamVO {
@JsonSerialize(using = ToStringSerializer.class)
private Long examId;
private String title;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
}
<select id="selectExamList" resultType="com.ck.friend.domain.exam.vo.ExamVO">
SELECT
te.exam_id,
te.title,
te.start_time,
te.end_time
FROM
tb_exam te
<where>
status = 1
<if test="title !=null and title !='' ">
AND te.title LIKE CONCAT('%',#{title},'%')
</if>
<if test="startTime != null and startTime != '' ">
AND te.start_time >= #{startTime}
</if>
<if test="endTime != null and endTime != ''">
AND te.end_time <= #{endTime}
</if>
<if test="type == 0">
AND te.end_time > Now()
</if>
<if test="type == 1">
AND te.end_time <= Now()
</if>
</where>
ORDER BY
te.create_time DESC
</select>
@GetMapping("/semiLogin/list")
public TableDataInfo list(ExamQueryDTO examQueryDTO){
log.info("获取竞赛列表信息,examQueryDTO:{}", examQueryDTO);
return getTableDataInfo(examService.list(examQueryDTO));
}
security:
ignore:
whites:
- /**/login
- /friend/user/sendCode
- /friend/user/loginOrRegister
- /**/semiLogin/**
这样就成功了
记得还有拦截器也要配置一下
3.2 Jmeter-基本操作
Apache JMeter是Apache组织开发的基于Java的压⼒测试⼯具。⽤于对软件做压⼒测试,它最初被设计⽤于Web应⽤测试,但后来扩展到其他测试领域。
下载官网
一个linux来服务部署
一个linux服务器来组件部署,比如redis,mysql,nacos
一个linux服务器来部署jemeter
压缩之后,在bin目录下找到jmeter.bat文件,双击就可以运行了。就可以启动了
还有一个方法是打开cmd,输入jmeter就可以打开的
----》注意这样的话,就要添加环境变量的,就是添加bin目录就可以了
但是我们的jmeter是英文的,可以变为中文的
在bin目录下找到jmeter.properties
找到language配置,默认是en
改为language=zh_CN就可以了
点击f12,中的network中的XHR可以找到发送的后端请求
先添加一个线程组
然后在添加http请求
点击左上角的运行之后要保存才可以
但是我们直接运行看不到响应的数据,所以还要添加一个监听器
这样运行以后就可以看到结果那些了
这个就是10个请求,相当于10个用户同时发送
点击这个可以清楚结果树
这样就有10个了
3.3 数据版本性能测试-压力测试
这样写的意思就是有1000个用户在120s内不断地发送请求
在linux执行jmeter
4.7/s
表示每s处理4.7个请求JPS
第一行表示42s内处理了201个请求,然后每s4.7个
=就是上面累加的和,是请求数和时间的累加
+表示是不同的数据
最终表示每s只能处理5.9个请求
所以性能不好
Err:表示错误数,和错误数占总数的比例
怎么提高性能呢----》redis
redis可以提高很多的性能
这样也说明了mysql性能太低了
而C端肯定人很多的,所以我们一定要用redis提高速度
因为B端管理员太少了,所以不用管是否用redis提高速度
MySQL可以保证数据的准确性,redis保证速度
所以我们先从redis中查询,如果没有再去数据库,在同步到redis
3.4 redis版本-缓存结构设计
发布竞赛的时候可以存入redis,因为只有发布的竞赛才会展示给用户
取消发布的时候就从redis中移除
然后就是已经发布的竞赛不允许修改(在前端的按钮实现了),所以不用担心redis数据不准确
然后就是选择什么数据结构存储到redis中
—》结构是有序的,可以按照开赛时间来排序,而且支持分页
redis中有list这个数据结构–》支持分页查询
然后就未完赛和历史竞赛可以分开存储到不同redis中,要用两个list
key是什么—》exam:history:list和exam:time:list(未完赛)
然后就是还有一个list就是我报名的竞赛列表,然后就是我报名的竞赛列表一定是会和未完赛和历史竞赛重复一些的–》不好,重复次数太多了就不好了,所以就不要用list来存基本信息了,会重复的
—》一个竞赛基本信息存一份,这样就不会浪费了
value为String,json格式,key为exam:detail:examId
所以exam:history:list和exam:time:list(未完赛)存储examId
exam:detail:examId存储详细信息,这样就不会很浪费了
list存储examId的,一份基本信息存一份String
3.5 redis版本代码开发
注意发布的竞赛肯定是一个还没有开始,还没结束的竞赛
—》e:t:l,和e:d:exmaId
取消发布的竞赛也是一个还没有开始,还没结束的竞赛
查询redis数据的时候,第一次(发布上线的时候自己调用)从数据库中查询,然后存入redis,后面才是从redis中查询
现在system下面写代码
就是发布的时候加入redis,取消发布的时候从redis中删除
创建一个manager的包
@Component
public class ExamCacheManager {
@Autowired
private RedisService redisService;
public void addCache(Exam exam) {
redisService.leftPushForList(getExamListKey(), exam.getExamId());
redisService.setCacheObject(getDetailKey(exam.getExamId()), exam);
}
public void deleteCache(Long examId) {
redisService.removeForList(getExamListKey(), examId);
redisService.deleteObject(getDetailKey(examId));
}
private String getExamListKey() {
return CacheConstants.EXAM_UNFINISHED_LIST;
}
private String getDetailKey(Long examId) {
return CacheConstants.EXAM_DETAIL + examId;
}
}
leftPushForList往左边插入,这个就相当于是把早创建的放到前面了
public final static String EXAM_UNFINISHED_LIST = "exam:time:list"; // 未完赛竞赛列表
public final static String EXAM_HISTORY_LIST = "exam:history:list"; // 历史竞赛列表
public final static String EXAM_DETAIL = "exam:detail:"; //竞赛详情信息
然后就是修改cancelPublish和publish的代码
把这两个方法添加进去就可以了
这两个就是对redis的增加和删除了
然后就是对redis的查询了
至于对redis的修改呢
我们规定
已经发布的,或者已经开赛的,已经存入redis的,不能修改exam相关数据
撤销发布的,没有存入redis的才可以修改exam,这样就没有问题了
然后就是redis的查询了
在friend中
@AllArgsConstructor
@Getter
public enum ExamListType {
EXAM_UN_FINISH_LIST(0),
EXAM_HISTORY_LIST(1);
private final Integer value;
}
public <T> List<T> multiGet(final List<String> keyList, Class<T> clazz) {
List list = redisTemplate.opsForValue().multiGet(keyList);
if (list == null || list.size() <= 0) {
return null;
}
List<T> result = new ArrayList<>();
for (Object o : list) {
result.add(JSON.parseObject(String.valueOf(o), clazz));
}
return result;
}
public <K, V> void multiSet(Map<? extends K, ? extends V> map) {
redisTemplate.opsForValue().multiSet(map);
}
在RedisService中插入这两个方法。这两个方法是批量处理的方法,就是对examId的list批量从redis中获取详细数据,或者批量设置数据,不然一个一个设置,效率还是太低了
还是创建一个manager的包
@Component
public class ExamCacheManager {
@Autowired
private ExamMapper examMapper;
@Autowired
private RedisService redisService;
public Long getListSize(Integer examListType) {
String examListKey = getExamListKey(examListType);
return redisService.getListSize(examListKey);
}
public List<ExamVO> getExamVOList(ExamQueryDTO examQueryDTO) {
int start = (examQueryDTO.getPageNum() - 1) * examQueryDTO.getPageSize();
int end = start + examQueryDTO.getPageSize() - 1; //下标需要 -1
String examListKey = getExamListKey(examQueryDTO.getType());
List<Long> examIdList = redisService.getCacheListByRange(examListKey, start, end, Long.class);
List<ExamVO> examVOList = assembleExamVOList(examIdList);
if (CollectionUtil.isEmpty(examVOList)) {
//说明redis中数据可能有问题 从数据库中查数据并且重新刷新缓存
examVOList = getExamListByDB(examQueryDTO); //从数据库中获取数据
refreshCache(examQueryDTO.getType());
}
return examVOList;
}
//刷新缓存逻辑
public void refreshCache(Integer examListType) {
List<Exam> examList = new ArrayList<>();
if (ExamListType.EXAM_UN_FINISH_LIST.getValue().equals(examListType)) {
//查询未完赛的竞赛列表
examList = examMapper.selectList(new LambdaQueryWrapper<Exam>()
.select(Exam::getExamId, Exam::getTitle, Exam::getStartTime, Exam::getEndTime)
.gt(Exam::getEndTime, LocalDateTime.now())
.eq(Exam::getStatus, Constants.TRUE)
.orderByDesc(Exam::getCreateTime));
} else if (ExamListType.EXAM_HISTORY_LIST.getValue().equals(examListType)) {
//查询历史竞赛
examList = examMapper.selectList(new LambdaQueryWrapper<Exam>()
.select(Exam::getExamId, Exam::getTitle, Exam::getStartTime, Exam::getEndTime)
.le(Exam::getEndTime, LocalDateTime.now())
.eq(Exam::getStatus, Constants.TRUE)
.orderByDesc(Exam::getCreateTime));
}
if (CollectionUtil.isEmpty(examList)) {
return;
}
Map<String, Exam> examMap = new HashMap<>();
List<Long> examIdList = new ArrayList<>();
for (Exam exam : examList) {
examMap.put(getDetailKey(exam.getExamId()), exam);
examIdList.add(exam.getExamId());
}
redisService.multiSet(examMap); //刷新详情缓存
redisService.deleteObject(getExamListKey(examListType));
redisService.rightPushAll(getExamListKey(examListType), examIdList); //刷新列表缓存
}
private List<ExamVO> getExamListByDB(ExamQueryDTO examQueryDTO) {
PageHelper.startPage(examQueryDTO.getPageNum(), examQueryDTO.getPageSize());
return examMapper.selectExamList(examQueryDTO);
}
private List<ExamVO> assembleExamVOList(List<Long> examIdList) {
if (CollectionUtil.isEmpty(examIdList)) {
//说明redis当中没数据 从数据库中查数据并且重新刷新缓存
return null;
}
//拼接redis当中key的方法 并且将拼接好的key存储到一个list中
List<String> detailKeyList = new ArrayList<>();
for (Long examId : examIdList) {
detailKeyList.add(getDetailKey(examId));
}
List<ExamVO> examVOList = redisService.multiGet(detailKeyList, ExamVO.class);
CollUtil.removeNull(examVOList);
if (CollectionUtil.isEmpty(examVOList) || examVOList.size() != examIdList.size()) {
//说明redis中数据有问题 从数据库中查数据并且重新刷新缓存
return null;
}
return examVOList;
}
private String getExamListKey(Integer examListType) {
if (ExamListType.EXAM_UN_FINISH_LIST.getValue().equals(examListType)) {
return CacheConstants.EXAM_UNFINISHED_LIST;
} else if (ExamListType.EXAM_HISTORY_LIST.getValue().equals(examListType)) {
return CacheConstants.EXAM_HISTORY_LIST;
}
return null;
}
private String getDetailKey(Long examId) {
return CacheConstants.EXAM_DETAIL + examId;
}
}
现在开始挨个讲一下这些方法的使用
getListSize是根据key查出对应的list的元素数量
getExamVOList是根据service传入的参数进行分页查询
其实就是根据页数和每页数量进行下标的查询罢了
中的assembleExamVOList是根据从redis查出的examId再从redis查出对应的exam详细数据
getExamListByDB中的CollUtil.removeNull(examVOList);就是移除数组中null的元素
refreshCache是刷新缓存,从数据库中查询exam列表数据,和详细数据
将详细数据直接multiSet,将列表数据,先deleteObject,在rightPushAll
是尾插
然后就可以写正式的代码了
@GetMapping("/semiLogin/redis/list")
public TableDataInfo redisList(ExamQueryDTO examQueryDTO){
log.info("获取竞赛列表信息,examQueryDTO:{}", examQueryDTO);
return examService.redisList(examQueryDTO);
}
@Override
public TableDataInfo redisList(ExamQueryDTO examQueryDTO) {
Long listSize = examCacheManager.getListSize(examQueryDTO.getType());
List<ExamVO> list;
TableDataInfo tableDataInfo =new TableDataInfo();
if(listSize==null||listSize==0){
//说明缓存中没有数据,所以要先从数据库中获取数据,然后存入redis
list = list(examQueryDTO);
examCacheManager.refreshCache(examQueryDTO.getType());
long total = new PageInfo<>(list).getTotal();
return TableDataInfo.success(list, total);
}else{
//直接从redis中获取数据
list = examCacheManager.getExamVOList(examQueryDTO);
listSize = examCacheManager.getListSize(examQueryDTO.getType());
return TableDataInfo.success(list, listSize);
}
}
这样就可以了
这样就可以了
然后就是竞赛慢慢就变为结束了的,怎么转移redis数据呢–》后面再说
3.6 redis版本性能测试
再次进行压力测试
我们在Windows下用jmeter进行测试,发现快得多有了redis之后,相比以前
然后在linux下的jmeter进行测试
发现速度直接提升了几十倍