Elasticsearch 提供 REST API 讓我們直接呼叫,但在實際進行程式開發時,可採用專門的 library。接下來的幾篇文章,會使用官方建議的「Java API Client」,在 Spring Boot 專案引進 ES。
本文會手刻 repository 層,包含實作單一 document 的 CRUD、批次新增 document,以及 index 的建立與刪除。讀者需對 Java 的泛型有基本概念。
讓我們在 Spring Boot 專案中引進 Elasticsearach。本文使用的版本如下。
請在 pom.xml 檔案添加 Elasticsearch 的依賴,筆者使用的版本為 8.8.2。其餘版本可參考它的 Maven Repository。
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.8.2</version>
</dependency>
現在讓我們在 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 類別中。
這系列文章,所示範的是將學生資料存放到 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 名稱需要有所區別,並能彈性抽換。
宣告好 repository 類別後,就能實際撰寫 ES 的相關操作。由於使用 library 的程式碼相當直觀,因此筆者不會用太多文字去解說。以下的範例程式都是寫在 AbstractEsRepository
。
簡單來說就是建立 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);
}
}
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 的程式。
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 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 的程式,會「覆蓋」資料(而非更新部份欄位)。
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 id 刪除資料的程式。
public void deleteById(String id) throws IOException {
var deleteReq = new DeleteRequest.Builder()
.index(indexName)
.id(id)
.build();
client.delete(deleteReq);
}
若同時有多個對 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,並轉貼到這裡。
今日文章到此結束!
最後推廣一下自己的部落格,我是「新手工程師的程式教室」的作者,請多指教