https://wedul.site/541에서 search after 기능을 사용해서 검색을 하는 이유를 알아봤었다.
그럼 spring boot에서 RestHighLevelClient를 이용해서 search after를 구현을 해보자.
1. Mapping
우선 index가 필요한데 간단하게 상품명과 지역 가격정보들을 가지고 있는 wedul_product 인덱스를 만들어 사용한다.
{
"settings": {
"index": {
"analysis": {
"tokenizer": {
"nori_user_dict": {
"type": "nori_tokenizer",
"decompound_mode": "mixed",
"user_dictionary": "analysis/userdict_ko.txt"
}
},
"analyzer": {
"wedul_analyzer": {
"tokenizer": "nori_user_dict",
"filter": [
"synonym"
]
}
},
"filter": {
"synonym": {
"type": "synonym",
"synonyms_path": "analysis/synonyms.txt"
}
}
}
}
},
"mappings": {
"dynamic": "false",
"properties": {
"productId": {
"type": "keyword"
},
"place": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"message": {
"type": "text"
},
"query": {
"type": "percolator"
},
"name": {
"type": "text",
"analyzer": "wedul_analyzer",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"price": {
"type": "integer"
},
"updateAt": {
"type": "date",
"format": "epoch_second"
},
"createAt": {
"type": "date",
"format": "epoch_second"
}
}
}
}
값은 적당하게 3개정도 삽입하였다.
2. 라이브러리
사용에 필요한 라이브러리들을 gradle을 사용해서 추가한다.
plugins {
id 'org.springframework.boot' version '2.2.0.RELEASE'
id 'io.spring.dependency-management' version '1.0.8.RELEASE'
id 'java'
}
ext {
set('elasticsearch.version', '7.4.2')
}
group = 'com.wedul'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
repositories {
mavenCentral()
maven { url "https://plugins.gradle.org/m2/" }
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.9'
compile group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.10.0'
annotationProcessor 'org.projectlombok:lombok'
testCompile group: 'org.mockito', name: 'mockito-all', version:'1.9.5'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// gson
compile group: 'com.google.code.gson', name: 'gson', version: '2.8.6'
// elasticsearch
compile 'org.elasticsearch.client:elasticsearch-rest-high-level-client:7.4.2'
compile group: 'org.elasticsearch', name: 'elasticsearch', version: '7.4.2'
}
3.RestHighLevelClient configuration
restHighLevelClient 사용을 위한 Configuration 파일을 만들어주는데 id와 pw는 AppConfig라는 별도 properties를 관리하는 bean에서 받아서 사용하는데 base64로 인코딩되어있어서 이를 decoding후 사용한다. (부족한 코드는 글 맨 아래있는 github 링크 참조)
package com.wedul.study.common.config;
import com.wedul.study.common.util.EncodingUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpHost;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.elasticsearch.client.RestClient;
import org.elasticsearch.client.RestClientBuilder;
import org.elasticsearch.client.RestHighLevelClient;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
/**
* study
*
* @author wedul
* @since 2019-11-07
**/
@Configuration
@Slf4j
public class ElasticsearchClientConfig implements FactoryBean<RestHighLevelClient>, InitializingBean, DisposableBean {
@Autowired
AppConfig appConfig;
private RestHighLevelClient restHighLevelClient;
@Override
public RestHighLevelClient getObject() {
return restHighLevelClient;
}
@Override
public Class<?> getObjectType() {
return RestHighLevelClient.class;
}
@Override
public void destroy() {
try {
if (null != restHighLevelClient) {
restHighLevelClient.close();
}
} catch (Exception e) {
log.error("Error closing ElasticSearch client: ", e);
}
}
@Override
public boolean isSingleton() {
return false;
}
@Override
public void afterPropertiesSet() {
restHighLevelClient = buildClient();
}
private RestHighLevelClient buildClient() {
try {
String id = EncodingUtil.decodingBase64(appConfig.getElasticsearchConfig().getId());
String pw = EncodingUtil.decodingBase64(appConfig.getElasticsearchConfig().getPw());
// 계정 설정
final CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY,
new UsernamePasswordCredentials(id, pw));
// client 설정
RestClientBuilder builder = RestClient.builder(
new HttpHost(appConfig.getElasticsearchConfig().getIp(),
appConfig.getElasticsearchConfig().getPort(), "http"))
.setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
restHighLevelClient = new RestHighLevelClient(builder);
} catch (Exception e) {
log.error(e.getMessage());
}
return restHighLevelClient;
}
}
4. Handler 추가
자주 사용되는 Elasticsearch 문법을 처리하기 위해서 만들어 놓은 ElasticsearchHandler에 search after에 사용 될 메소드를 추가한다. search after는 sort 필드가 없으면 사용이 불가능 하기 때문에 sort 필드가 없는 경우 에러를 전달한다.
public static SearchSourceBuilder searchAfter(Map<String, SortOrder> sortFields, QueryBuilder query, Object[] searchAfter, int size) {
return searchAfterBuilder(sortFields, query, searchAfter, size);
}
public static SearchSourceBuilder searchAfter(Map<String, SortOrder> sortFields, QueryBuilder query, Object[] searchAfter) {
return searchAfterBuilder(sortFields, query, searchAfter, 20);
}
private static SearchSourceBuilder searchAfterBuilder(Map<String, SortOrder> sortFields, QueryBuilder query, Object[] searchAfter, int size) {
SearchSourceBuilder builder = new SearchSourceBuilder();
if (CollectionUtils.isEmpty(sortFields)) {
throw new InternalServerException("잘못된 필드 요청입니다.");
}
sortFields.forEach((field, sort) -> {
builder.sort(field, sort);
});
builder.size(size);
builder.query(query);
if (ArrayUtils.isNotEmpty(searchAfter)) {
builder.searchAfter(searchAfter);
}
return builder;
}
5. 기능 구현
위의 기능들을 이용해서 실제로 구현해보자. productService와 productRepository 클래스를 통해서 구현하였다. 자세한 설명없이 간단하기 때문에 소스를 보면 알 수 있다.
우선 최종 결과물로 사용될 클래스는 ElasticResult인데 다음과 같이 현재 요청이 마지막인지 표시하는 isLast와 다음 요청을 위해 보내줘야 하는 cursor값과 결과값 전체 total과 결과 리스트 list 필드가 존재한다.
@Builder
@Data
public class ElasticResult<T extends ElasticsearchDto> {
private boolean isLast;
private long total;
private List<T> list;
private Object[] cursor;
}
그 다음 service로직을 통해 결과를 얻어서 위 ElasticResult에 결과를 담아보자. products 메서드는 요청을 받아서 elasticsearch에 실제 조작요청을 하는 productRepository에 동작을 요청하고 값을 받아서 처리하는 메서드이다. 그리고 extractProductList는 결과값에서 ProductDto 값을 뽑아내는 메서드이다.
public ElasticResult<ProductDto> products(String index, Object[] searchAfter, int size) throws IOException {
SearchResponse searchResponse = productRepository.products(index, searchAfter, size);
SearchHits searchHits = searchResponse.getHits();
int hitCnt = searchHits.getHits().length;
boolean isLast = 0 == hitCnt || size > hitCnt;
return ElasticResult.<ProductDto>builder()
.cursor(isLast ? null : searchHits.getHits()[hitCnt - 1].getSortValues())
.isLast(isLast)
.list(extractProductList(searchHits))
.total(searchHits.getTotalHits().value)
.build();
}
private List<ProductDto> extractProductList(SearchHits searchHits) {
List<ProductDto> productList = new ArrayList<>();
searchHits.forEach(hit -> {
Map<String, Object> result = hit.getSourceAsMap();
productList.add(ProductDto.builder()
.name(String.valueOf(result.get("name")))
.productId(String.valueOf(result.get("productId")))
.place(String.valueOf(result.get("place")))
.price(Integer.valueOf(result.get("price").toString()))
.updateAt(Long.valueOf(result.get("updateAt").toString()))
.createAt(Long.valueOf(result.get("createAt").toString())).build());
});
return productList;
}
그리고 마지막으로 es에 직접적으로 콜을 하는 productRepository 이다. 여기서 정렬 키워드는 name과 place를 사용한다.
public SearchResponse products(String index, Object[] searchAfter, int size) throws IOException {
SearchRequest searchRequest = new SearchRequest(index);
Map<String, SortOrder> sorts = new HashMap<String, SortOrder>() {
{
put("name.keyword", SortOrder.DESC);
put("place.keyword", SortOrder.DESC);
}
};
SearchSourceBuilder searchSourceBuilder = ElasticsearchHandler.searchAfter(sorts, QueryBuilders.matchAllQuery(), searchAfter, size);
searchRequest.source(searchSourceBuilder);
return restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
}
6. 테스트
그럼 위에 내용이 잘 구현되었는지 테스트를 해보자. 총 3개의 데이터가 있는데 이 데이터를 1개씩 search after를 통해서 값을 받아서 저장하고 한번에 출력하도록 해보자.
@Test
@DisplayName("search after")
public void searchAfter() throws IOException {
ElasticResult<ProductDto> result = productService.products(PRODUCT_INDEX, new Object[]{}, 1);
List<ProductDto> productDtos = new ArrayList<>();
while(result != null && !result.isLast()) {
productDtos.addAll(result.getList());
result = productService.products(PRODUCT_INDEX, result.getCursor(), 1);
}
productDtos.addAll(result.getList());
productDtos.forEach(productDto -> {
System.out.println("이름 : " + productDto.getName());
System.out.println("장소 : " + productDto.getPlace());
});
}
결과는 정상적으로 3가지 모두 잘 출력되는 걸 알 수있다.
우선 기능 구현을 해보기 위해서 진행하였는데 더 다듬어야 할 것같다.
자세한 소스는 github참조
'web > Spring' 카테고리의 다른 글
Spring boot2 resilience4j를 이용한 circuit breaker 사용 (1) | 2020.02.23 |
---|---|
spring cloud resilience4j 사용시 CircuitBreakerConfiguration 에러 (0) | 2020.02.23 |
JPA 다양한 Join 방법 정리 (N+1, queryDSL, fetch join) (3) | 2019.11.04 |
데이터 베이스 버전 컨트롤 Flyway (0) | 2019.09.28 |
Redis에서 Pub/Sub 방식 소개 및 Spring Boot에서 구현해보기 (2) | 2019.08.21 |