搜索引擎(vue3+springboot3.4.1+Solr9.7.0+MySQL8.3.0);

https://github.com/mozhongzhou/vue-springboot-solr

写于2024-12-29

架构

前端vue3

后端springboot3.4.1

搜索引擎以及分词系统Solr9.7.0

原始数据库MySQL8.3.0

环境(环境变量等问题不解释)

Solr9.7.0

MySQL8.3.0

Maven3.9.9

maven源该改就改,不然很慢

Java

操作系统为Windows11家庭版

配置MySQL(导入测试数据)

按理说生产环节这是最后的步骤,本次放在第一步

利用navicat先建表,然后导入sql,本次测试用sql十分复杂.建议测试时选取简单的数据

简单看一下数据量 不到32万

配置Solr(Solr+MySQL)

本项目关键所在

下载

https://solr.apache.org 找编译好的二进制文件或者用源文件自行编译

配置三个lib

分词器

市面上有多种开源分词器,2024-12-29试了IK分词器,经过多次尝试,发现似乎不支持solr9.7.0

采用Solr9.7.0自带中文分词器 在下面这个目录

把上面这个分词器放到下面文件夹目录去

E:\software\solr-9.7.0\server\solr-webapp\webapp\WEB-INF\lib

mysql依赖

需要mysql-connector-j-8.3.0.jar 对应版本的mysql依赖,在mysql官网下载,网上查得到

DIH(用于将mysql数据导入Solr)

还需要一个data-import-handler-9.7.0.jar 以前版本solr自带的 现在独立成一个项目了? 去github上找 下载放入

配置Solr核心

bin\solr create -c company

managed-schema.xml

