來聊一聊 ElasticSearch 最新版的 Java 客戶端

語言: CN / TW / HK

可能不少小夥伴都注意到了,從 ElasticSearch7.17 這個版本開始,原先的 Java 高階客戶端 Java High Level REST Client 廢棄了,不支援了。老實說,ElasticSearch 算是我用過的所有 Java 工具中,更新最為激進的一個了,在 Es7 中廢棄了 TransportClient,7.17 又廢棄了 TransportClient,那麼現在用啥呢?現在的客戶端叫做 Elasticsearch Java API Client。

一直偷懶選擇無視 Elasticsearch Java API Client,不過最近工作中用到了,所以還是整篇文章和小夥伴們簡單梳理一下 Elasticsearch Java API Client 的玩法。

下面的介紹我主要從索引操作和文件操作兩個方面來給大家介紹。

不過需要跟大家強調的是,ElasticSearch 的 Java 客戶端想要用的 6,必須要熟悉 ElasticSearch 的查詢指令碼,大家平時在工作中遇到 Es 相關的問題,我也都是建議先在 Kibana 中把操作指令碼寫好,然後再翻譯成 Java 程式碼,或者直接拷貝到 Java 程式碼中,非常不建議上來就整 Java 程式碼,那樣很容易出錯。

如果你對 Es 的操作不熟悉,鬆哥錄了免費的視訊教程,大家可以參考:

不想看視訊,也可以在微信公眾號後臺回覆 es,有文件教程。

1. Elasticsearch Java API Client

Elasticsearch Java API Client 是 Elasticsearch 的官方 Java API,這個客戶端為所有 Elasticsearch APIs 提供強型別的請求和響應。

> 這裡跟大家解釋下什麼是強型別的請求和響應:因為所有的 Elasticsearch APIs 本質上都是一個 RESTful 風格的 HTTP 請求,所以當我們呼叫這些 Elasticsearch APIs 的時候,可以就當成普通的 HTTP 介面來對待,例如使用 HttpUrlConnection 或者 RestTemplate 等工具來直接呼叫,如果使用這些工具直接呼叫,就需要我們自己組裝 JSON 引數,然後自己解析服務端返回的 JSON。而強型別的請求和響應則是系統把請求引數封裝成一個物件了,我們呼叫物件中的方法去設定就可以了,不需要自己手動拼接 JSON 引數了,請求的結果系統也會封裝成一個物件,不需要自己手動去解析 JSON 引數了。

小夥伴們看一下下面這個例子,我想查詢 books 索引中,書名中包含 Java 關鍵字的圖書:

public class EsDemo02 {
    public static void main(String[] args) throws IOException {
        URL url = new URL("http://localhost:9200/books/_search?pretty");
        HttpURLConnection con = (HttpURLConnection) url.openConnection();
        con.setRequestMethod("GET");
        con.setRequestProperty("content-type","application/json;charset=utf-8");
        //允許輸出流/允許引數
        con.setDoOutput(true);
        //獲取輸出流
        OutputStream out = con.getOutputStream();
        String params = "{\n" +
                "  \"query\": {\n" +
                "    \"term\": {\n" +
                "      \"name\": {\n" +
                "        \"value\": \"java\"\n" +
                "      }\n" +
                "    }\n" +
                "  }\n" +
                "}";
        out.write(params.getBytes());
        if (con.getResponseCode() == 200) {
            BufferedReader br = new BufferedReader(new InputStreamReader(con.getInputStream()));
            String str = null;
            while ((str = br.readLine()) != null) {
                System.out.println(str);
            }
            br.close();
        }
    }
}

小夥伴們看到,這就是一個普通的 HTTP 請求,請求引數就是查詢的條件,這個條件是一個 JSON 字串,需要我們自己組裝,請求的返回值也是一個 JSON 字串,這個 JSON 字串也需要我們自己手動去解析,這種可以算是弱型別的請求和響應。

Elasticsearch Java API Client 具有如下特性:

  • 為所有 Elasticsearch APIs 提供強型別的請求和響應。
  • 所有 API 都有阻塞和非同步版本。
  • 使用構建器模式,在建立複雜的巢狀結構時,可以編寫簡潔而可讀的程式碼。
  • 通過使用物件對映器(如 Jackson 或任何實現了 JSON-B 的解析器),實現應用程式類的無縫整合。
  • 將協議處理委託給一個 http 客戶端,如 Java Low Level REST Client,它負責所有傳輸級的問題。HTTP 連線池、重試、節點發現等等由它去完成。

