說明
本文記錄了如何使用 PHP 簡單操作 Elasticsearch(ES),適合對 ES 沒有一定了解的讀者。本文使用本地 Docker 環境進行開發,並提供了示例代碼。
示例代碼已上傳至 碼雲,歡迎查看。
搭建開發環境
為了方便讀者,本文將使用 Docker 搭建開發環境,並以 PHP 7.2 為例。通過本文的指導,讀者可以輕松地完成環境搭建,進而進行 Elasticsearch 相關的開發工作。
第一步拉取鏡像文件:
拉取 Elasticsearch 鏡像,這一步需要先在機器中安裝好 docker 並在終端中輸入以下命令:
docker pull elasticsearch:7.7.1
在執行完拉取鏡像命令后可能會報錯或者是下載速度過慢的情況,可以在網上查找切換 docker 源來解決。
同樣在終端拉取 Kibana 鏡像:
docker pull kibana:7.7.1
拉取好鏡像后,您可以通過實例化容器來運行它們,使得 Elasticsearch 和 Kibana 服務可以運行起來並且相互連接。
首先您需要啟動 Elasticsearch 容器,並且將本地目錄 /Users/fangaolin/www/es_plugin 映射到容器目錄 /www/es_plugin 上。請注意,/Users/fangaolin/www/es_plugin 是本地電腦上的目錄,您需要將其替換為您自己的目錄。
這個目錄是用來存放我們的 Elasticsearch 插件用的。
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 容器,並指定 Elasticsearch 服務地址為 http://host.docker.internal:9200/ 這樣能讓我們的 Kibana 訪問 Elasticsearch。
docker run -d --name kibana -e ELASTICSEARCH_HOSTS=http://host.docker.internal:9200/ -p 5601:5601 kibana:7.7.1
當 Elasticsearch 和 Kibana 服務啟動之后,我們需要安裝分詞擴展。Elasticsearch 自帶的默認分詞擴展對中文的處理效果不是很好,因此我們需要安裝 IK Analyzer 擴展來提高中文分詞的處理效果。
首先進入剛剛啟動的 Elasticsearch 容器,在終端輸入:
docker exec -it es /bin/sh
這是一個在 Docker 容器中執行命令的命令,其中:
- docker exec 是執行 Docker 容器中的命令的命令;
- -it 是告訴 Docker,讓命令在一個交互式的終端里執行;
- es 是容器的名稱或 ID;
- /bin/sh 是在容器中要執行的命令。
該命令將啟動一個新的終端會話,該會話與名為 es 的容器關聯,該終端會話將運行一個交互式的 shell(/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
同時需要新建一個同義詞的文件,這個文件是用來管理我們的同義詞。這里我們用 vi 來新建一個文件。
vi /usr/share/elasticsearch/config/synonyms.txt
進入 vi 編輯器后,您可以通過輸入 :wq 命令來保存並退出。如果您嘗試使用該命令卻沒有反應,可能需要您檢查一下您的輸入法是否處於英文狀態。另外,您可以直接輸入 i 進入編輯模式,然后保存並退出編輯器。
:wq
完成以上步驟后,您已經成功安裝了 IK Analyzer 擴展。您可以使用 exit 命令退出容器並返回到宿主機。
exit
之后我們再次開始練習就可以直接啟動容器就可以,啟動容器時使用 docker start [CONTAINER NAME] 命令,如: docker start es 同時這里也列舉了常見的 docker 命令:
- 啟動 Docker 容器:
docker start <container_name> - 停止 Docker 容器:
docker stop <container_name> - 重啟 Docker 容器:
docker restart <container_name> - 查看 Docker 容器狀態:
docker ps或docker container ls - 查看所有 Docker 容器:
docker ps -a或docker container ls -a - 進入 Docker 容器:
docker exec -it <container_name> /bin/bash - 刪除 Docker 容器:
docker rm <container_name>或docker container rm <container_name> - 刪除 Docker 鏡像:
docker rmi <image_name>或docker image rm <image_name> - 構建 Docker 鏡像:
docker build -t <image_name> . - 拉取 Docker 鏡像:
docker pull <image_name> - 推送 Docker 鏡像:
docker push <image_name>
此時你可以在瀏覽器中訪問 http://localhost:5601/ 來進入 Kibana 后台,以及訪問 http://localhost:9200/ 來查看 Elasticsearch 的信息。請注意,這兩個服務啟動需要一定的時間,如果沒有立即看到結果,請多刷新幾次。需要注意的是,我們實際生成的信息都存儲在 Elasticsearch 中,而 Kibana 是一個可視化工具,可以幫助我們更方便地查看和分析這些數據,類似於 Navicat 和 MySQL 的關系。
初始化 PHP 環境
開始編寫 PHP 代碼,可以在本地新建一個目錄並通過終端進入該目錄進行操作。
// 初始化項目的composer 一路默認即可 composer init // 引入包 composer require elasticsearch/elasticsearch 7.11.0 composer require ruflin/elastica 7.1
首先需要用上 PHP elasticsearch/elasticsearch 這個包,安裝時要注意和 Elasticsearch 的版本相對應;
如果直接用我上傳在碼雲的項目只需在項目根目錄下執行 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 一般默認是打開 Dynamic Mapping 的,即 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 目錄下執行 php index.php 即可。
如果沒有報錯的話,現在通過配置一下 Kibana 就可以看到剛剛添加的數據。



Mappings
Mapping 類似與數據庫中表的定義,指定了字段的類型與其它信息。
但至此並沒有設置任何 Mapping。
前面說過 ES 會默認推算字段類型,並且可以在 Kibana 上查看到。

為了方便快捷,可以參考自動生成的 Mapping,在這個基礎上修改字段類型,至於有哪些類型可以網上查一下;
不僅需要知道字段有哪些類型還需要知道 tokenizers & analyzer & filter 三者的區別:
Tokenizers 分詞器
分詞器可以按照我們的設定將文本進行拆分,打散。
Token Filters 字符過濾器
前者打散后的字符稱為 token,token filters 即進一步過濾,比如統一轉大寫,轉小寫。
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
所有的語句都 不能(must not) 匹配,與 NOT 等價。
詳細查看 組合過濾器
在 kibana 中查詢內容
在 kibana 上可以在 Dev Tools 中嘗試使用上述內容進行查詢,可以執行示例代碼中的插入數據后嘗試查詢:
# 查詢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 } } } ] } } }
查詢功能代碼
通過上面內容可以發現搜索的組合是十分靈活的,如果每個業務場景的都要通過拼接數組再去用客戶端查詢,代碼將會十分復雜(想想會有很多 if else 並且不同的場景還不一樣)。
所以能不能封裝一層,將生成組合條件數組的部分抽離出來,通過鏈式調用構造查詢,保證業務代碼和通用代碼相分離。
// 類似這樣的查詢 $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 查詢是有單獨的語法,這個還需要單獨處理。