<field name="userid" type="pint" indexed="true" stored="true" />
<field name="lsguserid" type="string" indexed="true" stored="true" />
<field name="username" type="string" indexed="true" stored="true" />
<field name="groupid" type="pint" indexed="true" stored="true" />
<field name="pid" type="pint" indexed="true" stored="true" />
<field name="company" type="text_smartcn" indexed="true" stored="true" />
<field name="level" type="pint" indexed="true" stored="true" />
<field name="validated" type="pint" indexed="true" stored="true" />
<field name="validator" type="string" indexed="true" stored="true" />
<field name="validtime" type="pint" indexed="true" stored="true" />
<field name="vip" type="pint" indexed="true" stored="true" />
<field name="vipt" type="pint" indexed="true" stored="true" />
<field name="vipr" type="pint" indexed="true" stored="true" />
<field name="type" type="text_smartcn" indexed="true" stored="true" />
<field name="catid" type="string" indexed="true" stored="true" />
<field name="catids" type="string" indexed="true" stored="true" />
<field name="areaid" type="pint" indexed="true" stored="true" />
<field name="mode" type="string" indexed="true" stored="true" />
<field name="capital" type="string" indexed="true" stored="true" />
<field name="regunit" type="text_smartcn" indexed="true" stored="true" />
<field name="size" type="text_smartcn" indexed="true" stored="true" />
<field name="regyear" type="string" indexed="true" stored="true" />
<field name="regcity" type="string" indexed="true" stored="true" />
<field name="sell" type="text_smartcn" indexed="true" stored="true" />
<field name="buy" type="text_smartcn" indexed="true" stored="true" />
<field name="buy_keyword" type="text_smartcn" indexed="true" stored="true" />
<field name="business" type="text_smartcn" indexed="true" stored="true" />
<field name="telephone" type="string" indexed="true" stored="true" />
<field name="fax" type="string" indexed="true" stored="true" />
<field name="mail" type="string" indexed="true" stored="true" />
<field name="address" type="text_smartcn" indexed="true" stored="true" />
<field name="postcode" type="string" indexed="true" stored="true" />
<field name="homepage" type="string" indexed="true" stored="true" />
<field name="fromtime" type="pint" indexed="true" stored="true" />
<field name="totime" type="pint" indexed="true" stored="true" />
<field name="styletime" type="plong" indexed="true" stored="true" />
<field name="thumb" type="string" indexed="true" stored="true" />
<field name="introduce" type="text_smartcn" indexed="true" stored="true" />
<field name="hits" type="pint" indexed="true" stored="true" />
<field name="keyword" type="text_smartcn" indexed="true" stored="true" />
<field name="sell_area" type="text_smartcn" indexed="true" stored="true" />
<field name="template" type="string" indexed="true" stored="true" />
<field name="skin" type="string" indexed="true" stored="true" />
<field name="domain" type="string" indexed="true" stored="true" />
<field name="icp" type="string" indexed="true" stored="true" />
<field name="linkurl" type="string" indexed="true" stored="true" />
<field name="service_times" type="pint" indexed="true" stored="true" />
<field name="parkid" type="pint" indexed="true" stored="true" />
<field name="prior" type="pint" indexed="true" stored="true" />
<field name="platformid" type="pint" indexed="true" stored="true" />
<field name="ncgroupid" type="pint" indexed="true" stored="true" />
<!-- 定义一个通用字段 -->
<field name="mytext" type="text_smartcn" indexed="true" stored="false" multiValued="true"/>
<!-- 将所有需要搜索的字段内容复制到通用字段 -->
<copyField source="company" dest="mytext"/>
<copyField source="type" dest="mytext"/>
<copyField source="regunit" dest="mytext"/>
<copyField source="size" dest="mytext"/>
<copyField source="sell" dest="mytext"/>
<copyField source="buy" dest="mytext"/>
<copyField source="buy_keyword" dest="mytext"/>
<copyField source="business" dest="mytext"/>
<copyField source="address" dest="mytext"/>
<copyField source="introduce" dest="mytext"/>
<copyField source="keyword" dest="mytext"/>
<copyField source="sell_area" dest="mytext"/>
<!-- 其他字段 -->
<copyField source="userid" dest="mytext"/>
<copyField source="lsguserid" dest="mytext"/>
<copyField source="username" dest="mytext"/>
<copyField source="groupid" dest="mytext"/>
<copyField source="pid" dest="mytext"/>
<copyField source="level" dest="mytext"/>
<copyField source="validated" dest="mytext"/>
<copyField source="validator" dest="mytext"/>
<copyField source="validtime" dest="mytext"/>
<copyField source="vip" dest="mytext"/>
<copyField source="vipt" dest="mytext"/>
<copyField source="vipr" dest="mytext"/>
<copyField source="catid" dest="mytext"/>
<copyField source="catids" dest="mytext"/>
<copyField source="areaid" dest="mytext"/>
<copyField source="mode" dest="mytext"/>
<copyField source="capital" dest="mytext"/>
<copyField source="regyear" dest="mytext"/>
<copyField source="regcity" dest="mytext"/>
<copyField source="telephone" dest="mytext"/>
<copyField source="fax" dest="mytext"/>
<copyField source="mail" dest="mytext"/>
<copyField source="postcode" dest="mytext"/>
<copyField source="homepage" dest="mytext"/>
<copyField source="fromtime" dest="mytext"/>
<copyField source="totime" dest="mytext"/>
<copyField source="styletime" dest="mytext"/>
<copyField source="thumb" dest="mytext"/>
<copyField source="hits" dest="mytext"/>
<copyField source="template" dest="mytext"/>
<copyField source="skin" dest="mytext"/>
<copyField source="domain" dest="mytext"/>
<copyField source="icp" dest="mytext"/>
<copyField source="linkurl" dest="mytext"/>
<copyField source="service_times" dest="mytext"/>
<copyField source="parkid" dest="mytext"/>
<copyField source="prior" dest="mytext"/>
<copyField source="platformid" dest="mytext"/>
<copyField source="ncgroupid" dest="mytext"/>
中文的type是text_smartcn 便于分词器工作
新增一个字段mytext用于默认关键词模糊查询,存放所有数据

solrconfig.xml

注意新增

<requestHandler name="/dataimport" class="org.apache.solr.handler.dataimport.DataImportHandler">
<lst name="defaults">
<str name="config">data-config.xml</str>
</lst>
</requestHandler>

data-config.xml(新增文件)

E:\software\solr-9.7.0\server\solr\company\conf 新增文件

