PHP使用Elasticsearch簡單實踐
背景
之前對 ES 不是很瞭解,最多是用 Kibana 去查一下日誌,突然有任務希望我能加個 ES 搜尋優化一下商品搜尋的效能。
主要是一些在 MySQL 資料量多的情況,通過 like 模糊搜尋會拉低效能,使用者體驗不是很好。
之前也看過一點 ES 教程,但是沒有上手做的話雲裡霧裡的,經過上手練習了一下後,之前學的內容一下都能理解了。
因為是總結所以我脫離了專案框架用原生大致寫了一遍,這樣就不用考慮業務上的一些邏輯了降低學習成本。
demo 程式碼我上傳到碼雲
搭建開發環境
為了方便我直接使用 docker 去搭建開發環境,本地我的 PHP 開發版本是7.2。
// 1. 拉取映象檔案 // 拉取es映象docker pull elasticsearch:7.7.1 // 拉取kibana映象 docker pull kibana:7.7.1 // 2. 例項化容器 // 將es跑起來,這裡我將本地 /Users/fangaolin/www/es_plugin 目錄和容器 /www/es_plugin 對映起來,你可以換成你本地的目錄路徑。 docker run -d --name es -p 9200:9200 -p 9300:9300 -v /Users/fangaolin/www/es_plugin:/www/es_plugin -e "discovery.type=single-node" -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" elasticsearch:7.7.1 // 將kibana跑起來 docker run -d --name kibana -e ELASTICSEARCH_HOSTS=http://host.docker.internal:9200/ --name kibana -p 5601:5601 kibana:7.7.1 // 3. 進入容器安裝分詞擴充套件 // 進入es容器 docker exec -it es /bin/sh // 安裝擴充套件,這裡要等一會,可能網路原因比較慢。 ./bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.7.1/elasticsearch-analysis-ik-7.7.1.zip// 4. 新建同義詞檔案 vi /usr/share/elasticsearch/config/synonyms.txt // 儲存退出 :wq // 5. 退出容器 exit // 6. 其它注意 // 後面再啟動容器就直接start,docker start [CONTAINER NAME] 如: docker start es
這時候可以在瀏覽器中看一下是否跑起來了(這兩個一般完全跑起來有點慢,沒出來多重新整理試試)
開啟 http://localhost:5601/ 進入 kibana 後臺
開啟 http://localhost:9200/ 檢視 ES 資訊
後面我們生成的資訊其實都存在 ES 中,kibana其實是方便我們檢視的視覺化工具,就和 Navicat 和 MySQL 的關係。
安裝好後就可以開始寫 PHP 程式碼了,新建一個目錄再右鍵開啟終端。
// 初始化專案的composer 一路預設即可 composer init // 引入包 composer require elasticsearch/elasticsearch 7.11.0 composer require ruflin/elastica 7.1
首先需要用上 PHP elasticsearch/elasticsearch 這個包,安裝時要注意和ES的版本相對應;
如果直接用我上傳在碼雲的專案只需在專案根目錄下執行 composer install 即可
如果你想自己新建可以執行下面命令
// 初始化專案的composer 一路預設即可 composer init // 執行命令 composer require elasticsearch/elasticsearch 7.11.0 composer require ruflin/elastica 7.1
關於PHP API的使用,ES 官網的文件庫中有一份中文文件
例項化客戶端PHP程式碼:
$builder = ClientBuilder::create(); // 連線地址 $hosts = ['localhost']; // 設定es地址 $builder->setHosts($hosts); // 還可以設定記錄日誌的例項,但要實現 LoggerInterface 介面的方法。 // $builder->setLogger(); $client = $builder->build();
後面的操作都將基於這個例項完成,所以可以把這個步驟用單例封裝一下,如果是在框架裡使用可以考慮用框架中容器工具封裝下。
除此之外還把配置資訊單獨抽離了出來:
class ElasticsearchObj { private static $singleObj; private $client; private function __construct() { $this->client = $this->init(); return $this->client; } /** * 初始化es連線例項 * * @return Client */ private function init() { $builder = ClientBuilder::create(); // 連線地址 $hosts = Config::getConfig('hosts'); // 設定es地址 $builder->setHosts($hosts); // 還可以設定記錄日誌的例項,但要完成 LoggerInterface 介面的方法。 // $builder->setLogger(); return $builder->build(); } /** * 獲得單例物件 * * @return ElasticsearchObj */ public static function getInstance() { if (isset(self::$singleObj)) { return self::$singleObj; } self::$singleObj = new ElasticsearchObj(); return self::$singleObj; } } class Config { private static $config = [ 'hosts' => [ '127.0.0.1' ] ]; public static function getConfig($name) { if (isset(self::$config[$name])){ return self::$config[$name]; } return ''; } }
快速新增資料 & Kibana 檢視資料
ES 一般預設是開啟DynamicMapping的,即 ES 在插入時沒有mapping時會自己推算型別,創造一個 mapping 讓文件插入成功。
可以先寫一些簡單的 demo 嘗試往 ES 中寫一些資料:
// 通過直接插入資料,生成一條全新的index $docItem = [ 'id' => 10, 'name' => '紅富士蘋果', 'price' => 19.9, 'category' => 'fruit' ]; $indexName = 'new_index'; $params = [ 'index' => $indexName, 'id' => $docItem['id'], 'body' => $docItem ]; // 是不是很簡單 主要是構造一些引數 $client->index($params);
同樣可以對插入操作進行封裝並放在 ES 物件中:
/** * 插入文件 * * @param string $indexName * @param int $id * @param array $insertData * @return array|callable */ public function insert(string $indexName, int $id, array $insertData) { $params = [ 'index' => $indexName, 'id' => $id, 'body' => $insertData ]; return $this->client->index($params); }
封裝後就可以通過面向物件的方式呼叫,即資料和操作相分離:
$client = ElasticsearchObj::getInstance(); // 通過直接插入資料,生成一條全新的index $docItem = [ 'id' => 10, 'name' => '紅富士蘋果', 'price' => 19.9, 'category' => 'fruit' ]; $indexName = 'new_index'; $client->insert($indexName, $docItem['id'], $docItem);
直接在src目錄下執行phpindex.php即可。
如果沒有報錯的話,現在通過配置一下Kibana就可以看到剛剛新增的資料。
Mappings
Mapping類似與資料庫中表的定義,指定了欄位的型別與其它資訊。
但至此並沒有設定任何Mapping。
前面說過 ES 會預設推算欄位型別,並且可以在Kibana上檢視到。
為了方便快捷,可以參考自動生成的Mapping,在這個基礎上修改欄位型別,至於有哪些型別可以網上查一下;
不僅需要知道欄位有哪些型別還需要知道tokenizers&analyzer&filter三者的區別:
Tokenizers分詞器
分詞器可以按照我們的設定將文字進行拆分,打散。
TokenFilters字元過濾器
前者打散後的字元稱為 token,tokenfilters即進一步過濾,比如統一轉大寫,轉小寫。
Analyzer分析器
即分詞器與字元過濾器的組合,通過分析器可以應用在 elasticsearch 欄位上;
elasticsearch預設自帶了很多的分析器但是對中文的拆分都不是很好,前面安裝的ik對中文支援就非常好。
通過Kibana可以測試分析器對文字應用的效果:
詳細的內容還可以看下 官方文件
知道了這些概念後就可以迴歸程式碼了,對於 ES 的每個索引來說就和 MySQL 中的表一樣。
為了能合理存放這些索引屬性資訊,將每個索引資訊分別對應存放在一個物件例項中並通過介面約束例項的方法。
後面使用時只需面向介面程式設計,不用考慮實際用了哪個索引。
說了這麼多,直接看程式碼吧:
// 新建介面 interface IndexInterface { /** * 獲取索引名稱 * * @return mixed */ public function getIndexName(): string; /** * 獲取屬性資訊 * * @return mixed */ public function getProperties(): array; /** * 獲取索引上的分詞設定 * * @return mixed */ public function getSettings(): array; } // 實現介面填充介面方法 class ItemsIndex implements IndexInterface { public static $name = 'new_index'; // 前面說到的分詞設定 private static $settings = [ 'analysis' => [ 'filter' => [ // 這裡 key 是自定義名稱 'word_synonym' => [ // 同義詞過濾 'type' => 'synonym', 'synonyms_path' => 'synonyms.txt', ], ], // 前面說到的分析器 'analyzer' => [ // 這裡 key 是自定義名稱 'ik_max_word_synonym' => [ // 分詞器 這裡用了ik分詞器,其它的一些用法可以去ik github 上看下 'tokenizer' => 'ik_max_word', // 用到了上面我們自定義的過濾器 'filter' => 'word_synonym', ], ] ] ]; /** * 對應名稱 * @return string */ public function getIndexName(): string { return self::$name; } /** * ES 欄位MAPPING * @return array */ public function getProperties(): array { // 這裡就是按照es自動生成的json改改 return [ 'id' => [ 'type' => 'long' ], 'name' => [ 'type' => 'text', 'analyzer' => 'ik_max_word',// 儲存時用上的analyzer 'search_analyzer' => 'ik_max_word_synonym',// 搜尋時用上上面自定義的analyzer 'fields' => [ // 定義了最大長度 'keyword' => [ 'type' => 'keyword', 'ignore_above' => 256 ] ] ], 'price' => [ 'type' => 'float' ], 'category' => [ 'type' => 'keyword' ], ]; } /** * 分詞庫設定 * @return array */ public function getSettings(): array { return self::$settings; } }
好了,現在已經定義好了 Mapping 的程式碼結構,但是要注意的是欄位的 Mapping 一旦設定好了是不能重新修改的,只能刪了再重新設定。
至於原因是修改欄位的型別會導致ES索引失效,如果實在需要修改需要通過 Reindex 重建索引,這個需要使用時看下就可以了。
雖然還沒用上這個 Mapping 但後續只要接上就可以使用了,再整理一下程式碼對應的目錄結構:
index 目錄中存放所有索引資訊;
Config.php用於存放配置資訊;
ElasticsearchObj.php目前用於獲取客戶端例項以及耦合了插入方法,如果操作方法太多這裡可以進行功能性抽離;
index.php場景類方便測試呼叫寫的程式碼。
基本操作
現在開始嘗試更新索引並完善其它索引操作
之前都是將客戶端操作封裝到ElasticsearchObj物件中,但索引的操作很多的話ElasticsearchObj就會越來越臃腫
在ElasticsearchObj中新增一個獲取客戶端例項的方法方便在其它類中呼叫客戶端例項:
/** * 獲取ES客戶端例項 * * @return Client */ public function getElasticsearchClint(): Client { return $this->client; } // 可以通過鏈式方法獲取到客戶端例項 $client = ElasticsearchObj::getInstance()->getElasticsearchClint();
上面在說 Mapping 時就已經將獲取索引方法抽象為介面,這裡只要面向介面程式設計即可。
其餘的操作都大同小異這裡不再多說,都是拼湊出陣列引數傳給 ES 客戶端。
class ElasticsearchIndex { private $client; public function __construct() { $this->client = ElasticsearchObj::getInstance()->getElasticsearchClint(); } /** * 建立索引 * * @param IndexInterface $index * @return array */ public function createIndex(IndexInterface $index): array { $config = [ 'index' => $index->getIndexName(), // 索引名 'body' => [ 'settings' => $index->getSettings() ?: [], // mappings 對應的欄位屬性 & 詳細欄位的分詞規則 'mappings' => [ 'properties' => $index->getProperties(), ] ] ]; return $this->client->indices()->create($config); } }
寫好的程式碼當然要拉出來溜溜,現在如果直接執行的話會報resource_already_exists_exception因為上面已經建立過這個索引,這裡直接去Kibana刪除即可。
在開發時碰到錯誤是不能避免的,但只要耐心看下錯誤提示的意思或者網上查下往往都能找到問題所在。
現在還可以完善一些對文件的增刪改操作,對於文件來說相當於資料庫的行。
更新與新增操作是可以通過 ID 確定文件的唯一性,同時在通過 PHP 操作時可以公用一個方法。
可以看到每次文件資料的重建,資料的版本都會增一。
下面再新增一些刪除方法即可完成增刪改操作:
/** * 刪除文件 * * @param $index * @param $id * @return array|callable */ public function delete($index, $id) { $params = [ 'index' => $index, 'id' => $id ]; return $this->client->delete($params); } /** * 通過ID列表刪除文件 * * @param $index * @param $idList * @return array|callable */ public function deleteByIdList($index, $idList) { $indexParams = [ 'index' => $index, ]; $this->client->indices()->open($indexParams); $params = [ 'body' => [] ]; foreach ($idList as $deleteId) { $params['body'][] = [ 'delete' => [ '_index' => $index, '_id' => $deleteId ] ]; } return $this->client->bulk($params); }
基本操作
前面的內容完成後其實已經可以自由的對es進行文件的操作了。
是不是還挺簡單的,後面的查詢操作其實大致也是組合引數再進行查詢。
但ES的查詢是可以巢狀的,用起來十分靈活。
在寫程式碼之前最少要知道一些必要的基礎概念:
match
會先將要查詢的內容分詞處理,分詞處理後再進行搜尋查詢返回。
match_all
查詢所有,等於資料庫中where後面沒有條件。
term
精準查詢,不會將查詢的內容分詞處理,直接使用查詢的內容進行搜尋查詢返回。
match_phrase
同樣會分詞處理但分詞的詞彙必須要都匹配上才返回。
詳細搜尋的內容可以檢視 深入搜尋
查詢條件組合
must
所有的語句都必須(must)匹配,與AND等價。
should
至少有一個語句要匹配,與OR等價。
must_not
所有的語句都不能(mustnot)匹配,與NOT等價。
詳細檢視 組合過濾器
在 kibana 中查詢內容
在kibana上可以在DevTools中嘗試使用上述內容進行查詢,可以執行示例程式碼中的插入資料後嘗試查詢:
# 查詢ID為10的文件 GET /new_index/_search { "query": { "bool": { "must": { "match": { "id": 10 } } } } } # 查詢價格低於二十的文件 GET /new_index/_search { "query": { "bool": { "must": { "range": { "price": { "lt": 20 } } } } } } # 價格低於30的肉類 GET /new_index/_search { "query": { "bool": { "must": [ { "match": { "category": "meat" } }, { "range": { "price": { "lt": 30 } } } ] } } } # 火腿腸或者價格低於十元 GET /new_index/_search { "query": { "bool": { "should": [ { "match": { "name": "火腿腸" } }, { "range": { "price": { "lt": 10 } } } ] } } }
查詢功能程式碼
通過上面內容可以發現搜尋的組合是十分靈活的,如果每個業務場景的都要通過拼接陣列再去用客戶端查詢,程式碼將會十分複雜(想想會有很多ifelse並且不同的場景還不一樣)。
所以能不能封裝一層,將生成組合條件陣列的部分抽離出來,通過鏈式呼叫構造查詢,保證業務程式碼和通用程式碼相分離。
// 類似這樣的查詢 $where = ['name' => '火腿腸']; $list = $model->where($where)->query();
在做這件事之前首先介紹elastica這個PHP包,通過包中的方法可以生成查詢陣列。
後來寫完後我翻了一下elastica的程式碼,發現elastica不僅可以生成條件陣列而且覆蓋了對es操作的大部分操作,這個可以後面直接使用這個包來實現一下應該也會很棒。
這裡我只是用來生成陣列引數來使用了,整個過程也和上述的操作很像,拼湊出一個數組引數,將陣列作為引數進行傳遞。
只要將這個陣列作為類的成員變數,通過不同的方法不斷的給陣列中新增內容,這樣就給鏈式呼叫的實現帶來了可能。
創造類
前面已經將不同的索引通過面向介面方式實現出來了,再通過構造注入方式將例項注入到類中。
下面的程式碼通過鏈式呼叫實現了一些類似分頁這樣基礎的功能。
class ElasticModelService { private $client; private $index; private $condition; private $search; private $fields; public function __construct(IndexInterface $index) { $this->client = ElasticsearchObj::getInstance()->getElasticsearchClint(); $this->setIndex($index); $this->initModel(); return $this; } /** * 初始化索引模型 * * @throws \Exception */ private function initModel() { // 重置條件 $this->reset(); // 索引名 $this->search['index'] = $this->index->getAliasName(); // fields $mapping = $this->index->getProperties(); $this->fields = array_keys($mapping); } /** * 重置查詢 * * @return $this */ public function reset(): ElasticModelService { $this->condition = []; $this->search = []; return $this; } /** * 設定過濾引數 * * @param array $fields * @return $this */ public function fields(array $fields): ElasticModelService { if (!empty($fields)) { $this->search['body']['_source'] = $fields; } return $this; } /** * 分頁查詢引數 * * @param int $page * @param int $pageSize * @return $this */ public function pagination(int $page, int $pageSize): ElasticModelService { $this->search['size'] = $pageSize; $fromNum = ($page - 1) * $pageSize; $this->setFrom((int)$fromNum); return $this; } /** * 設定開始查詢位置 * * @param int $from * @return $this */ public function setFrom(int $from): ElasticModelService { $this->search['from'] = $from; return $this; } /** * 設定查詢大小 * * @param int $size * @return $this */ public function setSize(int $size): ElasticModelService { $this->search['size'] = $size; return $this; } /** * 設定索引名 * * @param IndexInterface $index */ private function setIndex(IndexInterface $index) { $this->index = $index; } }
在上面的基礎上可以嘗試寫一些簡單的查詢構造方法在類中,如下面程式碼片段:
// 傳入 ['name' => '火腿腸'],返回物件方便後面再次用鏈式呼叫 public function where(array $where): ElasticModelService { // 遍歷條件陣列 foreach ($where as $field => $value) { // 利用 elastica 包生成查詢陣列 if (is_numeric($value)) { $query = new Term(); $query->setTerm($field, $value); $match = $query->toArray(); } else { $matchQuery = new MatchPhrase(); $match = $matchQuery->setFieldQuery($field, $value)->toArray(); } if ($match) { // 更改類中成員變數的資料 $this->condition['must'][] = $match; } } return $this; }
這樣實現了簡單版的where構造方法只要認真看下程式碼應該不難理解,但後面再加上一些其它操作方法的程式碼量會累積的很多。
準備進一步拆分,將能夠複用的部分程式碼拆成一部分,根據不同的需要呼叫這些方法。
並且在where方法中加上一些相容處理。
public function where(array $where): ElasticModelService { foreach ($where as $field => $value) { $realField = $this->getRealField($field); if (in_array($realField, $this->fields)) { $match = $this->getFilterMatch($field, $value); if ($match) { $this->condition['must'][] = $match; } } } return $this; } // 加上一些增加功能如可以傳 ['id|in' => [1,2,3,4]] 或者 ['date|gt' => '2022-01-01'] public function getRealField(string $field): string { $tempField = $field; if (strpos($field, '|') !== false) { $fields = explode('|', $field); $tempField = (string)$fields[0]; } return $tempField; } public function getFilterMatch($field, $value) { if (strpos($field, '|') !== false) { // 範圍搜尋 $rangeField = explode('|', $field); if (count($rangeField) != 2) { return false; } switch (strtolower($rangeField[1])) { case 'in': return $this->_getMatch($rangeField[0], $value); case 'notin': return $this->_getMatch($rangeField[0], $value,'must_not'); default: return $this->_getRangeMatch($rangeField[0], $rangeField[1], $value); } } else { // 等值查詢 return $this->_getMatch($field, $value); } } private function _getMatch($field, $value, string $operate = 'should'): array { $match = []; if (is_array($value)) { $matchQuery = new MatchQuery(); foreach ($value as $valId) { $match['bool'][$operate][] = $matchQuery->setFieldQuery($field, $valId)->toArray(); } if ($operate == 'should') { $match['bool']['minimum_should_match'] = 1; } } else { if (is_numeric($value)) { $query = new Term(); $query->setTerm($field, $value); $match = $query->toArray(); } else { $matchQuery = new MatchPhrase(); $match = $matchQuery->setFieldQuery($field, $value)->toArray(); } } return $match; } private function _getRangeMatch($field, $operator, $value): array { $range = new Range(); $range->addField($field, [$operator => $value]); $match = []; $match['bool']['must'] = $range->toArray(); return $match; }
拆分後代碼雖然看起來變更多了,但程式碼的功能和複用性也增強了。
很容易發現一些基礎的方法可以使用Trait集中起來以此提高可讀性。
其它的功能這裡也不再贅述可以看下整體程式碼。
測試呼叫
雖然看起來還有很多可以優化的地方,但至少一個簡易的es操作程式碼就完成了。
先跑起來測試一下。
$itemsIndex = new ItemsIndex(); $itemModel = new ElasticModelService($itemsIndex); $queryList = $itemModel->where(['id' => 11])->fields(['name', 'id', 'price'])->query(); var_dump($queryList);
文件之間的關聯
在實際使用時可能還會出現類似資料庫連表的場景,但這並不是 ES 的強項。
這時需要了解巢狀型別nested或者父子文件組合。
nested 是文件中巢狀文件,而父子文件通過 index 之間進行關聯。
因為父子文件的效能問題,建議非要使用的話就使用 nested。
詳情可以檢視文件。
並且 ES 對於 nested 查詢是有單獨的語法,這個還需要單獨處理。