mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-24 07:00:49 +08:00
feat: opensearch init
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.openisle.search;
|
||||
|
||||
public interface SearchIndexer {
|
||||
void indexDocument(String index, SearchDocument document);
|
||||
void deleteDocument(String index, Long id);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.openisle.search.event;
|
||||
|
||||
public record DeleteDocumentEvent(String index, Long id) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.openisle.search.event;
|
||||
|
||||
import com.openisle.search.SearchDocument;
|
||||
|
||||
public record IndexDocumentEvent(String index, SearchDocument document) {}
|
||||
Reference in New Issue
Block a user