<dataConfig>
<dataSource type="JdbcDataSource"
driver="com.mysql.cj.jdbc.Driver"
url="jdbc:mysql://localhost:3306/lucene"
user="root"
password="1231512315" />
<document>
<entity name="company" query="SELECT * FROM company LIMIT 100000 OFFSET ${dataimporter.request.offset}">
<field column="userid" name="userid" />
<field column="lsguserid" name="lsguserid" />
<field column="username" name="username" />
<field column="groupid" name="groupid" />
<field column="pid" name="pid" />
<field column="company" name="company" />
<field column="level" name="level" />
<field column="validated" name="validated" />
<field column="validator" name="validator" />
<field column="validtime" name="validtime" />
<field column="vip" name="vip" />
<field column="vipt" name="vipt" />
<field column="vipr" name="vipr" />
<field column="type" name="type" />
<field column="catid" name="catid" />
<field column="catids" name="catids" />
<field column="areaid" name="areaid" />
<field column="mode" name="mode" />
<field column="capital" name="capital" />
<field column="regunit" name="regunit" />
<field column="size" name="size" />
<field column="regyear" name="regyear" />
<field column="regcity" name="regcity" />
<field column="sell" name="sell" />
<field column="buy" name="buy" />
<field column="buy_keyword" name="buy_keyword" />
<field column="business" name="business" />
<field column="telephone" name="telephone" />
<field column="fax" name="fax" />
<field column="mail" name="mail" />
<field column="address" name="address" />
<field column="postcode" name="postcode" />
<field column="homepage" name="homepage" />
<field column="fromtime" name="fromtime" />
<field column="totime" name="totime" />
<field column="styletime" name="styletime" />
<field column="thumb" name="thumb" />
<field column="introduce" name="introduce" />
<field column="hits" name="hits" />
<field column="keyword" name="keyword" />
<field column="sell_area" name="sell_area" />
<field column="template" name="template" />
<field column="skin" name="skin" />
<field column="domain" name="domain" />
<field column="icp" name="icp" />
<field column="linkurl" name="linkurl" />
<field column="service_times" name="service_times" />
<field column="parkid" name="parkid" />
<field column="prior" name="prior" />
<field column="platformid" name="platformid" />
<field column="ncgroupid" name="ncgroupid" />
</entity>
</document>
</dataConfig>

数据有点多,分四次导完,每次10万条数据

<entity name=”company” query=”SELECT * FROM company LIMIT 10000 OFFSET ${dataimporter.request.offset}”> 每次从设置位置导入10000条数据

用法(示例)

配置完后,启动solr 然后cmd中用api将数据库数据导入solr中

第一次 curl “http://localhost:8983/solr/company/dataimport?command=full-import&clean=true&commit=true&offset=0”

第二次 curl “http://localhost:8983/solr/company/dataimport?command=full-import&clean=false&commit=true&offset=100000”

第三次 curl “http://localhost:8983/solr/company/dataimport?command=full-import&clean=false&commit=true&offset=200000”

第四次 curl “http://localhost:8983/solr/company/dataimport?command=full-import&clean=false&commit=true&offset=300000”

其他 solr.cmd

java目录调整?看自己

启动内存设置?看自己

IF “%SOLR_JAVA_MEM%”==”” set SOLR_JAVA_MEM=-Xms4g -Xmx4g

启动solr

环境变量加好了后 solr start (官方推荐在bin的上层目录使用 bin\solr +命令 启动)

似乎显示有问题,但运行成功

solr控制台 http://localhost:8983/solr/#/

启动后 核心要等一会才会启动

导入完数据的核心

面板使用举例 查询演示

配置后端Springboot

MySQL主要用于连接Solr提供数据

后端需要连接Solr,使用Solr的查询,给前端提供API

创建后端项目

利用spring initializr

springboot的java版本用低一点的,兼容性好 (虽然本地环境java为23)

注意solr的依赖加进去

<dependency>
<groupId>org.apache.solr</groupId>
<artifactId>solr-solrj</artifactId>
<version>9.7.0</version>
</dependency>

连接solr

spring.application.name=demo
spring.data.solr.host=http://localhost:8983/solr
spring.data.solr.core=company
# CORS配置
spring.mvc.cors.allowed-origins=http://localhost:5173
spring.mvc.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
spring.mvc.cors.allowed-headers=*
spring.mvc.cors.allow-credentials=true
spring.mvc.cors.max-age=3600