關於第三點,鬆哥吐槽一句,確實簡潔,但是可讀性一般般吧。

另外還有兩點需要注意:

  • Elasticsearch Java 客戶端是向前相容的,即該客戶端支援與 Elasticsearch 的更大或相等的次要版本進行通訊。
  • Elasticsearch Java 客戶端只向後相容預設的發行版本,並且沒有做出保證。

好了,那就不廢話了,開整吧。

2. 引入 Elasticsearch Java API Client

首先需要我們加依賴,對 JDK 的版本要求是 1.8,我們需要新增如下兩個依賴:

<dependency>
  <groupid>co.elastic.clients</groupid>
  <artifactid>elasticsearch-java</artifactid>
  <version>8.5.1</version>
</dependency>

<dependency>
  <groupid>com.fasterxml.jackson.core</groupid>
  <artifactid>jackson-databind</artifactid>
  <version>2.12.3</version>
</dependency>

如果是 Spring Boot 專案,就不用新增第二個依賴了,因為 Spring Boot 的 Web 中預設已經加了這個依賴了,但是 Spring Boot 一般需要額外新增下面這個依賴,出現這個原因是由於從 JavaEE 過渡到 JakartaEE 時衍生出來的一些問題,這裡我就不囉嗦了,咱們直接加依賴即可:

<dependency>
  <groupid>jakarta.json</groupid>
  <artifactid>jakarta.json-api</artifactid>
  <version>2.0.1</version>
</dependency>

3. 建立連線

接下來我們需要用我們的 Java 客戶端和 ElasticSearch 之間建立連線,建立連線的方式如下:

RestClient restClient = RestClient.builder(
    new HttpHost("localhost", 9200)).build();
ElasticsearchTransport transport = new RestClientTransport(
    restClient, new JacksonJsonpMapper());
ElasticsearchClient client = new ElasticsearchClient(transport);

小夥伴們看到,這裡一共有三個步驟:

  1. 首先建立一個低階客戶端,這個其實鬆哥之前的視訊中和大家講過低階客戶端的用法,這裡就不再贅述。
  2. 接下來建立一個通訊 Transport,並利用 JacksonJsonpMapper 做資料的解析。
  3. 最後建立一個阻塞的 Java 客戶端。

上面這個是建立了一個阻塞的 Java 客戶端,當然我們也可以建立非阻塞的 Java 客戶端,如下:

RestClient restClient = RestClient.builder(
        new HttpHost("localhost", 9200)).build();
ElasticsearchTransport transport = new RestClientTransport(
        restClient, new JacksonJsonpMapper());
ElasticsearchAsyncClient client = new ElasticsearchAsyncClient(transport);

只有第三步和前面的不一樣,其他都一樣。

> 利用阻塞的 Java 客戶端操作 Es 的時候會發生阻塞,也就是必須等到 Es 給出響應之後,程式碼才會繼續執行;非阻塞的 Java 客戶端則不會阻塞後面的程式碼執行,非阻塞的 Java 客戶端一般通過回撥函式處理請求的響應值。

有時候,我們可能還需要和 Es 之間建立 HTTPS 連線,那麼需要在前面程式碼的基礎之上,再套上一層 SSL,如下:

String fingerprint = "<certificate fingerprint>";
SSLContext sslContext = TransportUtils
    .sslContextFromCaFingerprint(fingerprint); 
BasicCredentialsProvider credsProv = new BasicCredentialsProvider(); 
credsProv.setCredentials(
    AuthScope.ANY, new UsernamePasswordCredentials(login, password)
);
RestClient restClient = RestClient
    .builder(new HttpHost(host, port, "https")) 
    .setHttpClientConfigCallback(hc -&gt; hc
        .setSSLContext(sslContext) 
        .setDefaultCredentialsProvider(credsProv)
    )
    .build();
ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
ElasticsearchClient client = new ElasticsearchClient(transport);

好了,關於建立連線,差不多就這些點。

4. 索引操作

Elasticsearch Java API Client 中最大的特色就是建造者模式+Lambda 表示式。例如,我想建立一個索引,方式如下:

