Я реализовал один сервис с 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 сделает след приложения почти несущественным.