iT邦幫忙

2023 iThome 鐵人賽

DAY 27
0
Software Development

救救我啊我救我!CRUD 工程師的惡補日記系列 第 27

【Elasticsearch】導入到 Spring Boot 並使用 Java API Client 實作 CRUD

  • 分享至 

  • xImage
  •  

Elasticsearch 提供 REST API 讓我們直接呼叫,但在實際進行程式開發時,可採用專門的 library。接下來的幾篇文章,會使用官方建議的「Java API Client」,在 Spring Boot 專案引進 ES。

本文會手刻 repository 層,包含實作單一 document 的 CRUD、批次新增 document,以及 index 的建立與刪除。讀者需對 Java 的泛型有基本概念。


一、程式專案準備

讓我們在 Spring Boot 專案中引進 Elasticsearach。本文使用的版本如下。

  • 建置工具:Maven
  • 程式語言:Java 17(zulu-17)
  • Spring Boot:3.1.3
  • Elasticsearch:8

(一)添加依賴

請在 pom.xml 檔案添加 Elasticsearch 的依賴,筆者使用的版本為 8.8.2。其餘版本可參考它的 Maven Repository

<dependency>
    <groupId>co.elastic.clients</groupId>
    <artifactId>elasticsearch-java</artifactId>
    <version>8.8.2</version>
</dependency>

(二)準備 Client

現在讓我們在 Spring Boot 專案中配置 ES 的連線設定。請新增一個 Configuration 類別,並在裡面配置 ElasticsearchClient 的元件。在後續的範例,都會使用這個 client 元件來發出請求。

@Configuration
public class ElasticsearchConfig {

    @Bean
    public ElasticsearchClient elasticsearchClient() {
        var httpHost = new HttpHost("localhost", 9200);
        var restClient = RestClient.builder(httpHost).build();
        var transport = new RestClientTransport(restClient, new JacksonJsonpMapper());

        return new ElasticsearchClient(transport);
    }
}

只要簡單地給予 ES 服務所在的 IP 地址與 port 號即可。而 JacksonJsonpMapper 物件則是用來將 JSON 與 Java 物件互相轉換,畢竟 ES 的 document 是以 JSON 格式來儲存。

IP 地址與 port 號可寫在 application.properties 檔案中,再注入到 Configuration 類別中。

二、建構 Repository 類別

這系列文章,所示範的是將學生資料存放到 ES,為此設計了描述學生的 model 類別。這裡只簡單地宣告兩個基本欄位,在 Day 29、Day 30 的文章,可隨著範例再額外宣告新欄位。

public class Student implements EsDocument {
    private String id;
    private String name;
    
    // getter, setter ...
}

public interface EsDocument {
    String getId();
}

該類別還實作了自定義的 EsDocument 介面,代表這是一個要存放在 ES 的物件類別。到了本文第四節,讀者會比較清楚為何要這麼做。

為了存取 ES,讓我們準備一個 repository 類別來封裝操作。由於程式邏輯並不會因為 document 類別的不同而有顯著差異,所以筆者設計為抽象類別。若有差異的地方,再宣告抽象方法讓子類別去實作即可,在 Day 29、Day 30 將會見到。

public abstract class AbstractEsRepository<T extends EsDocument> {
    private final ElasticsearchClient client;
    private final String indexName;
    private final Class<T> docClz;

    protected AbstractEsRepository(ElasticsearchClient client, String indexName) {
        this.client = client;
        this.indexName = indexName;
        this.docClz = (Class<T>) ((ParameterizedType) getClass()
                .getGenericSuperclass())
                .getActualTypeArguments()[0];
    }

    // TODO
}

子類別繼承時會定義泛型類別,也就是 ES document 的 model 類別。

另外,此抽象類別的建構子除了接收 ElasticsearchClient 元件和 ES index 的名稱,還會將上述的泛型類別讀取成 Class 常數,同樣在本文第四節會使用到。

最後以 Student 這個 model 類別為基礎,建立 ES 的 repository 類別,並建立為元件。

public class StudentEsRepository extends AbstractEsRepository<Student> {