这个搜索逻辑不可谓不简单

SearchController.java

package com.example.demo;
import org.apache.solr.client.solrj.SolrQuery;
import org.apache.solr.client.solrj.SolrServerException;
import org.apache.solr.client.solrj.impl.HttpSolrClient;
import org.apache.solr.client.solrj.response.QueryResponse;
import org.apache.solr.client.solrj.response.SolrPingResponse;
import org.apache.solr.common.SolrDocumentList;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;
@RestController
@CrossOrigin(origins = "http://localhost:5173", allowCredentials = "true")
public class SearchController {
private static final Logger logger = Logger.getLogger(SearchController.class.getName());
@Value("${spring.data.solr.host}")
private String solrHost;
@Value("${spring.data.solr.core}")
private String solrCore;
@GetMapping("/api/test-connection")
public ResponseEntity<Map<String, Object>> testConnection() {
Map<String, Object> response = new HashMap<>();
HttpSolrClient solrClient = null;
try {
solrClient = new HttpSolrClient.Builder(solrHost + "/" + solrCore).build();
SolrPingResponse pingResponse = solrClient.ping();
int statusCode = pingResponse.getStatus();
long qTime = pingResponse.getQTime();
response.put("status", "success");
response.put("message", "成功连接到Solr服务器");
response.put("statusCode", statusCode);
response.put("responseTime", qTime);
response.put("solrUrl", solrHost + "/" + solrCore);
return ResponseEntity.ok(response);
} catch (Exception e) {
response.put("status", "error");
response.put("message", "连接Solr服务器失败: " + e.getMessage());
response.put("solrUrl", solrHost + "/" + solrCore);
return ResponseEntity.status(500).body(response);
} finally {
if (solrClient != null) {
try {
solrClient.close();
} catch (IOException e) {
// ignore
}
}
}
}
@GetMapping("/api/search")
public ResponseEntity<Map<String, Object>> search(
@RequestParam String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) throws SolrServerException, IOException {
HttpSolrClient solrClient = new HttpSolrClient.Builder(solrHost + "/" + solrCore).build();
SolrQuery query = new SolrQuery();
query.setQuery("mytext:" + q);
query.setStart(page * size);
query.setRows(size);
query.setSort("score", SolrQuery.ORDER.desc);
query.setHighlight(true);
query.addHighlightField("mytext");
query.setHighlightSimplePre("<em>");
query.setHighlightSimplePost("</em>");
QueryResponse response = solrClient.query(query);
SolrDocumentList results = response.getResults();
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("results", results);
resultMap.put("numFound", results.getNumFound());
resultMap.put("start", results.getStart());
resultMap.put("page", page);
resultMap.put("size", size);
return ResponseEntity.ok(resultMap);
}
}

跨域

跨域问题是指在浏览器中,当一个域下的网页尝试请求另一个域下的资源时,由于浏览器的同源策略限制,导致无法直接进行通信的问题。同源策略是浏览器的一项安全功能,要求两个页面必须具有相同的协议、域名和端口号才能进行交互。如果协议、域名或端口号有任何一个不同,就会发生跨域。

config/Corsconfig.java

package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}

启动后端

mvn spring-boot:run

要做一些测试,比如说后端与solr连接情况 利用之前写的api/test-connection

一步一步做,没问题了再继续

配置前端Vue

创建前端项目

npm vue create@latest 创建项目,默认设置即可,到时候写一个简单组件用于演示即可

SearchComponent.vue

前端核心组件