@Test
public void test99() throws IOException {
    RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200)).build();
    ElasticsearchTransport transport = new RestClientTransport(
            restClient, new JacksonJsonpMapper());
    ElasticsearchClient client = new ElasticsearchClient(transport);
    CreateIndexResponse createIndexResponse = client.indices().create(
            c -&gt;
                    c.index("javaboy_books")
                            .settings(s -&gt;
                                    s.numberOfShards("3")
                                            .numberOfReplicas("1"))
                            .mappings(m -&gt;
                                    m.properties("name", p -&gt; p.text(f -&gt; f.analyzer("ik_max_word")))
                                            .properties("birthday", p -&gt; p.date(d -&gt; d.format("yyyy-MM-dd"))))
                            .aliases("books_alias", f -&gt; f.isWriteIndex(true)));
    System.out.println("createResponse.acknowledged() = " + createIndexResponse.acknowledged());
    System.out.println("createResponse.index() = " + createIndexResponse.index());
    System.out.println("createResponse.shardsAcknowledged() = " + createIndexResponse.shardsAcknowledged());
}

小夥伴們看到,這裡都是建造者模式和 Lambda 表示式,方法名稱其實都很好理解(前提是你得熟悉 ElasticSearch 操作指令碼),例如:

  • index 方法表示設定索引名稱
  • settings 方法表示配置 setting 中的引數
  • numberOfShards 表示索引的分片數
  • numberOfReplicas 表示配置索引的副本數
  • mapping 表示配置索引中的對映規則
  • properties 表示配置索引中的具體欄位
  • text 方法表示欄位是 text 型別的
  • analyzer 表示配置欄位的分詞器
  • aliases 表示配置索引的別名

反正這裡的方法都是見名知義的,上面這個就類似於下面這個請求:

PUT javaboy_books
{
  "settings": {
    "number_of_replicas": 1,
    "number_of_shards": 3
  },
  "mappings": {
    "properties": {
      "name":{
        "type": "text",
        "analyzer": "ik_max_word"
      },
      "birthday":{
        "type": "date",
        "format": "yyyy-MM-dd"
      }
    }
  },
  "aliases": {
    "xxxx":{
      
    }
  }
}

小夥伴們在寫的時候,腦子裡要先有下面這個指令碼,然後 Java 方法可以順手拈來了。

最終建立好的索引如下圖:

有的小夥伴可能覺得調這一大堆方法太囉裡囉唆了,來個簡單的,直接上 JSON,那也不是不可以,如下:

@Test
public void test98() throws IOException {
    RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200)).build();
    ElasticsearchTransport transport = new RestClientTransport(
            restClient, new JacksonJsonpMapper());
    ElasticsearchClient client = new ElasticsearchClient(transport);
    StringReader json = new StringReader("{\n" +
            "  \"settings\": {\n" +
            "    \"number_of_replicas\": 1,\n" +
            "    \"number_of_shards\": 3\n" +
            "  },\n" +
            "  \"mappings\": {\n" +
            "    \"properties\": {\n" +
            "      \"name\":{\n" +
            "        \"type\": \"text\",\n" +
            "        \"analyzer\": \"ik_max_word\"\n" +
            "      },\n" +
            "      \"birthday\":{\n" +
            "        \"type\": \"date\",\n" +
            "        \"format\": \"yyyy-MM-dd\"\n" +
            "      }\n" +
            "    }\n" +
            "  },\n" +
            "  \"aliases\": {\n" +
            "    \"xxxx\":{\n" +
            "      \n" +
            "    }\n" +
            "  }\n" +
            "}");
    CreateIndexResponse createIndexResponse = client.indices().create(
            c -&gt;
                    c.index("javaboy_books").withJson(json));
    System.out.println("createResponse.acknowledged() = " + createIndexResponse.acknowledged());
    System.out.println("createResponse.index() = " + createIndexResponse.index());
    System.out.println("createResponse.shardsAcknowledged() = " + createIndexResponse.shardsAcknowledged());
}

這是直接把 JSON 引數給拼接出來,就不需要一堆建造者+Lambda 了。

如果你想刪除索引呢?如下:

@Test
public void test06() throws IOException {
    RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200)).build();
    ElasticsearchTransport transport = new RestClientTransport(
            restClient, new JacksonJsonpMapper());
    ElasticsearchClient client = new ElasticsearchClient(transport);
    //刪除一個索引
    DeleteIndexResponse delete = client.indices().delete(f -&gt;
            f.index("my-index")
    );
    System.out.println("delete.acknowledged() = " + delete.acknowledged());
}