    public StudentEsRepository(ElasticsearchClient client, String indexName) {
        super(client, indexName);
    }
}

@Configuration
public class ElasticsearchConfig {
    // ...

    @Bean
    public StudentEsRepository studentEsRepository(ElasticsearchClient client) {
        return new StudentEsRepository(client, "student");
    }
}

此處 index 名稱是 hard code 的,然而讀者亦可將 index 名稱寫在 application.properties 檔案後,再注入進來。筆者前公司就是這麼做的,由於有多臺 server 做為測試環境,但運行 ES 服務的 server 只有一臺,所以 index 名稱需要有所區別,並能彈性抽換。

三、Index 操作

宣告好 repository 類別後,就能實際撰寫 ES 的相關操作。由於使用 library 的程式碼相當直觀,因此筆者不會用太多文字去解說。以下的範例程式都是寫在 AbstractEsRepository

(一)建立 Index

簡單來說就是建立 CreateIndexRequest 物件,並指定 index 名稱。接著呼叫 ElasticsearchClient.indices 方法,根據要對 index 執行的操作,將 request 物件傳入即可。

public abstract class AbstractEsRepository<T extends EsDocument> {
    private final ElasticsearchClient client;
    private final String indexName;

    public void createIndex() throws IOException {
        var request = new CreateIndexRequest.Builder()
                .index(indexName)
                .build();
        client.indices().create(request);
    }
}

(二)刪除 Index

public void deleteIndex() throws IOException {
    var request = new DeleteIndexRequest.Builder()
            .index(indexName)
            .build();
    client.indices().delete(request);
}

這款 library 除了能使用 builder 的方式建立各種物件,也能使用 of 方法。以這裡的 DeleteIndexRequest 為例,可改寫成如下。

DeleteIndexRequest request = DeleteIndexRequest.of(builder -> builder.index(indexName));

若建立物件所需的參數很少時,選用 of 方法可讓程式碼簡潔些。

本節介紹了新增與刪除 index。當 ES 的 document 有欄位上的異動,就會需要刪除 index,並重新建立。

四、單一 Document 的 CRUD

(一)建立 Document

以下是建立一筆 document 的程式。

public abstract class AbstractEsRepository<T extends EsDocument> {
    private final ElasticsearchClient client;
    private final String indexName;

    public void insert(T document) throws IOException {
        var createReq = new CreateRequest.Builder<T>()
                .index(indexName)
                .id(document.getId())
                .document(document)
                .build();
        client.create(createReq);
    }
}

需要建立出 CreateRequest 物件,會指定 index 名稱、ES document 的 id,以及傳入 document 資料本身。

過程中還會提供一個泛型類別,它將限制 document 方法所接收的參數型態。若不考慮本文的抽象類別設計,直接建立 CreateRequest 的程式碼寫法如下。

public void insert(Student student) throws IOException {
    var createReq = new CreateRequest.Builder<Student>()
            .index(indexName)
            .id(student.getId())
            .document(student)
            .build();
    client.create(createReq);
}

這樣應該不難看出筆者在抽象類別使用泛型的原因,就是為了適用每個需要傳入 document 類別與物件的地方。

(二)取得 Document

以下是根據 document id 取得資料的程式。

public abstract class AbstractEsRepository<T extends EsDocument> {
    private final Class<T> docClz;

    // ...

    public T findById(String id) throws IOException {
        var getReq = new GetRequest.Builder()
                .index(indexName)
                .id(id)
                .build();
        var getRes = client.get(getReq, docClz);

        return getRes.source();
    }
}

建立出 GetRequest 物件後,需要連同 document 類別一同交由 client 發送請求。隨後得到 GetResponse 物件。若找不到 document,GetResponse.source 方法會回傳 null。

此處程式中的 docClz 參數,對於 StudentEsRepository 而言就是 Student.class。這是在建構子中讀取泛型類別所得到的。

(三)更新 Document

以下是更新 document 的程式,會「覆蓋」資料(而非更新部份欄位)。

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...

    public void save(T document) throws IOException {
        var indexReq = new IndexRequest.Builder<T>()
                .index(indexName)
                .id(document.getId())
                .document(document)
                .build();
        client.index(indexReq);
    }
}

