Skip to content

Commit

Permalink
Implement full-text search of posts with Lucene default (#2675)
Browse files Browse the repository at this point in the history
#### What type of PR is this?

/kind feature
/area core
/milestone 2.0

#### What this PR does / why we need it:

This PR mainly implement full-text search of posts and provide extension point for other search engine.

Meanwhile, I implement ExtensionGetter to get implemention(s) of extension point from system ConfigMap.

But there still are something to do here:

- [x] Udpate documents when posts are published or posts are becoming unpublic.
- [x] Delete documents when posts are unpublished or deleted.

Because I'm waiting for #2659 got merged.

I create two endpoints:

1. For full-text search of post

    ```bash
    curl -X 'GET' \
      'http://localhost:8090/apis/api.halo.run/v1alpha1/indices/post?keyword=halo&limit=10000&highlightPreTag=%3CB%3E&highlightPostTag=%3C%2FB%3E' \
      -H 'accept: */*'
    ```

1. For refreshing indices

    ```bash
    curl -X 'POST' \
      'http://localhost:8090/apis/api.console.halo.run/v1alpha1/indices/post' \
      -H 'accept: */*' \
      -d ''
    ```

#### Which issue(s) this PR fixes:

Fixes ##2637

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
提供文章全文搜索功能并支持搜索引擎扩展
```
  • Loading branch information
JohnNiang authored Nov 11, 2022
1 parent 8b9ea1d commit dac4eec
Show file tree
Hide file tree
Showing 37 changed files with 1,468 additions and 31 deletions.
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ dependencies {
implementation 'org.openapi4j:openapi-schema-validator:1.0.7'
implementation "net.bytebuddy:byte-buddy"

// Apache Lucene
implementation 'org.apache.lucene:lucene-core:9.4.1'
implementation 'org.apache.lucene:lucene-queryparser:9.4.1'
implementation 'org.apache.lucene:lucene-highlighter:9.4.1'
implementation 'cn.shenyanchao.ik-analyzer:ik-analyzer:9.0.0'

implementation "org.apache.commons:commons-lang3:$commonsLang3"
implementation "io.seruco.encoding:base62:$base62"
implementation "org.pf4j:pf4j:$pf4j"
Expand Down
356 changes: 356 additions & 0 deletions docs/full-text-search/README.md

Large diffs are not rendered by default.

Binary file added docs/full-text-search/algolia.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/full-text-search/meilisearch.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/main/java/run/halo/app/config/HaloConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration(proxyBeanMethods = false)
@EnableAsync
public class HaloConfiguration {

@Bean
Expand Down
11 changes: 9 additions & 2 deletions src/main/java/run/halo/app/core/extension/Post.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package run.halo.app.core.extension;

import static java.lang.Boolean.parseBoolean;

import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
Expand All @@ -13,6 +15,7 @@
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.GVK;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.infra.Condition;

/**
Expand Down Expand Up @@ -62,8 +65,12 @@ public boolean isDeleted() {

@JsonIgnore
public boolean isPublished() {
Map<String, String> labels = getMetadata().getLabels();
return labels != null && labels.getOrDefault(PUBLISHED_LABEL, "false").equals("true");
return isPublished(this.getMetadata());
}

public static boolean isPublished(MetadataOperator metadata) {
var labels = metadata.getLabels();
return labels != null && parseBoolean(labels.getOrDefault(PUBLISHED_LABEL, "false"));
}

@Data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,27 @@
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;

import io.swagger.v3.oas.annotations.enums.ParameterIn;
import java.time.Duration;
import lombok.AllArgsConstructor;
import org.springdoc.core.fn.builders.schema.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.content.ListedPost;
import run.halo.app.content.PostQuery;
import run.halo.app.content.PostRequest;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.Post;
import run.halo.app.event.post.PostPublishedEvent;
import run.halo.app.event.post.PostRecycledEvent;
import run.halo.app.event.post.PostUnpublishedEvent;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.QueryParamBuildUtil;
Expand All @@ -37,6 +44,8 @@ public class PostEndpoint implements CustomEndpoint {
private final PostService postService;
private final ReactiveExtensionClient client;

private final ApplicationEventPublisher eventPublisher;

@Override
public RouterFunction<ServerResponse> endpoint() {
final var tag = "api.console.halo.run/v1alpha1/Post";
Expand Down Expand Up @@ -91,9 +100,29 @@ public RouterFunction<ServerResponse> endpoint() {
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class))
.parameter(parameterBuilder().name("headSnapshot")
.description("Head snapshot name of content.")
.in(ParameterIn.QUERY)
.required(false))
.response(responseBuilder()
.implementation(Post.class))
)
.PUT("posts/{name}/unpublish", this::unpublishPost,
builder -> builder.operationId("UnpublishPost")
.description("Publish a post.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true))
.response(responseBuilder()
.implementation(Post.class)))
.PUT("posts/{name}/recycle", this::recyclePost,
builder -> builder.operationId("RecyclePost")
.description("Recycle a post.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)))
.build();
}

Expand All @@ -110,15 +139,54 @@ Mono<ServerResponse> updatePost(ServerRequest request) {
}

Mono<ServerResponse> publishPost(ServerRequest request) {
String name = request.pathVariable("name");
return client.fetch(Post.class, name)
.flatMap(post -> {
Post.PostSpec spec = post.getSpec();
var name = request.pathVariable("name");
return client.get(Post.class, name)
.doOnNext(post -> {
var spec = post.getSpec();
request.queryParam("headSnapshot").ifPresent(spec::setHeadSnapshot);
spec.setPublish(true);
// TODO Provide release snapshot query param to control
spec.setReleaseSnapshot(spec.getHeadSnapshot());
return client.update(post);
})
.flatMap(client::update)
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(t -> t instanceof OptimisticLockingFailureException))
.flatMap(post -> postService.publishPost(post.getMetadata().getName()))
// TODO Fire published event in reconciler in the future
.doOnNext(post -> eventPublisher.publishEvent(
new PostPublishedEvent(this, post.getMetadata().getName())))
.flatMap(post -> ServerResponse.ok().bodyValue(post));
}

private Mono<ServerResponse> unpublishPost(ServerRequest request) {
var name = request.pathVariable("name");
return client.get(Post.class, name)
.doOnNext(post -> {
var spec = post.getSpec();
spec.setPublish(false);
})
.flatMap(client::update)
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(t -> t instanceof OptimisticLockingFailureException))
// TODO Fire unpublished event in reconciler in the future
.doOnNext(post -> eventPublisher.publishEvent(
new PostUnpublishedEvent(this, post.getMetadata().getName())))
.flatMap(post -> ServerResponse.ok().bodyValue(post));
}

private Mono<ServerResponse> recyclePost(ServerRequest request) {
var name = request.pathVariable("name");
return client.get(Post.class, name)
.doOnNext(post -> {
var spec = post.getSpec();
spec.setDeleted(true);
})
.flatMap(client::update)
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(t -> t instanceof OptimisticLockingFailureException))
// TODO Fire recycled event in reconciler in the future
.doOnNext(post -> eventPublisher.publishEvent(
new PostRecycledEvent(this, post.getMetadata().getName())))
.flatMap(post -> ServerResponse.ok().bodyValue(post));
}

Expand Down
17 changes: 17 additions & 0 deletions src/main/java/run/halo/app/event/post/PostDeletedEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package run.halo.app.event.post;

import org.springframework.context.ApplicationEvent;

public class PostDeletedEvent extends ApplicationEvent {

private final String postName;

public PostDeletedEvent(Object source, String postName) {
super(source);
this.postName = postName;
}

public String getPostName() {
return postName;
}
}
18 changes: 18 additions & 0 deletions src/main/java/run/halo/app/event/post/PostPublishedEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package run.halo.app.event.post;

import org.springframework.context.ApplicationEvent;

public class PostPublishedEvent extends ApplicationEvent {

private final String postName;

public PostPublishedEvent(Object source, String postName) {
super(source);
this.postName = postName;
}

public String getPostName() {
return postName;
}

}
17 changes: 17 additions & 0 deletions src/main/java/run/halo/app/event/post/PostRecycledEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package run.halo.app.event.post;

import org.springframework.context.ApplicationEvent;

public class PostRecycledEvent extends ApplicationEvent {

private final String postName;

public PostRecycledEvent(Object source, String postName) {
super(source);
this.postName = postName;
}

public String getPostName() {
return postName;
}
}
18 changes: 18 additions & 0 deletions src/main/java/run/halo/app/event/post/PostUnpublishedEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package run.halo.app.event.post;

import org.springframework.context.ApplicationEvent;

public class PostUnpublishedEvent extends ApplicationEvent {

private final String postName;

public PostUnpublishedEvent(Object source, String postName) {
super(source);
this.postName = postName;
}

public String getPostName() {
return postName;
}

}
13 changes: 4 additions & 9 deletions src/main/java/run/halo/app/extension/ListResult.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package run.halo.app.extension;

import static run.halo.app.infra.utils.GenericClassUtils.generateConcreteClass;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
Expand Down Expand Up @@ -120,15 +122,8 @@ public static Class<?> generateGenericClass(Scheme scheme) {
* @return generic ListResult class.
*/
public static <T> Class<?> generateGenericClass(Class<T> type) {
var generic =
TypeDescription.Generic.Builder.parameterizedType(ListResult.class, type)
.build();
return new ByteBuddy()
.subclass(generic)
.name(type.getSimpleName() + "List")
.make()
.load(ListResult.class.getClassLoader())
.getLoaded();
return generateConcreteClass(ListResult.class, type,
() -> type.getSimpleName() + "List");
}

public static <T> ListResult<T> emptyResult() {
Expand Down
22 changes: 14 additions & 8 deletions src/main/java/run/halo/app/extension/Unstructured.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.time.Instant;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
Expand Down Expand Up @@ -45,6 +46,10 @@ public Unstructured(Map data) {
this.data = data;
}

public Map getData() {
return Collections.unmodifiableMap(data);
}

@Override
public String getApiVersion() {
return (String) data.get("apiVersion");
Expand Down Expand Up @@ -161,7 +166,7 @@ public void setMetadata(MetadataOperator metadata) {
data.put("metadata", metadataMap);
}

static Optional<Object> getNestedValue(Map map, String... fields) {
public static Optional<Object> getNestedValue(Map map, String... fields) {
if (fields == null || fields.length == 0) {
return Optional.of(map);
}
Expand All @@ -177,11 +182,11 @@ static Optional<Object> getNestedValue(Map map, String... fields) {
}

@SuppressWarnings("unchecked")
static Optional<List<String>> getNestedStringList(Map map, String... fields) {
public static Optional<List<String>> getNestedStringList(Map map, String... fields) {
return getNestedValue(map, fields).map(value -> (List<String>) value);
}

static Optional<Set<String>> getNestedStringSet(Map map, String... fields) {
public static Optional<Set<String>> getNestedStringSet(Map map, String... fields) {
return getNestedValue(map, fields).map(value -> {
if (value instanceof Collection collection) {
return new LinkedHashSet<>(collection);
Expand All @@ -192,7 +197,7 @@ static Optional<Set<String>> getNestedStringSet(Map map, String... fields) {
}

@SuppressWarnings("unchecked")
static void setNestedValue(Map map, Object value, String... fields) {
public static void setNestedValue(Map map, Object value, String... fields) {
if (fields == null || fields.length == 0) {
// do nothing when no fields provided
return;
Expand All @@ -205,12 +210,13 @@ static void setNestedValue(Map map, Object value, String... fields) {
});
}

static Optional<Map> getNestedMap(Map map, String... fields) {
public static Optional<Map> getNestedMap(Map map, String... fields) {
return getNestedValue(map, fields).map(value -> (Map) value);
}

@SuppressWarnings("unchecked")
static Optional<Map<String, String>> getNestedStringStringMap(Map map, String... fields) {
public static Optional<Map<String, String>> getNestedStringStringMap(Map map,
String... fields) {
return getNestedValue(map, fields)
.map(labelsObj -> {
var labels = (Map) labelsObj;
Expand All @@ -220,7 +226,7 @@ static Optional<Map<String, String>> getNestedStringStringMap(Map map, String...
});
}

static Optional<Instant> getNestedInstant(Map map, String... fields) {
public static Optional<Instant> getNestedInstant(Map map, String... fields) {
return getNestedValue(map, fields)
.map(instantValue -> {
if (instantValue instanceof Instant instant) {
Expand All @@ -231,7 +237,7 @@ static Optional<Instant> getNestedInstant(Map map, String... fields) {

}

static Optional<Long> getNestedLong(Map map, String... fields) {
public static Optional<Long> getNestedLong(Map map, String... fields) {
return getNestedValue(map, fields)
.map(longObj -> {
if (longObj instanceof Long l) {
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/run/halo/app/infra/SchemeInitializedEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package run.halo.app.infra;

import org.springframework.context.ApplicationEvent;

public class SchemeInitializedEvent extends ApplicationEvent {

public SchemeInitializedEvent(Object source) {
super(source);
}

}
Loading

0 comments on commit dac4eec

Please sign in to comment.