微服务的编程测评系统13-我的竞赛列表-elasticSearch
提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- 前言
- 1. 我的竞赛列表
- 1.1 遗留问题
- 1.2 后端开发
- 1.3 竞赛报名前端开发
- 2. 题目列表功能
- 2.1 elasticSearch基本知识
- 2.2 elasticSearch使用
- 2.2 Kibana
- 3. es命令
- 3.1 PUT增加
- 3.2 GET查询
- 3.3 DELETE删除
- 3.4 POST修改
- 3.5 分词器
- 3.6 安装ik分词器
- 3.7 分词模式
- 3.8 项目引入
- 总结
前言
1. 我的竞赛列表
1.1 遗留问题
处理完用户请求之后,要清理ThreadLocal里面的数据
—》拦截器–》after可以完成这个功能–》清理所有的数据,因为这个线程已经没了,数据肯定也要清理掉了
@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {ThreadLocalUtil.remove();}
这样就可以了
1.2 后端开发
@GetMapping("/list")public TableDataInfo list(ExamQueryDTO examQueryDTO){log.info("获取用户报名的竞赛信息,examQueryDTO:{}", examQueryDTO);return userExamService.list(examQueryDTO);}
直接复制获取exam历史和已发布的竞赛的代码,然后改改
@AllArgsConstructor
@Getter
public enum ExamListType {EXAM_UN_FINISH_LIST(0),EXAM_HISTORY_LIST(1),USER_EXAM_LIST(2);private final Integer value;}
private String getExamListKey(Integer examListType,Long userId) {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;}else if(ExamListType.USER_EXAM_LIST.getValue().equals(examListType)){return CacheConstants. +userId;}return null;}
尤其是改ExamCacheManager,主要就是增加了userid
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ck.friend.mapper.user.UserExamMapper"><select id="selectUserExamList" resultType="com.ck.friend.domain.exam.vo.ExamVO">SELECTe.exam_id,e.title,e.start_time,e.end_timeFROMtb_user_exam ueJOINtb_exam eONue.exam_id = e.exam_idWHEREue.user_id = #{userId}ORDER BYue.create_time DESC</select>
</mapper>
@Overridepublic TableDataInfo list(ExamQueryDTO examQueryDTO) {examQueryDTO.setType(ExamListType.USER_EXAM_LIST.getValue());Long userId= ThreadLocalUtil.get(Constants.USER_ID,Long.class);Long listSize = examCacheManager.getListSize(examQueryDTO.getType(),userId);List<ExamVO> list ;if(listSize==null||listSize==0){//说明缓存中没有数据,所以要先从数据库中获取数据,然后存入redisPageHelper.startPage(examQueryDTO.getPageNum(), examQueryDTO.getPageSize());list = userExamMapper.selectUserExamList(userId);examCacheManager.refreshCache(examQueryDTO.getType(),userId);long total = new PageInfo<>(list).getTotal();return TableDataInfo.success(list, total);}else{//直接从redis中获取数据list = examCacheManager.getExamVOList(examQueryDTO,userId);listSize = examCacheManager.getListSize(examQueryDTO.getType(),userId);return TableDataInfo.success(list, listSize);}}
这样就没有问题了
1.3 竞赛报名前端开发
竞赛开始前:没有报名显示未报名,已经报名显示已报名
竞赛开始后:没有报名显示已开赛,已经报名显示开始答题
所以我们要加上按钮
@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;private boolean enter;//true表示已经报名,false表示没有报名}
开发之前我们要先完善后端,后端返回的examList要自带是否报名的字段
在ExamCacheManager中增加方法
public List<Long> getAllUserExam(Long userId) {String detailKey = getDetailKey(userId);List<Long> cacheListByRange = redisService.getCacheListByRange(detailKey, 0, -1, Long.class);if(CollectionUtil.isNotEmpty(cacheListByRange)){return cacheListByRange;}List<UserExam> userExams = userExamMapper.selectList(new LambdaQueryWrapper<UserExam>().eq(UserExam::getUserId, userId));refreshCache(ExamListType.USER_EXAM_LIST.getValue(), userId);if(CollectionUtil.isEmpty(userExams)){return null;}return userExams.stream().map(UserExam::getExamId).toList();}
@Overridepublic TableDataInfo redisList(ExamQueryDTO examQueryDTO) {Long listSize = examCacheManager.getListSize(examQueryDTO.getType(),null);List<ExamVO> list;if(listSize==null||listSize==0){//说明缓存中没有数据,所以要先从数据库中获取数据,然后存入redislist = list(examQueryDTO);examCacheManager.refreshCache(examQueryDTO.getType(),null);listSize = new PageInfo<>(list).getTotal();}else{//直接从redis中获取数据list = examCacheManager.getExamVOList(examQueryDTO,null);listSize = examCacheManager.getListSize(examQueryDTO.getType(),null);}assembleEnterExamVoList(list);return TableDataInfo.success(list, listSize);}private void assembleEnterExamVoList(List<ExamVO> list) {Long userId = ThreadLocalUtil.get(Constants.USER_ID, Long.class);List<Long> allUserExam = examCacheManager.getAllUserExam(userId);if(CollectionUtil.isEmpty(allUserExam)){return;}for(ExamVO examVO : list){if(allUserExam.contains(examVO.getExamId())){examVO.setEnter(true);}}}
这样就可以了
然后是前端开发
<div class="exam-button-container"><span class="exam-hash-entry"v-if="isOngoingAndUnregisteredCompetition(exam)">已开赛</span><span class="exam-hash-entry" v-if="isEntryAndNotStart(exam)">已报名</span><div v-if="isHistoryExam(exam)"><el-button class="exam-practice-button" type="primary" plain@click="goExam(exam)">竞赛练习</el-button><el-button class="exam-rank-button" type="primary" plain@click="togglePopover(exam.examId)">查看排名</el-button></div><el-button class="exam-start-button" type="primary" plain v-if="isStartExam(exam)"@click="goExam(exam)">开始答题</el-button><el-button class="exam-enter-button" type="primary" plain v-if="isCanEntry(exam)"@click="enterExam(exam.examId)">报名参赛</el-button></div>
给Exam.vue增加按钮
function isOngoingAndUnregisteredCompetition(exam) {const now = new Date(); //当前时间return new Date(exam.startTime) < now && new Date(exam.endTime) > now && !exam.enter
}function isEntryAndNotStart(exam) {const now = new Date();return new Date(exam.startTime) > now && exam.enter
}function isHistoryExam(exam) {const now = new Date();return new Date(exam.endTime) < now;
}function isStartExam(exam) {const now = new Date();return new Date(exam.startTime) < now && new Date(exam.endTime) > now && exam.enter;
}function isCanEntry(exam) {const now = new Date();return new Date(exam.startTime) > now && !exam.enter;
}const isLogin = ref(false)
async function checkLogin() {if (getToken()) {await getUserInfoService()isLogin.value = true}
}
然后是按钮是否显示的逻辑判断
然后是报名按钮
export function enterExamService(enterExamDTO) {return service({url: "/user/exam/enter",method: "post",data: enterExamDTO,});
}
async function enterExam(examId) {await checkLogin()if (!isLogin.value) {ElMessage.error('请先登录后报名参赛,谢谢')return}const examDto = ref({examId: examId})await enterExamService(examDto.value)ElMessage.success('您已报名成功,请按时参赛',)getExamList() //报名成功后刷新竞赛列表
}
这样就成功了
然后是我的竞赛列表页面
创建UserExam.vue
还有路由
function goMyExam(){router.push("/c-oj/home/user/exam")
}
然后是跳转按钮,这个跳转更新的是子页面
然后就是开发这个页面了
export function getMyExamListService(params) {return service({url: "/user/exam/list",method: "get",params,});
}
<template><div class="my-exam-page"><div class="exam-list-block"><div class="exam-list-header"><span class="ex-title">我的竞赛</span><span class="exam-list-back" @click="goBack">返回</span></div><div class="exam-list-content" v-for="(exam, index) in myExamList" :key="index"><img src="@/assets/ide/jingsai.png" class="image" /><div class="exam-content"><div class="title">{{ exam.title }}</div><div class="date"><span>比赛时间:</span> {{ exam.startTime }} ~ {{ exam.endTime }}</div></div><div><div class="exam-end-lable-list" v-if="isHistoryExam(exam)"><span class="exam-end-lable">已完赛</span><el-button class="exam-rank-lable" type="text" @click="togglePopover(exam.examId)">查看排名</el-button><el-button class="exam-rank-lable" type="text" @click="goHistoryExam(exam)">竞赛练习</el-button></div><div class="exam-status-lable exam-end-lable-list" v-else-if="isNotStart(exam)">未开赛</div><div class="exam-end-lable-list" v-else><el-button class="exam-rank-lable" type="text" plain @click="goExam(exam)">开始答题</el-button></div></div></div><div class="my-exam-pagination"><!-- 增加分页展示器 --><el-pagination background layout="total, sizes, prev, pager, next, jumper" :total="total"v-model:current-page="params.pageNum" v-model:page-size="params.pageSize" :page-sizes="[5, 10, 15, 20]"@size-change="handleSizeChange" @current-change="handleCurrentChange" /></div></div></div>
</template><script setup>
import { reactive, ref } from 'vue'
import {getMyExamListService,
} from "@/apis/exam"
import router from '@/router'const myExamList = ref([]) //消息列表const total = ref(0)
const params = reactive({pageNum: 1,pageSize: 10,
})// 分页
function handleSizeChange(newSize) {params.pageNum = 1getMyExamList()
}function handleCurrentChange(newPage) {getMyExamList()
}//消息列表
async function getMyExamList() {const ref = await getMyExamListService(params.value)myExamList.value = ref.rowstotal.value = ref.total
}
getMyExamList()function isHistoryExam(exam) {const now = new Date();return new Date(exam.endTime) < now;
}const isNotStart = (exam) => {const now = new Date(); //当前时间return new Date(exam.startTime) > now;
}const goBack = () => {router.go(-1)
}</script><style lang="scss">
.my-exam-page {max-width: 1520px;margin: 0 auto;background-color: rgba(247, 247, 247, 1);position: relative;overflow: hidden;display: flex;justify-content: center;
}.exam-list-block {width: 100%;display: flex;flex-wrap: wrap;
}.exam-list-header {background-color: rgba(255, 255, 255, 1);border-radius: 10px;width: 100%;/* 设置宽度为100%以确保水平居中 */height: 60px;font-size: 25px;text-indent: 20px;display: flex;align-items: center;margin-top: 20px;.ex-title {font-family: PingFangSC, PingFang SC;font-weight: 600;font-size: 18px;color: #222222;}.exam-list-back {cursor: pointer;color: #999999;font-size: 15px;margin-left: auto;padding-right: 20px;}
}.exam-list-content {height: 110px;width: 100%;background: #FFFFFF;border-radius: 10px;margin: 0 auto;display: flex;align-items: center;margin-top: 20px;justify-content: space-between;cursor: pointer;&:hover {box-shadow: 0px 0px 6px 0px rgba(0, 0, 0, 0.1);}img {width: 50px;height: 50px;margin: 0 20px;}
}.exam-list-content .exam-content {max-width: calc(100% - 320px);width: 100%;
}.exam-list-content .title {font-weight: bold;font-size: 16px;margin-bottom: 10px;
}.exam-list-content .date {font-weight: 400;color: #000;line-height: 20px;margin-left: auto;font-size: 14px;span {color: #999;}/* 将日期推至行尾 */
}.exam-list-content .exam-status-lable {color: #999999;
}.exam-end-lable-list {padding-right: 20px;min-width: 228px;text-align: right;.exam-end-lable {margin-right: 10px;font-family: MicrosoftYaHei;font-size: 16px;color: #999999;}.exam-rank-lable {font-size: 16px;}}.my-exam-pagination {width: 100%;display: flex;justify-content: flex-end;padding-bottom: 20px;
}
</style>
这样就可以了
但是我们这个我的竞赛页面不想显示页面的图片,该怎么做呢
可以给页面增加变量
{path: '/c-oj/home',name: 'home',component: () => import('@/views/Home.vue'),children: [{path: 'exam',name: 'exam',component: () => import('@/views/Exam.vue'),meta: { showNavbar: true }},{path: 'question',name: 'question',component: () => import('@/views/Question.vue'),meta: { showNavbar: true }},{path: 'user/exam',name: 'userExam',component: () => import('@/views/UserExam.vue'),meta: { showNavbar: false}}]},
在Home页面中
<div v-if="$route.meta.showNavbar" ><img src="@/assets/images/log-banner.png" class="banner-img"></div>
这样就可以了
2. 题目列表功能
先分析一下 redis
题目可以有一个list
key为question:list
value为questionId
然后是每个竞赛详细信息的redis存储
key为question:questionId
value为json详细信息
但是有一个问题,就是redis虽然支持分页搜索,但是不支持模糊搜索那种搜索
但是也可以拿出所有详细题目信息缓存,然后挨个进行模糊搜索–》拿出所有缓存就很性能差了,而且还有所有在遍历一次
第二个办法是把key改为要模糊搜索的字段,比如key含有标题这种要模糊查询的字段,但是中文做key吗,挺别扭的,而且这个办法也不好
业界的redis模糊查询我们用的是elasticSearch
2.1 elasticSearch基本知识
官网
学习官网
我们用的是8.5版本
ElasticSearch,简称ES(后⽂将直接使⽤这⼀简称),是⼀款卓越的开源分布式搜索引擎。其独特之处在于其近乎实时的数据检索能⼒,为⽤⼾提供了迅速、⾼效的信息查询体验。
ES解决什么问题
全⽂检索(全部字段)、模糊查询(搜索)、数据分析(提供分析语法,例如聚合)
倒排索引(Inverted Index)
倒排索引,也被称为反向索引或逆向索引,是⼀种索引数据的⽅法。与正排索引不同。倒排索引是按照⽂档中的词汇(关键词)来组织。也就是说在倒排索引中,索引的键是⽂档集合中出现过的每个独特词汇或关键词,索引的值是包含该关键词的所有⽂档的标识符(如⽂档ID),以及可选的额外信
息。
ES采取的是倒排索引
索引关键字 对应数据序号
⽩蛇 1,2,3,4
⼤战 2
告别 3,4
法海 2
⻘蛇 3
许仙 4
Index 索引
索引,具有相同结构的⽂档集合,类似于关系型数据库的数据库实例(6.0.0版本type废弃后,索引的概念下降到等同于数据库表的级别)。⼀个集群⾥可以定义多个索引,如客⼾信息索引、商品分类索引、商品索引、订单索引、评论索引等等,分别定义⾃⼰的数据结构。索引命名要求全部使⽤⼩写,建⽴索引、搜索、更新、删除操作都需要⽤到索引名称。
type 类型
类型,原本是在索引(Index)内进⾏的逻辑细分,但后来发现企业研发为了增强可阅读性和可维护性,制订的规范约束,同⼀个索引下很少还会再使⽤type进⾏逻辑拆分(如同⼀个索引下既有订单数据,⼜有评论数据),因⽽在6.0.0版本之后,此定义废弃
每个索引只有一中type类型,但是有很多个
Document ⽂档
⼀个⽂档是⼀个可被索引的基础信息单元,Document(⽂档)是JSON格式的,Document 就像是MySQL 中某个 Table ⾥⾯每⼀⾏的数据,Document中可以包含多个字段,每个字段可以是⽂本、数字、⽇期等类型
Field 字段:
字段是⽂档中的⼀个元素或属性,每个字段都有⼀个数据类型,如字符串、整数、⽇期等。
Mapping 映射
Mapping是ES中的⼀个很重要的内容,它类似于传统关系型数据中table的schema(定义了数据库中的数据如何组织,包括表的结构、字段的数据类型、键的设置(如主键、外键)等),⽤于定义⼀个索引的数据的结构(mapping中主要包括字段名、字段数据类型和字段索引类型。)。 在ES中,我们可以⼿动创建mapping,也可以采⽤默认创建⽅式。在默认配置下,ES可以根据插⼊的数据⾃动地创
建mapping。
2.2 elasticSearch使用
拉取es镜像:
docker pull elasticsearch:8.5.3
创建⽹络
docker network create oj-network
为什么要配置网络呢
因为Kibana要操作es,所以配置同一个网络的话,就可以链接了
输入docker network ls
就可以查看创建的网络oj-network了
启动es:
docker run -d --name oj-es-dev -e "ES_JAVA_OPTS=-Xms256m -Xmx256m" -e "discovery.type=single-node" -v D:\spring-project\ck-oj\deploy\dev\elasticSearch\es-plugins:/usr/share/elasticsearch/plugins -e "xpack.security.enabled=false" --privileged --network oj-network -p 9200:9200 -p 9300:9300 elasticsearch:8.5.3
–network oj-network是配置的网络
-v D:\spring-project\ck-oj\deploy\dev\elasticSearch\es-plugins:/usr/share/elasticsearch/plugins是配置挂载卷,将容器映射到本地
----》这样在plugins新增插件就可以在本地操作了
-e "xpack.security.enabled=false"是关闭身份认证
-e "discovery.type=single-node"是单体模式
-e "ES_JAVA_OPTS=-Xms256m -Xmx256m"是配置最大和最小内存,防止es占完了
要先启动es,才能启动Kibana
访问:http://localhost:9200/,验证启动成功。
2.2 Kibana
Kibana是ElasticSearch的数据可视化和实时分析的⼯具。通过Kibana,⽤⼾可以搜索、查看和与存储在Elasticsearch索引中的数据进⾏交互,执⾏⾼级数据分析,并通过各种图表、表格和地图将数据可视化。
就是ES的可视化工具
拉取kibana镜像
docker pull kibana:8.5.3
与ES版本一样
启动kibana容器
docker run -d --name oj-kibana-dev -e "ELASTICSEARCH_HOSTS=http://oj-es-dev:9200" -e "I18N_LOCALE=zh-CN" -p15601:5601 --net=oj-network kibana:8.5.3
-e "I18N_LOCALE=zh-CN"是配置中文页面
-e "ELASTICSEARCH_HOSTS=http://oj-esdev:9200"是配置es地址
访问:http://localhost:15601/,验证启动成功。
点击自己浏览
点击开发工具
左侧是命令,右侧是结果
3. es命令
3.1 PUT增加
PUT /employee/_doc/1
{"first_name" : "John","last_name" : "Smith","age" : 25,"about" : "I love to go rock climbing","interests": [ "sports", "music" ]
}PUT /employee/_doc/2
{"first_name" : "Jane","last_name" : "Smith","age" : 32,"about" : "I like to collect rock albums","interests": [ "music" ]
}PUT /employee/_doc/3
{"first_name" : "Douglas","last_name" : "Fir","age" : 35,"about": "I like to build cabinets","interests": [ "forestry" ]
}
put就是增加的意思
PUT /employee/_doc/3的意思就是
先会增加一个employee索引,1表示新增第一条数据
employee:
表示索引名称(类似数据库中的 “表”),是存储文档的逻辑容器。
这里表示要操作的是名为 employee 的索引,如果该索引不存在,Elasticsearch 会自动创建它(基于默认配置)。
_doc:
表示文档类型(在 Elasticsearch 7.x 及以上版本中,这是默认且唯一的文档类型)。
历史上,一个索引可以有多个文档类型(类似表中的不同 “子表”),但 7.x 后已废弃多类型,统一使用 _doc 作为类型名。
它仅作为路径中的固定标识,无实际业务含义。
3:
表示文档 ID(类似数据库中的 “主键”),是当前文档的唯一标识。
通过这个 ID 可以精确操作该文档(如查询、修改、删除)。
如果省略 ID(如 PUT /employee/_doc),Elasticsearch 会自动生成一个随机字符串作为 ID。
向 employee 索引中,添加或更新 ID 为 3 的文档(如果 ID 为 3 的文档已存在,则会覆盖更新;不存在则创建)。
3.2 GET查询
GET /employee/_search
该请求默认会查询 employee 索引下的所有文档(类似 SQL 的 SELECT * FROM employee),返回的结果会包含匹配的文档列表、总命中数、排序结果等元数据。
_search:Elasticsearch 的搜索专用端点,表示对前面指定的 employee 索引执行 “搜索 / 查询” 操作。
这样就成功了
GET /employee/_doc/3
这个表示查询id为3的文档
3.3 DELETE删除
DELETE /employee/_doc/3
删除指定数据
DELETE /employee
直接删除索引
3.4 POST修改
POST /employee/_update/3{"doc" : { "last_name" : "mark"}}
这个就是修改第三条数据的last_name字段
3.5 分词器
根据前⾯学习的倒排索引的概念。倒排索引是按照⽂档中的词汇(关键词)来组织的,索引的键是⽂档集合中出现过的每个独特词汇或关键词。那es是怎么将这些关键词提取出来的呢?这其实就是es中的分词器在起着作⽤,它负责将⽂本切分成⼀个个有意义的词语,以建⽴索引或进⾏搜索和分析。
我们的业务中通常使⽤的是中⽂分词,es的中⽂分词默认会将中⽂词每个字看成⼀个词⽐如:“我想吃⾁夹馍”会被分为”我”,”想”,”吃”,”⾁” ,”夹”,”馍” 这显然是不太符
合⽤⼾的使⽤习惯,所以我们需要安装中⽂分词器ik,来讲中⽂内容分解成更加符合⽤⼾使⽤的关键
字。
GET _analyze {"text": "我想吃⾁夹馍"}
这个代码的意思就是对我想吃⾁夹馍进分词
我们发现确实对中文就只能一个一个分词
3.6 安装ik分词器
下载官网
下载到D:\spring-project\ck-oj\deploy\dev\elasticSearch\es-plugins这个目录
就是绑定卷的目录,就是映射的那个目录
这样就可以了
如果版本不是8.5.3,那么就要改文件名为8.5.3
然后是plugin-descriptor.properties里面要改版本为8.5.3
version=8.5.3
elasticsearch.version=8.5.3
然后是重启es容器
GET _cat/plugins
这个命令就可以知道我们安装的插件了
3.7 分词模式
ik分词器提供了两种分词模式:ik_smart 和 ik_max_word,其中 ik_smart 模式会尽量保持⻓词,尽可能地保留词语的完整性,提⾼搜索的准确性,⽽ ik_max_word 模式则会尽可能多地切分出词汇。
GET _analyze {"text": "我想吃肉夹馍","analyzer": "ik_smart"}
这个就是指定ik分词器的什么分词模式了
GET _analyze
{"text": "我想吃⾁夹馍","analyzer": "ik_max_word"
}
3.8 项目引入
先创建一个公共包oj-common-elasticSearch
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
这个包是空的,什么代码都没写,万一后面会写代码呢,而且这是一个组件,所以我们要低耦合
然后是es配置
直接在friend下引入oj-common-elasticSearch,然后配置