在建立 IndexRequest 的過程中,會提供 document 的 id。那這個 id 的值從哪裡來呢?有種做法是在這個自定義的 save 方法加一個參數來接收。

但與其多加一個參數,不如就規定這個泛型類別必須實作某個介面,如此便能透過介面的方法取得 id 了。以 StudentEsRepository 為例,在繼承 AbstractEsRepository 時,泛型類別 Student 必須實作一個可以取得 document id 的介面,也就是第二節宣告的 EsDocument

(四)刪除 Document

以下是根據 document id 刪除資料的程式。

public void deleteById(String id) throws IOException {
    var deleteReq = new DeleteRequest.Builder()
            .index(indexName)
            .id(id)
            .build();
    client.delete(deleteReq);
}

五、批次建立 Document

若同時有多個對 document 操作要進行,透過「批次」(bulk)的做法,效率會好一些。相對地程式碼也比較長。

public void insert(Collection<T> documents) throws IOException {
    List<BulkOperation> bulkCreateOperations = documents.stream()
            .map(doc -> {
                var createOp = new CreateOperation.Builder<T>()
                        .id(doc.getId())
                        .document(doc)
                        .build();
                return new BulkOperation.Builder()
                        .create(createOp)
                        .build();
            })
            .toList();

    var bulkReq = new BulkRequest.Builder()
            .index(indexName)
            .operations(bulkCreateOperations)
            .build();
    client.bulk(bulkReq);
}

想發送請求進行批次操作,會建立出 BulkRequest 物件,它會包含 index 名稱和要執行的操作。關於「要執行的操作」,在程式中會以 BulkOperation 的型態來提供。

本節我們要進行批次建立 document,那就會建立 CreateOperation,隨後包裝成 BulkOperation

以此類推,若想做批次刪除,則會建立 DeleteOperation,再使用 BulkOperation.Builder.delete 方法,得到 BulkOperation

六、例外處理

從前面的範例程式可以注意到,透過 ElasticsearchClient 發出請求時,都會有 IOException 這個 checked exception 要處理。若讀者不想往上層丟,也不想一直重複寫出 try-catch,可使用 functional programming 的做法來處理。

下面建立一個 functional interface,宣告一個名為 get 的方法,可以回傳任意型態的值,但會拋出 IOException

@FunctionalInterface
public interface IOSupplier<V> {
    V get() throws IOException;
}

接著回到 AbstractEsRepository 撰寫一個方法,讓會拋出 IOException 的一段程式,能以 functional programming 形式傳入。呼叫 IOSupplier.get 方法實際執行後,可達到在同一個地方統一處理例外的效果。

private <V> V execute(IOSupplier<V> supplier) {
    try {
        return supplier.get();
    } catch (IOException e) {
        // 範例為求方便,只簡單做例外處理
        throw new RuntimeException(e);
    }
}

如此一來,前面有用到 ElasticsearchClient 的地方,便能改寫成如下。

public abstract class AbstractEsRepository<T extends EsDocument> {
    // ...

    public void createIndex() {
        // ...
        execute(() -> client.indices().create(request));
    }
    
    public void insert(T document) {
        // ...
        execute(() -> client.create(createReq));
    }
    
    public T findById(String id) {
        // ...
        var getRes = execute(() -> client.get(getReq, docClz));

        return getRes.source();
    }

    private <V> V execute(IOSupplier<V> supplier) {
        // ...
    }
}

變得簡單多了。

Ref:【ElasticSearch 8】導入到 Spring Boot 並實作 CRUD(改寫自本人文章)

Elasticsearch 系列的完成後專案,會在鐵人賽結束後上傳到 Github,並轉貼到這裡。


今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教/images/emoticon/emoticon41.gif


上一篇
【Spring Security】透過 Security Context 得知誰在存取 API
下一篇
【Elasticsearch】使用 Java API Client 建立搜尋條件與排序方式
系列文
救救我啊我救我!CRUD 工程師的惡補日記50
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言