搜索引擎(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并注明来意。请确保在使用过程中遵守相关法律法规。任何因使用本技术内容而导致的直接或间接损失,作者概不负责。用户需自行承担因使用本技术内容而产生的所有风险和责任。请勿将本技术内容用于任何非法用途。
上一篇
下一篇