站内搜索解决方案: Gulp + Lucene

站内搜索解决方案: Gulp + Lucene

1. 前言

file

类似 jfoa: https://github.com/JavaFamilyClub/jfoa, 当项目功能越来越多, 大部分站点都会提供站内搜索以方便用户能够快速定位到想要跳转的页面,
或者一些搜索引擎都必然需要一些全文检索的技术. jfoa 项目目前可预见后台管理页面以后势必功能点较多, 因此便需要站内搜索的支持 — 提供一个搜索输入框, 对用户搜索的内容进行全文检索,
然后显示所有的搜索结果链接, 当用户点击链接时跳转到对应的页面.

file

现如今绝大多数项目都采用前后端分离的模式, 那么 Server 端如何确定前端页面路由地址和用户搜索关键字的对应关系?

目前就 Java 而言, 全文检索技术基本都是基于 Lucene (无论是 Solr, 还是 Elasticsearch 根本原理都是基于 Lucene), 因此我们就先用 Lucene 实现全文检索, 当jfoa 之后升级到微服务架构后, 再将 Lucene 升级到 Elasticsearch

2. Lucene 简介

file

file

Apache Lucene TM是完全用Java编写的高性能,功能齐全的文本搜索引擎库。它是一项适用于几乎所有需要全文搜索的应用程序的技术,尤其是跨平台。
Apache Lucene是一个开源项目,可以免费下载。
Lucene通过简单的API提供了强大的功能:

1 可扩展的高性能索引

  • 每小时超过150GB的现代硬件
  • 小内存需求-仅1MB堆
  • 增量索引与批处理索引一样快
  • 索引大小大约为被索引文本的大小的20-30%

2 强大,准确,高效的搜索算法

  • 排名搜索-返回最佳结果
  • 许多强大的查询类型:词组查询,通配符查询,邻近查询,范围查询等
  • 现场搜索(例如标题,作者,内容)
  • 按任何字段排序
  • 合并结果的多索引搜索
  • 允许同时更新和搜索
  • 灵活的分面,突出显示,联接和结果分组
  • 快速,记忆有效且耐错字的建议者
  • 可插拔排名模型,包括向量空间模型和Okapi BM25
  • 可配置的存储引擎(编解码器)

3 跨平台解决方案

  • 作为Apache许可下的开源软件提供,使您可以在商业程序和开源程序中使用Lucene
  • 100%纯Java
  • 实现在其他编程语言是指数兼容

3. 小试牛刀

Lucene 的学习这里不再详述, 我们这里只讨论技术解决方案. 技术的学习大家如果感兴趣可以留言, 必要的话我出文章或者视频.

4. 实践

jfoa 前端使用的是 Angular10哦, 大家如果用 vue/react也是类似.

4.1 前端添加注解进行关键字与路由信息的声明

@Searchable({
  title: "Role Manager",
  route: "/em/setting/role-manager",
  keywords: [
    "role", "role manager"
  ]
})
@Component({
  selector: "em-role-manager",
  templateUrl: "./role-manager.component.html",
  styleUrls: ["./role-manager.component.scss"]
})
export class RoleManagerComponent implements OnInit {

4.2 通过 gulp 将 @Searchable 的信息写入前端编译 dist

const gulp = require("gulp");
const through = require("through2");
const fs = require("fs");
const path = require("path");
const File = require("vinyl");

const CharSet_UTF_8 = "utf-8";

const generateMetadata = function() {
   const searchEntries = [];

   function generateSearchMetadata(file) {
      const content = file.contents.toString(CharSet_UTF_8);
			// 匹配注解
      const expr = /@Searchable\s*\(\s*({[^}]+})\s*\)/;
      const match = expr.exec(content);

      if(match != null) {
			  // 提取值
         const metadata = eval("(" + match[1] + ")");
         const route = metadata.route;
         const title = metadata.title;
         const keywords = metadata.keywords;
				
				// 存入数组
         searchEntries.push({route, title, keywords});
      }
   }

   function generateFileMetadata(file, encoding, callback) {
      if(file.isNull()) {
         callback();
         return;
      }

      if(file.isStream()) {
         this.emit("error", new Error("metadata: Streaming not supported"));
         callback();
         return;
      }

      generateSearchMetadata(file);

      return callback();
   }

   function endStream(callback) {

		 // 将所有的注解值写入 json 文件
      this.push(new File({
         path: "admin/search-index.json",
         contents: Buffer.from(JSON.stringify({entries: searchEntries}), CharSet_UTF_8)
      }));

      callback();
   }

   return through({objectMode: true}, generateFileMetadata, endStream);
};

gulp.task("metadata", function() {
   return gulp.src("src/app/**/*.component.ts") // 源文件夹
      .pipe(generateMetadata())
      .pipe(gulp.dest("../../runner/build/resources/main/config")); // 目标文件夹
});

4.3 Server 创建 SearchController

public class SearchController {