這個表示刪除一個名為 my-index 的索引。

好了,關於索引的操作我就說這兩點。

可能有的小夥伴會說,ElasticSearch 中建立索引可以配置很多引數你都沒講。在我看來,哪些很多引數其實跟這個 Java API 沒有多大關係,只要你會寫查詢指令碼,就自然懂得 Java API 中該呼叫哪個方法,退一萬步講,你會指令碼,不懂 Java API 的方法,那麼就像上面那樣,直接把你的 JSON 拷貝過來,作為 Java API 的引數即可。

5. 文件操作

5.1 新增文件

先來看文件的新增操作。

如下表示我想給一個名為 books 的索引中新增一個 id 為 890 的書:

@Test
public void test07() throws IOException {
    RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200)).build();
    ElasticsearchTransport transport = new RestClientTransport(
            restClient, new JacksonJsonpMapper());
    ElasticsearchClient client = new ElasticsearchClient(transport);
    Book book = new Book();
    book.setId(890);
    book.setName("深入理解Java虛擬機器");
    book.setAuthor("xxx");
    //新增一個文件
    //這是一個同步請求,請求會卡在這裡
    IndexResponse response = client.index(i -&gt; i.index("books").document(book).id("890"));
    System.out.println("response.result() = " + response.result());
    System.out.println("response.id() = " + response.id());
    System.out.println("response.seqNo() = " + response.seqNo());
    System.out.println("response.index() = " + response.index());
    System.out.println("response.shards() = " + response.shards());
}

新增成功之後,返回的 IndexResponse 物件其實就是對下面這個 JSON 的封裝:

現在我們只需要呼叫相應的方法,就可以獲取到 JSON 相關的屬性了。

5.2 刪除文件

如下表示刪除 books 索引中 id 為 891 的文件:

@Test
public void test09() {
    RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200)).build();
    ElasticsearchTransport transport = new RestClientTransport(
            restClient, new JacksonJsonpMapper());
    ElasticsearchAsyncClient client = new ElasticsearchAsyncClient(transport);
    client.delete(d -&gt; d.index("books").id("891")).whenComplete((resp, e) -&gt; {
        System.out.println("resp.result() = " + resp.result());
    });
}

刪除這裡我用了非同步非阻塞的客戶端來給小夥伴們演示的,非同步非阻塞的話,就使用 whenComplete 方法處理回撥就行了,裡邊有兩個引數,一個是正常情況下返回的物件,另外一個則是出錯時候的異常。

5.3 查詢文件

最後,就是查詢了。這應該是大家日常開發中使用較多的功能項了,不過我還是前面的態度,查詢的關鍵不在 Java API,而在於你對 ElasticSearch 指令碼的掌握程度。

所以我這裡舉個簡單的例子,小夥伴們大致瞭解下 Java API 的方法即可:

@Test
public void test01() throws IOException {
    RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200)).build();
    ElasticsearchTransport transport = new RestClientTransport(
            restClient, new JacksonJsonpMapper());
    ElasticsearchClient client = new ElasticsearchClient(transport);
    SearchRequest request = new SearchRequest.Builder()
            //去哪個索引裡搜尋
            .index("books")
            .query(QueryBuilders.term().field("name").value("java").build()._toQuery())
            .build();
    SearchResponse<book> search = client.search(request, Book.class);
    System.out.println("search.toString() = " + search.toString());
    long took = search.took();
    System.out.println("took = " + took);
    boolean b = search.timedOut();
    System.out.println("b = " + b);
    ShardStatistics shards = search.shards();
    System.out.println("shards = " + shards);
    HitsMetadata<book> hits = search.hits();
    TotalHits total = hits.total();
    System.out.println("total = " + total);
    Double maxScore = hits.maxScore();
    System.out.println("maxScore = " + maxScore);
    List<hit<book>&gt; list = hits.hits();
    for (Hit<book> bookHit : list) {
        System.out.println("bookHit.source() = " + bookHit.source());
        System.out.println("bookHit.score() = " + bookHit.score());
        System.out.println("bookHit.index() = " + bookHit.index());
    }
}

上面這個例子是一個 term 查詢,查詢 books 索引中書名 name 中包含 java 關鍵字的圖書,等價於下面這個查詢:

GET books/_search
{
  "query": {
    "term": {
      "name": {
        "value": "java"
      }
    }
  }
}

如果希望能夠對查詢關鍵字分詞之後查詢,那麼可以使用 match 查詢,如下:

@Test
public void test03() throws IOException {
    RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200)).build();
    ElasticsearchTransport transport = new RestClientTransport(
            restClient, new JacksonJsonpMapper());
    ElasticsearchClient client = new ElasticsearchClient(transport);
    SearchResponse<book> search = client.search(s -&gt; {
        s.index("books")
                .query(q -&gt; {
                    q.match(m -&gt; {
                        m.field("name").query("美術計算機");
                        return m;
                    });
                    return q;
                });
        return s;
    }, Book.class);
    System.out.println("search.toString() = " + search.toString());
    long took = search.took();
    System.out.println("took = " + took);
    boolean b = search.timedOut();
    System.out.println("b = " + b);
    ShardStatistics shards = search.shards();
    System.out.println("shards = " + shards);
    HitsMetadata<book> hits = search.hits();
    TotalHits total = hits.total();
    System.out.println("total = " + total);
    Double maxScore = hits.maxScore();
    System.out.println("maxScore = " + maxScore);
    List<hit<book>&gt; list = hits.hits();
    for (Hit<book> bookHit : list) {
        System.out.println("bookHit.source() = " + bookHit.source());
        System.out.println("bookHit.score() = " + bookHit.score());
        System.out.println("bookHit.index() = " + bookHit.index());
    }
}

> 為了讓小夥伴們看到這個 Java 客戶端的不同用法,上面兩個查詢的例子,我分別使用了構造查詢請求和建造者+Lambda 的方式。

match 查詢就呼叫 match 方法就行了,設定查詢關鍵字即可,這個查詢等價於下面這個查詢:

GET books/_search
{
  "query": {
    "match": {
      "name": "美術計算機"
    }
  }
}

如果你覺得這種呼叫各種方法拼接引數的方式不習慣,那麼也可以直接上 JSON,如下:

@Test
public void test04() throws IOException {
    RestClient restClient = RestClient.builder(
            new HttpHost("localhost", 9200)).build();
    ElasticsearchTransport transport = new RestClientTransport(
            restClient, new JacksonJsonpMapper());
    ElasticsearchClient client = new ElasticsearchClient(transport);
    String key = "java";
    StringReader sr = new StringReader("{\n" +
            "  \"query\": {\n" +
            "    \"term\": {\n" +
            "      \"name\": {\n" +
            "        \"value\": \"" + key + "\"\n" +
            "      }\n" +
            "    }\n" +
            "  }\n" +
            "}");
    SearchRequest request = new SearchRequest.Builder()
            .withJson(sr)
            .build();
    SearchResponse<book> search = client.search(request, Book.class);
    System.out.println("search.toString() = " + search.toString());
    long took = search.took();
    System.out.println("took = " + took);
    boolean b = search.timedOut();
    System.out.println("b = " + b);
    ShardStatistics shards = search.shards();
    System.out.println("shards = " + shards);
    HitsMetadata<book> hits = search.hits();
    TotalHits total = hits.total();
    System.out.println("total = " + total);
    Double maxScore = hits.maxScore();
    System.out.println("maxScore = " + maxScore);
    List<hit<book>&gt; list = hits.hits();
    for (Hit<book> bookHit : list) {
        System.out.println("bookHit.source() = " + bookHit.source());
        System.out.println("bookHit.score() = " + bookHit.score());
        System.out.println("bookHit.index() = " + bookHit.index());
    }
}

可以看到,直接把查詢的 JSON 引數傳進來也是可以的。這樣我們就可以先在 Kibana 中寫好指令碼,然後直接將指令碼拷貝到 Java 程式碼中來執行就行了。

好啦,關於 Es 中新的 Java 客戶端,我就和大家說這麼多,最後再強調一下,這其實不是重點,玩 Es 的重點是把 Es 的各種查詢引數搞懂,那麼 Java 程式碼其實就是順手拈來的事了。

最後,如果大家對 Es 不熟悉,可以看看鬆哥錄的這個免費視訊教程:

</book></hit<book></book></book></book></hit<book></book></book></book></hit<book></book></book></certificate>