layout: post
author: zjhChester
header-img: img/post-bg-hacker.jpg
catalog: true
tags:
- projects
純前後端項目的搜索引擎實戰
前言:
本項目是與2019.12.12初步完成,基於java configuration
的ssm
後臺,純前後端分離項目,並內嵌tomcat,一鍵啓動。
本項目初衷是針對於小範圍社區(企業,學校,院系)提供問題解決方案,不斷擴充和更新的解決方案庫。
本項目所有後臺代碼和前端js
交互邏輯代碼均原創
版本參數:
mysql
:8.0.11
spring
: 5.0.2.RELEASE
mybatis
:3.4.6
servlet
:4.0.1
tomcat
:8.5.33
正文:
核心:
爲啥本項目取名爲搜索引擎呢,在長達一週,超過60個小時的編碼過程裏,時間主要耗費在搜索這塊的業務實現,所以最終把他定爲搜索引擎的項目。
兩個重點:
搜索這一塊我總結了一下兩點爲重點:
1、在龐大的數據量下,如何把關鍵詞更快更準確的檢索出來
2、在檢索之前,如何過濾掉無效關鍵詞和定位主次關鍵詞,讓搜索結果有效的排序。
ok咱們先聊一聊第一個問題,簡而言之,就是搜索的效率和精準度。
搜索的效率和精準度。
首先,搜索的效率:
其實在做項目之前,我有考慮到用es(ElasticSearch)做引擎搜索,不過,我想要去更深一步的發掘mysql的潛力,說白了就是提升自己的mysql能力,更大程度上,我是把es看成了一種工具,程序員嘛,有的時候同樣業務效果,更喜歡去深究底層的東西。
比較難搞的事情就是在最初課程學習mysql的時候,並沒有去深入瞭解檢索效率的問題,以至於數據庫的水平就停留在最基礎的sql語句水平,所以在項目之初,想不到如何入手這個搜索引擎
。
搜索=模糊匹配?
說到搜索引擎,大部分人腦子裏都會出現模糊匹配
這個詞組,所以呢,最初就是用sql語句like去實現最基本的檢索需求
select * from tableName where fieldName like '%java%'
然而在最初的3w條模擬數據下,我檢索一個關鍵詞’java
’,檢索出來的時間居然是45s,並且在查詢過程中,磁盤的讀取利用率可以在任務管理器看到是100%。
amazing!那這還怎麼玩,不考慮查詢併發的問題,一個人查詢就能把磁盤讀取拉滿,還有45s的等待時間,如果放網頁上,那不直接就請求超時。
那怎麼辦呢,原因是剛剛的sql語句like 如果是%%雙百分號就是前後匹配,走的是全文檢索,意思是一條一條的問,不會走咱們的索引,所以即便在標題關鍵詞裏面加入索引也不會優化目前的搜索速度,如何改呢,既然他不走索引,咱們要強制讓他走索引,在翻閱相關資料後發現後匹配%即'java%'
這種形式,是可以走索引的,這樣一來雖然減少了匹配的條目,比如,在標題中央出現關鍵詞,他就不會檢索出來,但是現在的檢索效率在測試後發現,從45s降到了驚人的0.6s!
select * from tableName where fieldName like 'java%'
然後在網上傳的locate函數即
select * from tableName where locate('keywords',fieldName)
其實在很大程度上,和普通的like匹配無差,也不會走索引。
在此基礎上,因爲模擬數據目前只有3w條,我就暫時沒有去優化sql,而是更關心業務部分的東西,然而當最後的測試數據到了100w條的時候(總sql文件4.3G)的時候,全文檢索一遍,即便是後置匹配走索引,查詢時間居然是120s+!
這可不得了,查閱了各方的文章都沒有合適的回答,在最後要交付答卷前的2小時的時候,無聊的我嘗試了兩個sql語句
select * from tableName limit 0,20;
select id from tableName limit 0,20;
select * from tableName where id = ${id}
看似業務效果相同的兩個sql語句,甚至第二個感覺還會耗費更多的查詢次數,然而實際效果是可能第二個跑完所有的20條數據,第一個連1/10的結果都沒跑完,這裏肯定有的同學會講怎麼可能,然而在龐大的100w條數據的支撐下,事實就是如此的不敢相信,最後檢索的速度由最初的120s,穩定在了0.3s以內,也就是說,我在100w條數據內,不管搜索什麼關鍵詞,都能在1s以內把結果呈現給我。當然剛剛的第二條sql語句的第二條需要在後臺裏面循環去執行,ok,到此,搜索的效率就提升上來了。
接下來聊一聊搜索的精準度:
這裏的精準度,咱們先從原理上聊一聊,在後臺吧數據拿到持久層之後,咱們可以從哪些方面去增加檢索的精準度?細心地小夥伴肯定會發現,第一個是檢索詞的優化,另一個就是檢索結果的良好排序,檢索詞的優化,咱們放到下一節代碼層面上講,這裏咱們先把檢索結果排序講一講。
咱們知道,mysql like語句查詢出來的結果,他是亂序的,除非你用order by 等等排序的限定詞他會展示一定程度上的有序(發表時間,id順序,首字母順序等等),然而關乎查詢精準度,咱們根本不回去關心他的id在前在後,發表時間是否是幾年前或者今年(不過這個有可能有關結果更新程度),咱們最爲關心的是什麼?當然是查詢出來的結果和關鍵詞是否搭配,和關鍵詞匹配度最高的結果條目。
order by length(fieldName) desc
以上就是在查詢之後排序出和關鍵詞字段匹配度最高的順序。
ok,咱們接下來討論一下第二個問題:
過濾掉無效關鍵詞和定位主次關鍵詞:
這一部分,就是java代碼呈現了
說難也不難,每一句代碼大家都看得懂,我大概解讀一下,在前端拿回關鍵詞之前,先用trim把兩端的空格去掉(當然純前後端分離我想的是最後在後端接收的時候也吧前後端的空格去掉一下),拿到關鍵詞之後咱們用split 通過正則表達式把空格以及很多個空格和一些高頻的介詞過濾掉
String[] split = keywords.split("\\s+|、|,|。|;|?|!|,|\\.|;|\\?|!|]|的|得|地|中|內|外");
當然我這裏肯定還沒有把介詞寫完整,那麼現在的數組內部就得到了幾個主要的關鍵詞,當然裏頭還需要把空串給過濾掉
List<String> keywordsList = new ArrayList<>();
for (String s:
split) {
//處理介詞空串
if(!"".equals(s)){
keywordsList.add(s);
}
}
空串過濾掉之後,就是最終我們要進行檢索的關鍵詞組,但是還要考慮如果這個人只輸入了介詞或者空格,因爲咱們是前後端分離,要從接口層面把反饋給寫好,所以良好的提示是必不可少的:
//如果只輸入了介詞直接返回
if(keywordsList.size()==0){
return ResponseModel.failResModel(0,"please input args");
}
最後的關鍵詞集合需要把最初的關鍵詞也加入,舉個例子spring中的ioc,那麼這個中的其實並不是爲了隔離每個關鍵詞,我們需要把整個詞條也納入關鍵詞組
keywordsList.add(keywords);
這樣我們的無效搜索和關鍵詞優化就處理了。
在這兩個搜索重點之後呢,另外一個比較重要的就是用戶體驗。
關鍵詞高亮算法
所謂高亮,就是把搜索出來的詞條裏面的關鍵詞的部分,給加上紅色或者其他顏色,標識目前詞條和用戶所需關鍵詞的匹配度,例如百度搜索的東西,這樣看似很簡單的東西,我用了兩部分來完成,一部分是後臺過濾無效關鍵詞後,給出有效關鍵詞,另一部分是前端把有效關鍵詞拿到,通過遍歷迭代把詞組內部的關鍵詞定位並染色。
後臺過濾無效關鍵詞在上一部分已經給出,這裏給出返回給前端的關鍵詞語句:返回的格式是關鍵詞1,關鍵詞2
//其他優先級關鍵詞
for (String s:
keywordsList) {
realKeywords.append(s+",");
}
return ResponseModel.successResModel(1,realKeywords.toString().trim().substring(0,realKeywords.length()-1), resList.toArray());
realKeywords這個集合就是有效關鍵詞組
前端高亮算法:
//文字高亮 解決方案2 先把結果內容轉小寫 去匹配關鍵字的小寫,匹配到了記錄index,str.length 在原結果串取出來,再進行replace()
//1、取出關鍵詞的小寫
if(e.result != undefined){
//轉小寫
var lowercaseKeywords = e.desc.toLowerCase().split(",");
for (var i = 0; i < e.result.length; i++) {
//2、取結果串的小寫
var lowerResContent = e.result[i].title.toLowerCase();
//3、匹配
//找到後裝到index[]
var index=[];
for (var j = 0; j < lowercaseKeywords.length; j++) {
index.push(lowerResContent.indexOf(lowercaseKeywords[j]));
}
// 如果index!=-1 取出原串的值 然後替換 裝入需要高亮的原關鍵詞組
var keywordsFromRes = []
for (var j = 0; j < index.length; j++) {
if(index[j] != -1){
//截取的截止部位是拿到的關鍵詞串數組中的串的長度
keywordsFromRes.push(e.result[i].title.substr(index[j],Number(lowercaseKeywords[j].length)));
}
}
//進行替換
var title= e.result[i].title;
var type = e.result[i].type;
var desc = e.result[i].desc;
for (var j = 0; j < keywordsFromRes.length; j++) {
title = title.replace(new RegExp(keywordsFromRes[j],'g') ,"<em>"+keywordsFromRes[j]+"</em>");
type = type.replace(new RegExp(keywordsFromRes[j],'g') ,"<em>"+keywordsFromRes[j]+"</em>");
desc = desc.replace(new RegExp(keywordsFromRes[j],'g') ,"<em>"+keywordsFromRes[j]+"</em>");
}
var author = e.result[i].author;
var views = e.result[i].views;
簡單講一下js算法的原理,在後臺拿到有效關鍵詞後先把所有的關鍵詞轉小寫toLowerCase()
,因爲咱們前面後臺的返回結果是把每個有效關鍵詞用逗號隔開,所以我們取得時候,直接就以逗號給分割,分割之後我們就得到了一個一個的全小寫關鍵詞,然後我們把搜索結果全轉小寫,用於匹配小寫關鍵詞,然後我們用indexOf把定位關鍵詞的位置,
for (var j = 0; j < lowercaseKeywords.length; j++) {
index.push(lowerResContent.indexOf(lowercaseKeywords[j]));
}
定位到之後我們裝入一個集合,作爲下標集合,我們用於遍歷原檢索結果。這裏需要注意的是,js的substr和java的String類裏頭的subString是有很大差別的,js的處理是起始地址和截取長度,java的是起始地址和終止地址,小夥伴一定要注意:
var keywordsFromRes = []
for (var j = 0; j < index.length; j++) {
if(index[j] != -1){
//截取的截止部位是拿到的關鍵詞串數組中的串的長度
keywordsFromRes.push(e.result[i].title.substr(index[j],Number(lowercaseKeywords[j].length)));
}
}
//進行替換
var title= e.result[i].title;
var type = e.result[i].type;
var desc = e.result[i].desc;
for (var j = 0; j < keywordsFromRes.length; j++) {
title = title.replace(new RegExp(keywordsFromRes[j],'g') ,"<em>"+keywordsFromRes[j]+"</em>");
type = type.replace(new RegExp(keywordsFromRes[j],'g') ,"<em>"+keywordsFromRes[j]+"</em>");
desc = desc.replace(new RegExp(keywordsFromRes[j],'g') ,"<em>"+keywordsFromRes[j]+"</em>");
}
最終我們就可以把已經替換好高亮詞彙的檢索結果呈現到網頁上!
啓動/配置:
本項目啓動,使用的是內嵌tomcat的方式,摒棄了傳統的外置tomcat,項目啓動更快捷,更方便
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>8.5.33</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.tomcat.embed/tomcat-embed-jasper -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<version>8.5.33</version>
</dependency>
public static void run(){
Tomcat tomcat = new Tomcat();
tomcat.setPort(8080);
// 標識tomcat啓動爲webapp
tomcat.addWebapp("/","D://test/");
try {
// tomcat啓動
tomcat.start();
// tomcat監聽用戶接入
tomcat.getServer().await();
} catch (LifecycleException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
run();
}
mvc和ioc容器配置文件使用的是javaConfiguration
的方式配置:
package xyz.zjhwork.springApplicationStarter.mvcConf;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;
import org.apache.ibatis.datasource.pooled.PooledDataSource;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.mapper.MapperScannerConfigurer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import xyz.zjhwork.interceptor.LoginInterceptor;
import javax.sql.DataSource;
import java.io.IOException;
import java.util.List;
import java.util.Properties;
@Configuration
@EnableWebMvc
@ComponentScan("xyz.zjhwork")
public class MvcConf implements WebMvcConfigurer {
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
// 字符轉換 包括解決中文亂碼
FastJsonHttpMessageConverter fastJsonHttpMessageConverter = new FastJsonHttpMessageConverter();
fastJsonHttpMessageConverter.setSupportedMediaTypes(MediaType.parseMediaTypes("text/html;charset=utf-8"));
converters.add(fastJsonHttpMessageConverter);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
//攔截器註冊
registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/newException").addPathPatterns("/")
.addPathPatterns("/userStatus").addPathPatterns("/userExit").addPathPatterns("/newException").addPathPatterns("/myListException").addPathPatterns("/userInfo")
.addPathPatterns("/isFavByUsernameAndExceptionId").addPathPatterns("/findFavByUsername").addPathPatterns("/deleteFavFromFavByUsernameAndExceptionId").addPathPatterns("/addFavByUsernameAndExceptionId")
.addPathPatterns("/isAproByUsernameAndExceptionId").addPathPatterns("/addAproByUsernameAndExceptionId").addPathPatterns("/insertComment").addPathPatterns("/findHistoryByUsername").addPathPatterns("/userInfoUpdate")
;
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/");
registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/");
registry.addResourceHandler("/img/**").addResourceLocations("classpath:/static/img/");
registry.addResourceHandler("/theme/**").addResourceLocations("classpath:/static/theme/");
// 靜態資源存放
registry.addResourceHandler("/*.html").addResourceLocations("classpath:/static/");
}
/**
* mybatisConf
*
* @return
*/
@Bean("pooledDataSource")
public DataSource dataSource() {
//加載db.properties 讀取數據庫基本信息
Properties pop = new Properties();
try {
pop.load(this.getClass().getClassLoader().getResourceAsStream("db.properties"));
} catch (IOException e) {
e.printStackTrace();
}
PooledDataSource dataSource = new PooledDataSource();
try {
dataSource.setDriver(pop.getProperty("jdbc.driver"));
dataSource.setUsername(pop.getProperty("jdbc.username"));
dataSource.setPassword(pop.getProperty("jdbc.password"));
dataSource.setUrl(pop.getProperty("jdbc.url"));
dataSource.setDefaultAutoCommit(true);
dataSource.setPoolMaximumActiveConnections(20);
dataSource.setPoolMaximumIdleConnections(0);
} catch (Exception e) {
e.printStackTrace();
}
return dataSource;
}
@Bean("sqlSessionFactoryBean")
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws IOException {
SqlSessionFactory factory;
SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
bean.setDataSource(dataSource);
//加載mapper.xml
ResourcePatternResolver resolver = new ClassPathXmlApplicationContext();
bean.setMapperLocations(resolver.getResources("classpath*:/daoMappers/*.xml"));
try {
factory = bean.getObject();
} catch (Exception e) {
throw new RuntimeException(e);
}
return factory;
}
@Bean("mapperScannerConfigurer")
public MapperScannerConfigurer mapperScannerConfigurer() {
MapperScannerConfigurer mapperScannerConfigurer = new MapperScannerConfigurer();
mapperScannerConfigurer.setBasePackage("xyz.zjhwork.dao");
mapperScannerConfigurer.setSqlSessionFactoryBeanName("sqlSessionFactoryBean");
return mapperScannerConfigurer;
}
}
結語:
本文只提供本項目的核心算法和思想,另外本項目還包含了markdown富文本編譯器的整合等的,本項目github地址:
https://github.com/zjhChester/ExceptionSearch.git本地址也有效果圖!
最後放兩張效果圖在上面供大家參考,需要幫助或者溝通的同學們聯繫郵箱[email protected]。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-pM3gpHvQ-1580630195713)(https://zjhchester.github.io/img/exceptionSearch/2.png)]
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-cFwwDtuh-1580630195714)(https://zjhchester.github.io/img/exceptionSearch/3.png)]
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-e2ljwPqt-1580630195715)(https://zjhchester.github.io/img/exceptionSearch/4.png)][外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-qkoTIzQD-1580630195716)(https://zjhchester.github.io/img/exceptionSearch/5.png)][外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-DvVr2xqM-1580630195717)(https://zjhchester.github.io/img/exceptionSearch/6.png)][外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-6u8hzMyc-1580630195717)(https://zjhchester.github.io/img/exceptionSearch/10.png)]