   @PostConstruct
   public void createDocument() throws IOException {
	   // 创建库文件夹
      File searchLib = new File(Tool.getCacheDir(), "searchLib/");

      if(searchLib.exists()) {
         if(FileSystemUtils.deleteRecursively(searchLib)) {
            LOGGER.info("Delete cache search lib dir: {}", searchLib.getAbsolutePath());
         }
         else {
            LOGGER.info("Delete cached search lib({}) failed.", searchLib.getAbsolutePath());
         }
      }

      if(!searchLib.exists() || !searchLib.isDirectory()) {
         if(searchLib.mkdirs()) {
            LOGGER.info("Auto create search lib dir: " + searchLib.getAbsolutePath());
         }
      }

		 // 解析 json文件转化为 java 对象
      ObjectMapper mapper = new ObjectMapper();
      URL json = this.getClass().getResource("/config/admin/search-index.json");
      SearchModel searchModel = mapper.readValue(json, SearchModel.class);

      if(searchModel == null) {
         LOGGER.error("Can't found file: {}.", json.getPath());
         return;
      }

// 写入索引
      // 创建基于文件的 Directory, 你也可以创建基于内存的 RAMDirectory, 不过在最新版本的 Lucene 已经过时了
      Directory directory = new MMapDirectory(Paths.get(searchLib.toURI()));
			// 基于 Analyzer 创建 IndexWriterConfig, Analyzer 根据语言环境的不同有不同的 Analyzer.英文可以直接使用标准的 StandardAnalyzer, 中文可以使用 ZKAnalyzer或者CJKAnalyzer
      IndexWriterConfig writerConfig = new IndexWriterConfig(getAnalyzer());
      Searchable[] entries = searchModel.getEntries();

      if(ArrayUtils.isEmpty(entries)) {
         return;
      }

		 // 创建写入索引库的 writer 对象
      try(IndexWriter writer = new IndexWriter(directory, writerConfig)) {
         for(Searchable searchable : entries) {
            String titleStr = searchable.getTitle();
            String routeStr = searchable.getRoute();
            String[] keywordsArray = searchable.getKeywords();

			     // 创建各个字段和 Document 
            TextField title = new TextField("title", titleStr, Field.Store.YES);
            StringField route = new StringField("route", routeStr, Field.Store.YES);
            TextField keyword
               = new TextField("keyword", String.join(" ", keywordsArray), Field.Store.NO);

            Document document = new Document();
            document.add(title);
            document.add(route);
            document.add(keyword);
						// 写入 Document
            writer.addDocument(document);
         }
      }

      reader = DirectoryReader.open(directory);
      searcher = new IndexSearcher(reader); // 创建检索器
      queryParser = new QueryParser("keyword", getAnalyzer()); // 创建检索 keyword 字段的QueryParser
   }

   @PreDestroy
   public void destroy() throws Exception {
      if(reader != null) {
         reader.close();
         reader = null;
         LOGGER.info("Close Search Index Reader!");
      }

      searcher = null;
      queryParser = null;
   }

   private Analyzer getAnalyzer() {
      return new StandardAnalyzer();
   }

   @GetMapping("/public/tool/search")
   @ApiOperation(value = "Search", httpMethod = "GET")
   public SearchResult search(@ApiParam("Search key words") @RequestParam("searchWords") String searchWords)
      throws Exception
   {
      Query query = queryParser.parse(searchWords);
      TopDocs topDocs = searcher.search(query, 10);

      List<Searchable> searchables = new ArrayList<>();
      SearchResult searchResult = new SearchResult(searchables);
      searchResult.setTotal(topDocs.totalHits);

      ScoreDoc[] scoreDocs = topDocs.scoreDocs;

      if(ArrayUtils.isEmpty(scoreDocs)) {
         return searchResult;
      }

      for(ScoreDoc doc : scoreDocs) {
         Searchable searchable = new Searchable();
         int docId = doc.doc;
         Document document = searcher.doc(docId);
         String route = document.get("route");
         String title = document.get("title");
         searchable.setTitle(title);
         searchable.setRoute(route);

         searchables.add(searchable);
      }

      return searchResult;
   }

   private IndexReader reader;
   private IndexSearcher searcher;
   private QueryParser queryParser;

   private static final Logger LOGGER = LoggerFactory.getLogger(SearchController.class);
}

5. 遗留的问题

5.1 多语言环境

jfoa 这种多语言支持项目, 索引库应该支持本地化和基于 Locale 的全文检索.

在这种情况下只需要针对所支持的语言环境创建不同的索引库, 每次检索时根据客户端语言环境或者相应的索引库再进行检索就可以了.

5.2 项目前后端分离

如果项目前后端分离, gulp 就不能直接写入server 机器了

  • 采用分布式检索: ElasticSearch 等
  • 存储索引库到server resources, 当项目进入交付阶段, 索引库基本就不会再变更了, 这个时候就可以将索引库存储, 而不用每次创建.

6. 源码

具体代码大家可以参考 jfoa

file

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值