feat: opensearch init

This commit is contained in:
tim
2025-09-26 16:37:13 +08:00
parent 69869348f6
commit 0bc65077df
23 changed files with 874 additions and 18 deletions

View File

@@ -0,0 +1,14 @@
package com.openisle.search;
public class NoopSearchIndexer implements SearchIndexer {
@Override
public void indexDocument(String index, SearchDocument document) {
// no-op
}
@Override
public void deleteDocument(String index, Long id) {
// no-op
}
}

View File

@@ -0,0 +1,78 @@
package com.openisle.search;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
import org.apache.hc.core5.http.HttpHost;
import org.opensearch.client.RestClient;
import org.opensearch.client.RestClientBuilder;
import org.opensearch.client.json.jackson.JacksonJsonpMapper;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.transport.rest_client.RestClientTransport;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
@Configuration
@EnableConfigurationProperties(OpenSearchProperties.class)
public class OpenSearchConfig {
@Bean(destroyMethod = "close")
@ConditionalOnProperty(prefix = "app.search", name = "enabled", havingValue = "true")
public RestClient openSearchRestClient(OpenSearchProperties properties) {
RestClientBuilder builder = RestClient.builder(
new HttpHost(properties.getScheme(), properties.getHost(), properties.getPort())
);
if (StringUtils.hasText(properties.getUsername())) {
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(
new AuthScope(properties.getHost(), properties.getPort()),
new UsernamePasswordCredentials(
properties.getUsername(),
properties.getPassword() != null ? properties.getPassword().toCharArray() : new char[0]
)
);
builder.setHttpClientConfigCallback(httpClientBuilder ->
httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
);
}
return builder.build();
}
@Bean(destroyMethod = "close")
@ConditionalOnBean(RestClient.class)
public RestClientTransport openSearchTransport(RestClient restClient) {
return new RestClientTransport(restClient, new JacksonJsonpMapper());
}
@Bean
@ConditionalOnBean(RestClientTransport.class)
public OpenSearchClient openSearchClient(RestClientTransport transport) {
return new OpenSearchClient(transport);
}
@Bean
@ConditionalOnBean(OpenSearchClient.class)
public SearchIndexInitializer searchIndexInitializer(
OpenSearchClient client,
OpenSearchProperties properties
) {
return new SearchIndexInitializer(client, properties);
}
@Bean
@ConditionalOnBean(OpenSearchClient.class)
public SearchIndexer openSearchIndexer(OpenSearchClient client, OpenSearchProperties properties) {
return new OpenSearchIndexer(client);
}
@Bean
@ConditionalOnMissingBean(SearchIndexer.class)
public SearchIndexer noopSearchIndexer() {
return new NoopSearchIndexer();
}
}

View File

@@ -0,0 +1,51 @@
package com.openisle.search;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch.core.DeleteRequest;
import org.opensearch.client.opensearch.core.IndexRequest;
import org.opensearch.client.opensearch.core.IndexResponse;
@Slf4j
@RequiredArgsConstructor
public class OpenSearchIndexer implements SearchIndexer {
private final OpenSearchClient client;
@Override
public void indexDocument(String index, SearchDocument document) {
if (document == null || document.entityId() == null) {
return;
}
try {
IndexRequest<SearchDocument> request = IndexRequest.of(builder ->
builder.index(index).id(document.entityId().toString()).document(document)
);
IndexResponse response = client.index(request);
if (log.isDebugEnabled()) {
log.debug(
"Indexed document {} into {} with result {}",
document.entityId(),
index,
response.result()
);
}
} catch (IOException e) {
log.warn("Failed to index document {} into {}", document.entityId(), index, e);
}
}
@Override
public void deleteDocument(String index, Long id) {
if (id == null) {
return;
}
try {
client.delete(DeleteRequest.of(builder -> builder.index(index).id(id.toString())));
} catch (IOException e) {
log.warn("Failed to delete document {} from {}", id, index, e);
}
}
}

View File

@@ -0,0 +1,61 @@
package com.openisle.search;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Getter
@Setter
@ConfigurationProperties(prefix = "app.search")
public class OpenSearchProperties {
private boolean enabled = false;
private String host = "localhost";
private int port = 9200;
private String scheme = "http";
private String username;
private String password;
private String indexPrefix = "openisle";
private boolean initialize = true;
private int highlightFragmentSize = 200;
private Indices indices = new Indices();
public String postsIndex() {
return indexName(indices.posts);
}
public String commentsIndex() {
return indexName(indices.comments);
}
public String usersIndex() {
return indexName(indices.users);
}
public String categoriesIndex() {
return indexName(indices.categories);
}
public String tagsIndex() {
return indexName(indices.tags);
}
private String indexName(String suffix) {
if (indexPrefix == null || indexPrefix.isBlank()) {
return suffix;
}
return indexPrefix + "-" + suffix;
}
@Getter
@Setter
public static class Indices {
private String posts = "posts";
private String comments = "comments";
private String users = "users";
private String categories = "categories";
private String tags = "tags";
}
}