<template>
<div class="search-container">
<!-- 连接状态检查 -->
<div
class="connection-status"
:class="{ connected: isConnected, disconnected: !isConnected }"
>
Solr服务状态: {{ isConnected ? "已连接" : "未连接" }}
</div>
<!-- 搜索区域 -->
<div class="search-box">
<input
v-model="query"
placeholder="请输入搜索关键词..."
@keyup.enter="search"
:disabled="!isConnected"
class="search-input"
/>
<button
@click="search"
:disabled="!isConnected || !query"
class="search-button"
>
<span v-if="!loading">搜索</span>
<span v-else>搜索中...</span>
</button>
</div>
<!-- 错误提示 -->
<div v-if="error" class="error-message">
{{ error }}
</div>
<!-- 搜索结果 -->
<div v-if="results && results.length > 0" class="search-results">
<div v-for="result in results" :key="result.id" class="result-card">
<h3 v-if="result.company" v-html="result.company"></h3>
<div v-if="result.address" class="result-field">
<strong>地址:</strong><span v-html="result.address"></span>
</div>
<div v-if="result.type" class="result-field">
<strong>类型:</strong><span v-html="result.type"></span>
</div>
<div v-if="result.business" class="result-field">
<strong>业务:</strong><span v-html="result.business"></span>
</div>
<div v-if="result.introduce" class="result-field">
<strong>简介:</strong><span v-html="result.introduce"></span>
</div>
</div>
</div>
<!-- 无结果提示 -->
<div v-else-if="results && !loading && query && !error" class="no-results">
未找到相关结果
</div>
</div>
</template>
<script>
import axios from "axios";
const API_BASE_URL = "http://localhost:8080/api";
export default {
name: "SearchComponent",
data() {
return {
query: "",
results: null,
loading: false,
error: null,
isConnected: false,
};
},
created() {
this.checkConnection();
},
methods: {
async checkConnection() {
try {
const response = await axios.get(`${API_BASE_URL}/test-connection`, {
withCredentials: true,
});
this.isConnected = response.data.status === "success";
} catch (error) {
this.isConnected = false;
this.error = "无法连接到搜索服务";
console.error("Connection error:", error);
}
},
async search() {
if (!this.query || !this.isConnected) return;
this.loading = true;
this.error = null;
try {
const response = await axios.get(`${API_BASE_URL}/search`, {
params: {
q: this.query,
},
withCredentials: true,
});
this.results = response.data.results;
} catch (error) {
this.error = "搜索过程中出现错误,请稍后重试";
console.error("Search error:", error);
} finally {
this.loading = false;
}
},
},
};
</script>
<style scoped>
.search-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.connection-status {
padding: 8px 16px;
border-radius: 4px;
margin-bottom: 20px;
text-align: center;
}
.connected {
background-color: #e6f4ea;
color: #1e7e34;
}
.disconnected {
background-color: #fde7e9;
color: #dc3545;
}
.search-box {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.search-input {
flex: 1;
padding: 12px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 16px;
transition: border-color 0.3s;
}
.search-input:focus {
border-color: #4a90e2;
outline: none;
}
.search-button {
padding: 12px 24px;
background-color: #4a90e2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.search-button:hover:not(:disabled) {
background-color: #357abd;
}
.search-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.error-message {
padding: 12px;
background-color: #fde7e9;
color: #dc3545;
border-radius: 4px;
margin-bottom: 20px;
}
.search-results {
display: grid;
gap: 20px;
}
.result-card {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.result-card h3 {
margin: 0 0 12px 0;
color: #2c3e50;
}
.result-field {
margin-bottom: 8px;
line-height: 1.5;
}
.result-field strong {
color: #666;
margin-right: 8px;
}
.no-results {
text-align: center;
padding: 40px;
color: #666;
background-color: #f8f9fa;
border-radius: 8px;
}
:deep(em) {
background-color: #fff3cd;
font-style: normal;
padding: 0 2px;
border-radius: 2px;
}
</style>

App.vue

主文件 调用Search组件

<script setup>
import SearchComponent from "./components/SearchComponent.vue";
</script>
<template>
<header>
<img
alt="Vue logo"
class="logo"
src="./assets/logo.svg"
width="125"
height="125"
/>
</header>
<main>
<SearchComponent />
</main>
</template>
<style scoped>
header {
line-height: 1.5;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
}
</style>

启动

npm run dev

效果演示

启动Solr服务器 后端springboot 前端vue后

打开前端界面http://localhost:5173/可以查看效果

优化?

功能方面:增加字段筛选,比如从公司里面找,简介里面找

显示方面:高亮关键词

本技术内容仅供学习和交流使用,如有疑问请联系qq2014160588并注明来意。请确保在使用过程中遵守相关法律法规。任何因使用本技术内容而导致的直接或间接损失,作者概不负责。用户需自行承担因使用本技术内容而产生的所有风险和责任。请勿将本技术内容用于任何非法用途。
上一篇
下一篇