Я реализовал один сервис с Quarkus в качестве основной платформы и Elasticsearch в качестве хранилища данных. В ходе реализации мне пришла в голову идея написать статью о том, как реактивно связывать Quarkus с клиентом Elasticsearch Java High Level REST .
Я начал делать заметки о статье и выделил общий код, связанный с Elasticsearch, в общей библиотеке (модуль otaibe-commons-quarkus -asticsearch), хранящейся в Github . Затем мне потребовалось несколько часов, чтобы собрать простой пример проекта (также на Github), как на странице Руководства Quarkus . На данный момент руководство Elasticsearch там отсутствует.
Давайте продолжим с более подробным объяснением того, как соединить Quarkus с Elasticsearch.
Создание проекта Quarkus
Оболочка
xxxxxxxxxx
1
mvn io.quarkus:quarkus-maven-plugin:1.0.1.Final:create \
2
-DprojectGroupId=org.otaibe.quarkus.elasticsearch.example \
3
-DprojectArtifactId=otaibe-quarkus-elasticsearch-example \
4
-DclassName="org.otaibe.quarkus.elasticsearch.example.web.controller.FruitResource" \
5
-Dpath="/fruits" \
6
-Dextensions="resteasy-jackson,elasticsearch-rest-client"
Вам также нравится:
Создание Java REST API с помощью Quarkus .
Настройки Maven
Как вы можете видеть, в Quarkus присутствует эластичный поисковый клиент-клиент ; тем не менее, это Elasticsearch Java REST Client низкого уровня. Если мы хотим использовать Elasticsearch Java High Level REST Client, нам просто нужно добавить его в качестве зависимости в файле pom.xml :
XML
xxxxxxxxxx
1
<dependency>
2
<groupId>org.elasticsearch.client</groupId>
3
<artifactId>elasticsearch-rest-high-level-client</artifactId>
4
<version>7.4.0</version>
5
</dependency>
Пожалуйста, убедитесь, что версия Elasticsearch Java Low Level REST Client соответствует Elasticsearch Java High Level REST Client .
Поскольку мы используем Elasticsearch реактивно, я предпочитаю использовать Project Reactor . Мы должны добавить спецификацию в раздел управления зависимостями:
XML
xxxxxxxxxx
1
<dependency>
2
<groupId>io.projectreactor</groupId>
3
<artifactId>reactor-bom</artifactId>
4
<version>Dysprosium-SR2</version>
5
<type>pom</type>
6
<scope>import</scope>
7
</dependency>
Мы также должны добавить ядро реактора в качестве зависимости:
XML
xxxxxxxxxx
1
<dependency>
2
<groupId>io.projectreactor</groupId>
3
<artifactId>reactor-core</artifactId>
4
</dependency>
Я выделил общий код в библиотеке , поэтому мы должны добавить эту библиотеку в наш пример проекта. Для этого мы будем использовать Jitpack . Это потрясающий сервис. Вам просто нужно указать правильный путь к вашему проекту Github, и он создаст для него артефакт. Вот способ, которым я использую это:
XML
xxxxxxxxxx
1
<dependency>
2
<groupId>com.github.tpenakov.otaibe-commons-quarkus</groupId>
3
<artifactId>otaibe-commons-quarkus-core</artifactId>
4
<version>elasticsearch-example.02</version>
5
</dependency>
6
<dependency>
7
<groupId>com.github.tpenakov.otaibe-commons-quarkus</groupId>
8
<artifactId>otaibe-commons-quarkus-elasticsearch</artifactId>
9
<version>elasticsearch-example.02</version>
10
</dependency>
11
<dependency>
12
<groupId>com.github.tpenakov.otaibe-commons-quarkus</groupId>
13
<artifactId>otaibe-commons-quarkus-rest</artifactId>
14
<version>elasticsearch-example.02</version>
15
</dependency>
Запустите Elasticsearch через Docker
Кроме того, мы должны начать Elastisearch. Самый простой способ сделать это — запустить его через Docker:
Оболочка
xxxxxxxxxx
1
docker run -it --rm=true --name elasticsearch_quarkus_test \
2
-p 11027:9200 -p 11028:9300 \
3
-e "discovery.type=single-node" \
4
docker.elastic.co/elasticsearch/elasticsearch:7.4.0
Подключение к Elasticsearch
Давайте начнем с подключения нашего сервиса к Elasticsearch — реализация в примере проекта проста — поэтому он будет прослушивать события запуска и завершения работы Quarkus и инициировать или завершать соединения:
Джава
xxxxxxxxxx
1
package org.otaibe.quarkus.elasticsearch.example.service;
2
import io.quarkus.runtime.ShutdownEvent;
4
import io.quarkus.runtime.StartupEvent;
5
import lombok.Getter;
6
import lombok.Setter;
7
import lombok.extern.slf4j.Slf4j;
8
import org.otaibe.commons.quarkus.elasticsearch.client.service.AbstractElasticsearchService;
9
import javax.enterprise.context.ApplicationScoped;
11
import javax.enterprise.event.Observes;
12
14
15
16
17
public class ElasticsearchService extends AbstractElasticsearchService {
18
public void init( StartupEvent event) {
20
log.info("init started");
21
super.init();
22
log.info("init completed");
23
}
24
public void shutdown( ShutdownEvent event) {
26
log.info("shutdown started");
27
super.shutdown();
28
log.info("shutdown completed");
29
}
30
}
Фактическая работа по подключению к Elasticsearch выполняется в AbstractElasticsearchService :
Джава
xxxxxxxxxx
1
public abstract class AbstractElasticsearchService {
2
name = "service.elastic-search.hosts") (
3
String[] hosts;
4
name = "service.elastic-search.num-threads", defaultValue = "10") (
5
Optional<Integer> numThreads;
6
private RestHighLevelClient restClient;
8
private Sniffer sniffer;
9
11
public void init() {
12
log.info("init started");
13
List<HttpHost> httpHosts = Arrays.stream(hosts)
14
.map(s -> StringUtils.split(s, ':'))
15
.map(strings -> new HttpHost(strings[0], Integer.valueOf(strings[1])))
16
.collect(Collectors.toList());
17
RestClientBuilder builder = RestClient.builder(httpHosts.toArray(new HttpHost[httpHosts.size()]));
18
getNumThreads().ifPresent(integer ->
19
builder.setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setDefaultIOReactorConfig(
20
IOReactorConfig
21
.custom()
22
.setIoThreadCount(integer)
23
.build())
24
));
25
restClient = new RestHighLevelClient(builder);
27
sniffer = Sniffer.builder(getRestClient().getLowLevelClient()).build();
28
log.info("init completed");
29
}
30
}
Как вы можете видеть, соединение здесь выполняется способом, предложенным в документации Elasticsearch . Моя реализация зависит от двух свойств конфигурации:
Файлы свойств
xxxxxxxxxx
1
service.elastic-search.hosts=localhost:11027
Это строка подключения Elasticsearch после запуска из Docker.
Второе необязательное свойство:
Файлы свойств
xxxxxxxxxx
1
service.elastic-search.num-threads
Это количество потоков, необходимых для клиента.
Создание POJO
Теперь давайте создадим объект нашего домена ( Fruit ):
Джава
xxxxxxxxxx
1
package org.otaibe.quarkus.elasticsearch.example.domain;
2
import com.fasterxml.jackson.annotation.JsonProperty;
4
import lombok.AllArgsConstructor;
5
import lombok.Data;
6
import lombok.NoArgsConstructor;
7
9
10
staticName = "of") (
11
public class Fruit {
12
public static final String ID = "id";
14
public static final String EXT_REF_ID = "ext_ref_id";
15
public static final String NAME = "name";
16
public static final String DESCRIPTION = "description";
17
public static final String VERSION = "version";
18
ID) (
20
public String id;
21
EXT_REF_ID) (
22
public String extRefId;
23
NAME) (
24
public String name;
25
DESCRIPTION) (
26
public String description;
27
VERSION) (
28
public Long version;
29
}
Создание и внедрение DAO
Создание индекса
Давайте создадим FruitDaoImpl . Это высокоуровневый класс, созданный для заполнения AbstractElasticsearchReactiveDaoImplementation и реализации необходимой бизнес-логики. Другой важной частью здесь является создание индекса для класса Fruit:
Джава
xxxxxxxxxx
1
2
protected Mono<Boolean> createIndex() {
3
CreateIndexRequest request = new CreateIndexRequest(getTableName());
4
Map<String, Object> mapping = new HashMap();
5
Map<String, Object> propsMapping = new HashMap<>();
6
propsMapping.put(Fruit.ID, getKeywordTextAnalizer());
7
propsMapping.put(Fruit.EXT_REF_ID, getKeywordTextAnalizer());
8
propsMapping.put(Fruit.NAME, getTextAnalizer(ENGLISH));
9
propsMapping.put(Fruit.DESCRIPTION, getTextAnalizer(ENGLISH));
10
propsMapping.put(Fruit.VERSION, getLongFieldType());
11
mapping.put(PROPERTIES, propsMapping);
12
request.mapping(mapping);
13
return createIndex(request);
15
}
Реальный вызов индекса создания для Elasticsearch реализован в родительском классе ( AbstractElasticsearchReactiveDaoImplementation ):
Джава
xxxxxxxxxx
1
protected Mono<Boolean> createIndex(CreateIndexRequest request) {
2
return Flux.<Boolean>create(fluxSink -> getRestClient().indices().createAsync(request, RequestOptions.DEFAULT, new ActionListener<CreateIndexResponse>() {
3
4
public void onResponse(CreateIndexResponse createIndexResponse) {
5
log.info("CreateIndexResponse: {}", createIndexResponse);
6
fluxSink.next(createIndexResponse.isAcknowledged());
7
fluxSink.complete();
8
}
9
11
public void onFailure(Exception e) {
12
log.error("unable to create index", e);
13
fluxSink.error(new RuntimeException(e));
14
}
15
}))
16
.next();
17
}
Играя с DAO
Большинство операций CRUD реализованы в AbstractElasticsearchReactiveDaoImplementation .
Она имеет save
, update
, findById
и deleteById
общие методы. Также есть findByExactMatch
и findByMatch
защищенные методы. Эти FindBy*
методы очень полезны в классах - потомках , когда бизнес - логике потребность тобы заполненные.
Методы поиска бизнеса реализованы в классе FruitDaoImpl :
Джава
xxxxxxxxxx
1
public Flux<Fruit> findByExternalRefId(String value) {
2
return findByMatch(Fruit.EXT_REF_ID, value);
3
}
4
public Flux<Fruit> findByName(String value) {
6
return findByMatch(Fruit.NAME, value);
7
}
8
public Flux<Fruit> findByDescription(String value) {
10
return findByMatch(Fruit.NAME, value);
11
}
12
public Flux<Fruit> findByNameOrDescription(String value) {
14
Map<String, Object> query = new HashMap<>();
15
query.put(Fruit.NAME, value);
16
query.put(Fruit.DESCRIPTION, value);
17
return findByMatch(query);
18
}
Инкапсуляция DAO в классе обслуживания
FruitDaoImpl инкапсулируется в FruitService :
Джава
xxxxxxxxxx
1
2
3
4
5
public class FruitService {
6
8
FruitDaoImpl dao;
9
public Mono<Fruit> save(Fruit entity) {
11
return getDao().save(entity);
12
}
13
public Mono<Fruit> findById(Fruit entity) {
15
return getDao().findById(entity);
16
}
17
public Mono<Fruit> findById(String id) {
19
return Mono.just(Fruit.of(id, null, null, null, null))
20
.flatMap(entity -> findById(entity))
21
;
22
}
23
public Flux<Fruit> findByExternalRefId(String value) {
25
return getDao().findByExternalRefId(value);
26
}
27
public Flux<Fruit> findByName(String value) {
29
return getDao().findByName(value);
30
}
31
public Flux<Fruit> findByDescription(String value) {
33
return getDao().findByDescription(value);
34
}
35
public Flux<Fruit> findByNameOrDescription(String value) {
37
return getDao().findByNameOrDescription(value);
38
}
39
public Mono<Boolean> delete(Fruit entity) {
41
return Mono.just(entity.getId())
42
.filter(s -> StringUtils.isNotBlank(s))
43
.flatMap(s -> getDao().deleteById(entity))
44
.defaultIfEmpty(false);
45
}
46
}
Тестирование FruitService
В FruitServiceTests написана для того , чтобы протестировать базовую функциональность. Он также используется для обеспечения правильной индексации полей класса Fruit и того, что полнотекстовый поиск работает должным образом:
Джава
xxxxxxxxxx
1
2
public void manageFruitTest() {
3
Fruit apple = getTestUtils().createApple();
4
Fruit apple1 = getFruitService().save(apple).block();
6
Assertions.assertNotNull(apple1.getId());
7
Assertions.assertTrue(apple1.getVersion() > 0);
8
log.info("saved result: {}", getJsonUtils().toStringLazy(apple1));
9
List<Fruit> fruitList = getFruitService().findByExternalRefId(TestUtils.EXT_REF_ID).collectList().block();
11
Assertions.assertTrue(fruitList.size() > 0);
12
List<Fruit> fruitList1 = getFruitService().findByNameOrDescription("bulgaria").collectList().block();
14
Assertions.assertTrue(fruitList1.size() > 0);
15
//Ensure that the full text search is working - it is 'Apples' in description
17
List<Fruit> fruitList2 = getFruitService().findByDescription("apple").collectList().block();
18
Assertions.assertTrue(fruitList2.size() > 0);
19
//Ensure that the full text search is working - it is 'Apple' in name
21
List<Fruit> fruitList3 = getFruitService().findByName("apples").collectList().block();
22
Assertions.assertTrue(fruitList3.size() > 0);
23
Boolean deleteAppleResult = getFruitService().getDao().deleteById(apple1).block();
25
Assertions.assertTrue(deleteAppleResult);
26
}
Добавление конечных точек REST
Поскольку это пример проекта, полная функциональность CRUD не добавляется в качестве конечных точек REST. Только save
и findById
добавляются как конечные точки REST. Они добавлены в FruitResource . Методы там возвращаются CompletionStage<Response>
, что гарантирует, что в нашем приложении не будет заблокированных потоков.
Тестирование конечных точек REST
FruitResourceTest добавлен для того, чтобы проверить RESTendpoints:
Джава
xxxxxxxxxx
1
package org.otaibe.quarkus.elasticsearch.example.web.controller;
2
import io.quarkus.test.junit.QuarkusTest;
4
import lombok.AccessLevel;
5
import lombok.Getter;
6
import lombok.extern.slf4j.Slf4j;
7
import org.apache.commons.lang3.StringUtils;
8
import org.eclipse.microprofile.config.inject.ConfigProperty;
9
import org.junit.jupiter.api.Assertions;
10
import org.junit.jupiter.api.Test;
11
import org.otaibe.commons.quarkus.core.utils.JsonUtils;
12
import org.otaibe.quarkus.elasticsearch.example.domain.Fruit;
13
import org.otaibe.quarkus.elasticsearch.example.service.FruitService;
14
import org.otaibe.quarkus.elasticsearch.example.utils.TestUtils;
15
import javax.inject.Inject;
17
import javax.ws.rs.core.HttpHeaders;
18
import javax.ws.rs.core.MediaType;
19
import javax.ws.rs.core.Response;
20
import javax.ws.rs.core.UriBuilder;
21
import java.net.URI;
22
import java.util.Optional;
23
import static io.restassured.RestAssured.given;
25
27
value = AccessLevel.PROTECTED) (
28
29
public class FruitResourceTest {
30
name = "service.http.host") (
32
Optional<URI> host;
33
35
TestUtils testUtils;
36
37
JsonUtils jsonUtils;
38
39
FruitService service;
40
42
public void restEndpointsTest() {
43
log.info("restEndpointsTest start");
44
Fruit apple = getTestUtils().createApple();
45
Fruit savedApple = given()
47
.when()
48
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
49
.body(apple)
50
.post(getUri(FruitResource.ROOT_PATH))
51
.then()
52
.statusCode(200)
53
.extract()
54
.as(Fruit.class);
55
String id = savedApple.getId();
56
Assertions.assertTrue(StringUtils.isNotBlank(id));
57
URI findByIdPath = UriBuilder.fromPath(FruitResource.ROOT_PATH)
59
.path(id)
60
.build();
61
Fruit foundApple = given()
63
.when().get(getUri(findByIdPath.getPath()).getPath())
64
.then()
65
.statusCode(200)
66
.extract()
67
.as(Fruit.class);
68
Assertions.assertEquals(savedApple, foundApple);
70
Boolean deleteResult = getService().delete(foundApple).block();
72
Assertions.assertTrue(deleteResult);
73
given()
75
.when().get(findByIdPath.getPath())
76
.then()
77
.statusCode(Response.Status.NOT_FOUND.getStatusCode())
78
;
79
log.info("restEndpointsTest end");
81
}
82
private URI getUri(String path) {
84
return getUriBuilder(path)
85
.build();
86
}
87
private UriBuilder getUriBuilder(String path) {
89
return getHost()
90
.map(uri -> UriBuilder.fromUri(uri))
91
.map(uriBuilder -> uriBuilder.path(path))
92
.orElse(UriBuilder
93
.fromPath(path)
94
);
95
}
96
}
Создание собственного исполняемого файла
Перед созданием собственного исполняемого файла мы должны зарегистрировать наш объект домена Fruit . Причина этого в том, что наш FruitResource возвращает данные CompletionStage<Response>
, и поэтому фактический тип возврата неизвестен для приложения, поэтому мы должны зарегистрировать его явно для отражения. В Quarkus есть как минимум два способа сделать это:
-
С помощью аннотации @RegisterForReflection .
-
Через отражение-config.json .
Я лично предпочитаю второй метод, потому что классы, которые вы хотите зарегистрировать, могут быть в сторонней библиотеке, и было бы невозможно поместить туда @RegisterForReflection .
Теперь, отражение-config.json выглядит так:
JSON
xxxxxxxxxx
1
[
2
{
3
"name" : "org.otaibe.quarkus.elasticsearch.example.domain.Fruit",
4
"allDeclaredConstructors" : true,
5
"allPublicConstructors" : true,
6
"allDeclaredMethods" : true,
7
"allPublicMethods" : true,
8
"allDeclaredFields" : true,
9
"allPublicFields" : true
10
}
11
]
Следующим шагом будет ознакомление Quarkus с файлом reflection-config.json . Вы должны добавить эту строку в native
профиль в вашем файле pom.xml :
XML
xxxxxxxxxx
1
<quarkus.native.additional-build-args>-H:ReflectionConfigurationFiles=${project.basedir}/src/main/resources/reflection-config.json</quarkus.native.additional-build-args>
Теперь вы можете создать собственное приложение:
Оболочка
xxxxxxxxxx
1
mvn clean package -Pnative
И начать это:
Оболочка
xxxxxxxxxx
1
./target/otaibe-quarkus-elasticsearch-example-1.0-SNAPSHOT-runner
Служба будет доступна по адресу http: // localhost: 11025, поскольку этот порт явно указан в application.properties .
Файлы свойств
xxxxxxxxxx
1
quarkus.http.port=11025
Тестирование родной сборки
FruitResourceTest ожидает следующее дополнительное свойство:
Файлы свойств
xxxxxxxxxx
1
service.http.host
Если он присутствует, тестовые запросы попадут на указанный хост. Если вы запускаете собственный исполняемый файл:
Оболочка
xxxxxxxxxx
1
./target/otaibe-quarkus-elasticsearch-example-1.0-SNAPSHOT-runner
и выполните тесты / сборку со следующим кодом:
Оболочка
xxxxxxxxxx
1
mvn package -D%test.service.http.host=http://localhost:11025
тесты будут выполняться против собственной сборки.
Вывод
Я был приятно удивлен, что Elasticsearch работает из коробки с Quarkus и может быть скомпилирован в нативный код, который в сочетании с реактивной реализацией через Project Reactor сделает след приложения почти несущественным.