View File

@@ -0,0 +1,16 @@
package com.openisle.search;
import java.time.Instant;
import java.util.List;
public record SearchDocument(
String type,
Long entityId,
String title,
String content,
String author,
String category,
List<String> tags,
Long postId,
Instant createdAt
) {}

View File

@@ -0,0 +1,125 @@
package com.openisle.search;
import com.openisle.model.Category;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.Tag;
import com.openisle.model.User;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public final class SearchDocumentFactory {
private SearchDocumentFactory() {}
public static SearchDocument fromPost(Post post) {
if (post == null || post.getId() == null) {
return null;
}
List<String> tags = post.getTags() == null
? Collections.emptyList()
: post
.getTags()
.stream()
.map(Tag::getName)
.filter(Objects::nonNull)
.collect(Collectors.toList());
return new SearchDocument(
"post",
post.getId(),
post.getTitle(),
post.getContent(),
post.getAuthor() != null ? post.getAuthor().getUsername() : null,
post.getCategory() != null ? post.getCategory().getName() : null,
tags,
post.getId(),
toInstant(post.getCreatedAt())
);
}
public static SearchDocument fromComment(Comment comment) {
if (comment == null || comment.getId() == null) {
return null;
}
Post post = comment.getPost();
List<String> tags = post == null || post.getTags() == null
? Collections.emptyList()
: post
.getTags()
.stream()
.map(Tag::getName)
.filter(Objects::nonNull)
.collect(Collectors.toList());
return new SearchDocument(
"comment",
comment.getId(),
post != null ? post.getTitle() : null,
comment.getContent(),
comment.getAuthor() != null ? comment.getAuthor().getUsername() : null,
post != null && post.getCategory() != null ? post.getCategory().getName() : null,
tags,
post != null ? post.getId() : null,
toInstant(comment.getCreatedAt())
);
}
public static SearchDocument fromUser(User user) {
if (user == null || user.getId() == null) {
return null;
}
return new SearchDocument(
"user",
user.getId(),
user.getUsername(),
user.getIntroduction(),
null,
null,
Collections.emptyList(),
null,
toInstant(user.getCreatedAt())
);
}
public static SearchDocument fromCategory(Category category) {
if (category == null || category.getId() == null) {
return null;
}
return new SearchDocument(
"category",
category.getId(),
category.getName(),
category.getDescription(),
null,
null,
Collections.emptyList(),
null,
null
);
}
public static SearchDocument fromTag(Tag tag) {
if (tag == null || tag.getId() == null) {
return null;
}
return new SearchDocument(
"tag",
tag.getId(),
tag.getName(),
tag.getDescription(),
null,
null,
Collections.emptyList(),
null,
toInstant(tag.getCreatedAt())
);
}
private static Instant toInstant(LocalDateTime time) {
return time == null ? null : time.atZone(ZoneId.systemDefault()).toInstant();
}
}

View File

