前言
Halo 搜索引擎在 https://github.com/halo-dev/halo/releases/tag/v2.17.0 版本后进行了重大重构,详见 https://github.com/halo-dev/halo/pull/6082。因此,在 Halo 2.17 版本之后,插件作者可以通过插件对 Halo 搜索进行扩展。
对 Halo 搜索的扩展可以分为两个部分:搜索引擎扩展和搜索文档扩展。
搜索引擎扩展
在插件中,可以通过扩展搜索引擎来提供更为丰富的搜索功能。例如,Halo 默认使用的是 Lucene
,但插件可以通过扩展支持其他搜索引擎,如 Solr
、 MeiliSearch
、 ElaticSearch
。
不过,搜索引擎扩展不在本文讨论范围内,有兴趣的小伙伴可以阅读 Halo 搜索引擎扩展点 以了解更多详情。
搜索文档扩展
如果你的插件提供了新的内容数据,例如 Moment
,使用搜索功能时,你会发现无法搜到 Moment
中的任何内容。这是因为 Halo 默认只提供了文章与页面这两种核心内容的搜索。
对于自建的内容数据,Halo 搜索不会自动补充,因为自定义数据结构可能与文章和页面截然不同。因此,插件作者就需要主动将自己的内容数据提交至 Halo 搜索。
接下来,我将基于 Halo Moment 插件 这个实际案例,详细介绍如何扩展搜索文档。
搜索文档事件
Halo 搜索利用事件机制来收集核心和插件中发布的文档。当插件发布某个文档事件后,Halo 搜索会添加、更新、删除与重建目标文档索引。
要将插件的内容数据提供给搜索,最佳做法是插件主动发出特定事件供 Halo 消费。
目前(Halo 版本 2.17.1), Halo 搜索共监听了三个事件:
添加文档事件 -
HaloDocumentAddRequestEvent
删除文档事件 -
HaloDocumentDeleteRequestEvent
重建索引事件 -
HaloDocumentRebuildRequestEvent
HaloDocumentAddRequestEvent
用于添加或更新文档,例如在 Moment 中添加或更新文档的示例如下:
// MomentReconciler.java
// Create moment search document
var haloDoc = converter.convert(moment).blockOptional().orElseThrow();
eventPublisher.publishEvent(new HaloDocumentAddRequestEvent(this, List.of(haloDoc)));
HaloDocumentDeleteRequestEvent
用于从搜索索引中删除文档,例如删除 Moment 搜索索引的示例如下:
// MomentReconciler.java
eventPublisher.publishEvent(new HaloDocumentDeleteRequestEvent(this,
// Get moment search document id
List.of(converter.haloDocId(moment)))
);
HaloDocumentRebuildRequestEvent
用于重新获取搜索文档索引,示例如下:
eventPublisher.publishEvent(new HaloDocumentRebuildRequestEvent(this));
实现文档扩展
除了使用搜索文档事件添加、更新、删除某个特定文档之外,插件还需要实现 HaloDocumentsProvider
接口,该接口用于重建搜索索引。
案例 - Moment 插件搜索文档同步
了解 Halo 搜索文档同步事件之后,便可以进行文档同步了。以下是 Moment 插件搜索文档同步的步骤:
创建一个
Reconciler
用于处理 Moment 内容更新后的搜索相关业务:
@Component
@RequiredArgsConstructor
public class MomentSearchReconciler implements Reconciler<Reconciler.Request> {
private static final String FINALIZER = "moment-search-protection";
private final ExtensionClient client;
@Override
public Result reconcile(Request request) {
client.fetch(Moment.class, request.name()).ifPresent(moment -> {
if (ExtensionUtil.isDeleted(moment)) {
if (ExtensionUtil.removeFinalizers(moment.getMetadata(), Set.of(FINALIZER))) {
client.update(moment);
}
return;
}
ExtensionUtil.addFinalizers(moment.getMetadata(), Set.of(FINALIZER));
client.update(moment);
});
return Result.doNotRetry();
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Moment())
.workerCount(1)
.build();
}
}
为
MomentSearchReconciler
添加搜索文档扩展相关的内容:
@Component
@RequiredArgsConstructor
public class MomentSearchReconciler implements Reconciler<Reconciler.Request> {
private static final String FINALIZER = "moment-search-protection";
private final ApplicationEventPublisher eventPublisher;
private final ExtensionClient client;
// Convert moment to Halo Search Document
private final DocumentConverter converter;
@Override
public Result reconcile(Request request) {
client.fetch(Moment.class, request.name()).ifPresent(moment -> {
if (ExtensionUtil.isDeleted(moment)) {
if (ExtensionUtil.removeFinalizers(moment.getMetadata(), Set.of(FINALIZER))) {
// Delete the document index when deleting moment.
eventPublisher.publishEvent(
new HaloDocumentDeleteRequestEvent(this,
List.of(converter.haloDocId(moment)))
);
client.update(moment);
}
return;
}
ExtensionUtil.addFinalizers(moment.getMetadata(), Set.of(FINALIZER));
var haloDoc = converter.convert(moment)
.blockOptional().orElseThrow();
// When moment is updated, add or update the document.
eventPublisher.publishEvent(
new HaloDocumentAddRequestEvent(this, List.of(haloDoc)));
client.update(moment);
});
return Result.doNotRetry();
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Moment())
.workerCount(1)
.build();
}
}
其中使用了 DocumentConverter
,用于将 Moment
转为 Halo Search Document
。关于 HaloDocument 的字段见 HaloDocument。以下是需要注意的几个属性:
/**
* Document for search.
*/
@Data
public final class HaloDocument {
/**
* Metadata name of the corresponding extension.
*/
@NotBlank
private String metadataName;
/**
* Whether the document is published.
* After setting it to false, visitors will not be able to search
* and will only be used for system internal search.
*/
private boolean published;
/**
* Whether the document is recycled.
*/
private boolean recycled;
/**
* Whether the document is exposed to the public.
*/
private boolean exposed;
}
实现
HaloDocumentsProvider
接口,用于重建索引时调用:
@Component
@RequiredArgsConstructor
public class MomentHaloDocumentsProvider implements HaloDocumentsProvider {
public static final String MOMENT_DOCUMENT_TYPE = "moment.moment.halo.run";
private final ReactiveExtensionClient client;
private final DocumentConverter converter;
@Override
public Flux<HaloDocument> fetchAll() {
var options = new ListOptions();
var notDeleted = QueryFactory.isNull("metadata.deletionTimestamp");
var approved = QueryFactory.equal("spec.approved", "true");
options.setFieldSelector(FieldSelector.of(notDeleted).andQuery(approved));
var pageRequest = createPageRequest();
// make sure the moments are approved and not deleted.
return client.listBy(Moment.class, options, pageRequest)
.map(ListResult::getItems)
.flatMapMany(Flux::fromIterable)
.flatMap(converter::convert);
}
@Override
public String getType() {
return MOMENT_DOCUMENT_TYPE;
}
private PageRequest createPageRequest() {
return PageRequestImpl.of(1, SEARCH_DEFAULT_PAGE_SIZE,
Sort.by("metadata.creationTimestamp", "metadata.name"));
}
}
至此, Moment 插件已经适配了 Halo 搜索文档。之后使用搜索功能时,就能查找到 Type
为 moment.moment.halo.run
的内容了。
结语
总体而言,Halo 在核心部分处理了搜索引擎的大部分工作,插件作者只需要关心如何将数据内容转换为 Halo Search Document
,以及何时新增、更新、删除文档即可。实现逻辑相对简单。
希望这篇文章能够帮助到想要开发新插件的用户!