站内搜索解决方案: Gulp + Lucene
1. 前言
类似 jfoa: https://github.com/JavaFamilyClub/jfoa, 当项目功能越来越多, 大部分站点都会提供站内搜索以方便用户能够快速定位到想要跳转的页面,
或者一些搜索引擎都必然需要一些全文检索的技术. jfoa 项目目前可预见后台管理页面以后势必功能点较多, 因此便需要站内搜索的支持 — 提供一个搜索输入框, 对用户搜索的内容进行全文检索,
然后显示所有的搜索结果链接, 当用户点击链接时跳转到对应的页面.
现如今绝大多数项目都采用前后端分离的模式, 那么 Server 端如何确定前端页面路由地址和用户搜索关键字的对应关系?
目前就 Java 而言, 全文检索技术基本都是基于
Lucene
(无论是Solr
, 还是Elasticsearch
根本原理都是基于Lucene
), 因此我们就先用Lucene
实现全文检索, 当jfoa
之后升级到微服务架构后, 再将Lucene
升级到Elasticsearch
2. Lucene 简介
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