@@ -0,0 +1,33 @@
package com.openisle.search;
import com.openisle.search.event.DeleteDocumentEvent;
import com.openisle.search.event.IndexDocumentEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Slf4j
@Component
@RequiredArgsConstructor
public class SearchIndexEventListener {
private final SearchIndexer searchIndexer;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void handleIndex(IndexDocumentEvent event) {
if (event == null || event.document() == null) {
return;
}
searchIndexer.indexDocument(event.index(), event.document());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void handleDelete(DeleteDocumentEvent event) {
if (event == null) {
return;
}
searchIndexer.deleteDocument(event.index(), event.id());
}
}

View File

@@ -0,0 +1,99 @@
package com.openisle.search;
import com.openisle.model.Category;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.PostStatus;
import com.openisle.model.Tag;
import com.openisle.model.User;
import com.openisle.search.event.DeleteDocumentEvent;
import com.openisle.search.event.IndexDocumentEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class SearchIndexEventPublisher {
private final ApplicationEventPublisher publisher;
private final OpenSearchProperties properties;
public void publishPostSaved(Post post) {
if (!properties.isEnabled() || post == null || post.getStatus() != PostStatus.PUBLISHED) {
return;
}
SearchDocument document = SearchDocumentFactory.fromPost(post);
if (document != null) {
publisher.publishEvent(new IndexDocumentEvent(properties.postsIndex(), document));
}
}
public void publishPostDeleted(Long postId) {
if (!properties.isEnabled() || postId == null) {
return;
}
publisher.publishEvent(new DeleteDocumentEvent(properties.postsIndex(), postId));
}
public void publishCommentSaved(Comment comment) {
if (!properties.isEnabled() || comment == null) {
return;
}
SearchDocument document = SearchDocumentFactory.fromComment(comment);
if (document != null) {
publisher.publishEvent(new IndexDocumentEvent(properties.commentsIndex(), document));
}
}
public void publishCommentDeleted(Long commentId) {
if (!properties.isEnabled() || commentId == null) {
return;
}
publisher.publishEvent(new DeleteDocumentEvent(properties.commentsIndex(), commentId));
}
public void publishUserSaved(User user) {
if (!properties.isEnabled() || user == null) {
return;
}
SearchDocument document = SearchDocumentFactory.fromUser(user);
if (document != null) {
publisher.publishEvent(new IndexDocumentEvent(properties.usersIndex(), document));
}
}
public void publishCategorySaved(Category category) {
if (!properties.isEnabled() || category == null) {
return;
}
SearchDocument document = SearchDocumentFactory.fromCategory(category);
if (document != null) {
publisher.publishEvent(new IndexDocumentEvent(properties.categoriesIndex(), document));
}
}
public void publishCategoryDeleted(Long categoryId) {
if (!properties.isEnabled() || categoryId == null) {
return;
}
publisher.publishEvent(new DeleteDocumentEvent(properties.categoriesIndex(), categoryId));
}
public void publishTagSaved(Tag tag) {
if (!properties.isEnabled() || tag == null || !tag.isApproved()) {
return;
}
SearchDocument document = SearchDocumentFactory.fromTag(tag);
if (document != null) {
publisher.publishEvent(new IndexDocumentEvent(properties.tagsIndex(), document));
}
}
public void publishTagDeleted(Long tagId) {
if (!properties.isEnabled() || tagId == null) {
return;
}
publisher.publishEvent(new DeleteDocumentEvent(properties.tagsIndex(), tagId));
}
}

View File

@@ -0,0 +1,102 @@
package com.openisle.search;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch._types.mapping.Property;
import org.opensearch.client.opensearch._types.mapping.TypeMapping;
@Slf4j
@RequiredArgsConstructor
public class SearchIndexInitializer {
private final OpenSearchClient client;
private final OpenSearchProperties properties;
@PostConstruct
public void initialize() {
if (!properties.isEnabled() || !properties.isInitialize()) {
return;
}
ensureIndex(properties.postsIndex(), this::postMapping);
ensureIndex(properties.commentsIndex(), this::commentMapping);
ensureIndex(properties.usersIndex(), this::userMapping);
ensureIndex(properties.categoriesIndex(), this::categoryMapping);
ensureIndex(properties.tagsIndex(), this::tagMapping);
}
private void ensureIndex(String index, java.util.function.Supplier<TypeMapping> mappingSupplier) {
try {
boolean exists = client
.indices()
.exists(builder -> builder.index(index))
.value();
if (exists) {
return;
}
client.indices().create(builder -> builder.index(index).mappings(mappingSupplier.get()));
log.info("Created OpenSearch index {}", index);
} catch (IOException e) {
log.warn("Failed to initialize OpenSearch index {}", index, e);
}
}
private TypeMapping postMapping() {
return TypeMapping.of(builder ->
builder
.properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", Property.of(p -> p.text(t -> t)))
.properties("content", Property.of(p -> p.text(t -> t)))
.properties("author", Property.of(p -> p.keyword(k -> k)))
.properties("category", Property.of(p -> p.keyword(k -> k)))
.properties("tags", Property.of(p -> p.keyword(k -> k)))
.properties("postId", Property.of(p -> p.long_(l -> l)))
.properties("createdAt", Property.of(p -> p.date(d -> d)))
);
}
private TypeMapping commentMapping() {
return TypeMapping.of(builder ->
builder
.properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", Property.of(p -> p.text(t -> t)))
.properties("content", Property.of(p -> p.text(t -> t)))
.properties("author", Property.of(p -> p.keyword(k -> k)))
.properties("category", Property.of(p -> p.keyword(k -> k)))
.properties("tags", Property.of(p -> p.keyword(k -> k)))
.properties("postId", Property.of(p -> p.long_(l -> l)))
.properties("createdAt", Property.of(p -> p.date(d -> d)))
);
}
private TypeMapping userMapping() {
return TypeMapping.of(builder ->
builder
.properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", Property.of(p -> p.text(t -> t)))
.properties("content", Property.of(p -> p.text(t -> t)))
.properties("createdAt", Property.of(p -> p.date(d -> d)))
);
}
private TypeMapping categoryMapping() {
return TypeMapping.of(builder ->
builder
.properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", Property.of(p -> p.text(t -> t)))
.properties("content", Property.of(p -> p.text(t -> t)))
);
}
private TypeMapping tagMapping() {
return TypeMapping.of(builder ->
builder
.properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", Property.of(p -> p.text(t -> t)))
.properties("content", Property.of(p -> p.text(t -> t)))
.properties("createdAt", Property.of(p -> p.date(d -> d)))
);
}
}

View File

@@ -0,0 +1,6 @@
package com.openisle.search;
public interface SearchIndexer {
void indexDocument(String index, SearchDocument document);
void deleteDocument(String index, Long id);
}

View File

@@ -0,0 +1,3 @@
package com.openisle.search.event;
public record DeleteDocumentEvent(String index, Long id) {}

View File

@@ -0,0 +1,5 @@
package com.openisle.search.event;
import com.openisle.search.SearchDocument;
public record IndexDocumentEvent(String index, SearchDocument document) {}