Laravel5.5 使用 Elasticsearch 做引擎,scout 全文搜尋

沙漠行者發表於2018-11-27

背景

最近幾個專案要實現全文搜尋功能,所以學習了一下elasticsearch的使用和使用過程中遇到的一些坑。自己做一總結,幫助自己複習一下知識,希望能幫助那些也是剛剛開始學習es的同學。大神繞道!
專案框架是:laravel 5.5 
引     擎:elasticsearch
全文搜尋包:scout

準備工作

1.下載一個laravel 5.5框架。
2.安裝執行es 連結地址,點開連結,根據自己的系統下載安裝包,裡面關於怎麼樣安裝執行都說的比較清楚。我用的是mac系統。下載後解壓。

$ cd elasticsearch-6.4.2 //進入到解壓目錄
$ ./bin/elasticsearch //本地執行es

開始

1.進入專案目錄。

$ cd estest

2.安裝Laravel scout 全文搜尋包,這裡我用的是5.0版本,tamayo/laravel-scout-elastic
用的是4.0版本。這倆個包的版本號是有對應關係的,但是我沒有找到對照表,只是安
裝的時候實驗出來的。

$ composer require laravel/scout=5.0

3.註冊服務提供器,你需要將 ScoutServiceProvider 新增到你的配置檔案 config/app.php 的 providers 陣列中。

'providers' => [
    ...
    Laravel\Scout\ScoutServiceProvider::class,
],

4.生成配置檔案。註冊好 Scout 的服務提供器之後,你還需使用Artisan 命令
vendor:publish 生成 Scout 的配置檔案。這個命令會在你的 config 目錄下
生成 scout.php 配置檔案。

$ php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"

//Laravel 5.5 其實我們不用這麼麻煩!直接執行如下命令。這條命令會給你一個list,讓你選擇publish哪個選項。

$ php artisan vendor:publish

5.因為要使用es做搜尋引擎,所以我們要用到一個叫tamayo/laravel-scout-elastic的包。

 $ composer require tamayo/laravel-scout-elastic=4.0

6.新增服務提供器到config/app.php的providers陣列中。

// config/app.php
'providers' => [
    ...
    ScoutEngines\Elasticsearch\ElasticsearchProvider::class,
],

7.配置。在config/scout.php檔案中新增如下程式碼。預設使用的是algolia引擎,我們要使用es做引擎。

...
'algolia' => [
    'id' => env('ALGOLIA_APP_ID', ''),
    'secret' => env('ALGOLIA_SECRET', ''),
],
//這裡是新增的程式碼
'elasticsearch' => [
        'index' => env('ELASTICSEARCH_INDEX', 'laravel'),
        'hosts' => [
            env('ELASTICSEARCH_HOST', 'http://127.0.0.1:9200'),
        ],
 ],

8.配置.env檔案,新增如下程式碼。

# scout配置
SCOUT_DRIVER=elasticsearch  //選擇搜尋引擎
SCOUT_PREFIX=

# elasticsearch 配置
ELASTICSEARCH_INDEX=estest  //設定索引
# elasticsearch伺服器地址
ELASTICSEARCH_HOST=http://127.0.0.1:9200  //我用的就是本地的

先別急去實現搜尋功能,先來學習幾個基本的概念

  • Cluster :叢集。可以理解為一個或者多個伺服器的集合。用來儲存我們們的資料的。群集由唯一名稱標識,預設情況下為“elasticsearch”。
  • Node :節點。是叢集中單個的伺服器。本例子中我的伺服器就是本地的127.0.0.1,它就是一個節點。
  • Index:索引。可以理解為msyql中的一個資料庫,索引由名稱標識(必須全部小寫)。
  • Type:型別。可以理解為msyql中的一個表。注意:6.0版本前可以有多個型別。6.0以後的版本已經棄用。一個index下只能有一個type。這個地方當初沒有看明白,我專案中好幾個model模型都要做全文搜尋。所以在每一個model中都定義了一個type。查詢自然是不能成功。所以是一個小坑。希望讀到的人不要重複這樣的錯誤。也就是說我們把要做全文搜尋的欄位存進es中一個資料庫名字叫index,資料表名字叫type的表中。不管你要查詢的欄位在哪個model模型中。
  • Document:文件。可以理解為一條資料。

在專案中實現搜尋功能

進入專案目錄,建立倆個測試model,
$ cd estest  
$ php artisan make:model Models/User
$ php artisan make:model Models/Address

開啟Models/User.php,進行設定type,和你要搜尋對欄位。

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;//這個trait一定要引用的

class User extends Model
{
    use Searchable;
    protected $table = 'user';
    protected $fileable = ['name', 'email', 'phone'];
    // 定義索引裡面的型別,上文我們說過,可以把type理解成一個資料表。我們現在要做的就是把我們所有的要全文搜尋的欄位都存入到es中的一個叫'_doc'的表中。  
    public function searchableAs()  
    {  
        return '_doc';  
    }  
    // 定義有那些欄位需要搜尋  
    public function toSearchableArray()  
    {  
        return [  
            'user_name' => $this->name,  //user_name加上字首以區別。因為不同的表裡可能會有相同的欄位。mysql中的欄位是name,email,created_at。在es中我們儲存的user_name,user_email,user_created_at。是可以自定義的。
            'user_email' => $this->email,  
            'user_created_at' => $this->created_at,  
        ];  
    }  
}

Address.php中也是這樣使用。

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;

class Address extends Model
{
    use Searchable;
    protected $table = 'address';
    protected $fillable = ['home', 'company'];
    public function searchableAs()
    {
        return '_doc';
    }
    public function toSearchableArray()
    {
        return [
            'address_home' => $this->home,
            'address_company' => $this->company,
            'address_created_at' => $this->created_at,
        ];
    }
}

searchableAs(), toSearchableArray(),這倆個方法在Searchable這個trait裡,有興趣的同學可以去看一下原始碼。現在我們可以去實現搜尋功能了,但是我們的es中還沒有資料。所以要把我們mysql中資料同步到es中。注意:很多時候我們會用視覺化工具操作我們的資料表。這樣手動增加的資料是不會自動同步到es中的,所以如果你用搜尋查詢的資料和你在mysql中的資料不一致的問題,大多都是你的資料沒有達到同步。

//把現有的資料同步es中一個索引叫‘estest’,型別叫‘_doc’
php artisan scout:import "App\Models\User"//把User中到資料同步到es中
php artisan scout:import "App\Models\Address" //把Address中資料同步到es中
//如果你已經做過同步了,然後你不小心手動刪除或者增加了mysql中到資料,那麼你要清空一下es的資料,再從新匯入資料。
php artisan scout:flush "App\Models\User"
php artisan scout:flush "App\Models\Address"
php artisan scout:import "App\Models\User"
php artisan scout:import "App\Models\Address"
//如果這樣你的資料也還是有問題。那麼就要建議你手動刪除一下es的索引,然後再從新匯入資料。一開始做測試的時候,可以匯入資料,成功以後個人不建議再匯入資料。我們可以用官網上的儲存,刪除,更新。。。。讓資料自動同步到es上。這樣會減少我們資料不同步問題。

Laravel-scout官網連結

搜尋例子。

<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\User;
use App\Models\Address;

class PostsController extends Controller
{
    public function test(Request $requst)
    {
        $content = $request->content;
        $list = User::search($content)->where('query', ['*user_name*', '*user_email*'])->orderBy('user_created_at.date.keyword', 'desc')->paginate(20)->toArray();
        $res = Address::search($content)->where('query', ['*address_home*', '*address_company*'])->orderBy('address_created_at.date.keyword', 'desc')->paginate(20)->toArray();
    }   
}

現在可以用Model 直接呼叫search(‘$string’)方法,$string是你要搜尋到內容。這樣可以實現搜尋功能。但是我們的專案需要一般都是要用created_at做排序的,如果我們要用這個欄位去做排序,那麼就把這個欄位也要存入到es中。拿User來舉例,我們要做全文搜尋到欄位是‘user_name’,‘user_email’,但是要用‘user_created_at’排序。這樣我們搜一個字串,沒有匹配到user_name,user_email,但是卻匹配到user_created_at,這與我們的需求不符。所以我修改了一下原始碼。但是這裡我對原始碼的理解不是很深,所以沒有辦法詳細的解說怎麼回事。我把我改過的原始碼貼出來。以後理解了我會在寫到這裡。暫時實現了功能。

修改/vendor/tamayo/laravel-scout-elastic/src/ElasticsearchEngine.php

<?php

namespace ScoutEngines\Elasticsearch;

use Laravel\Scout\Builder;
use Laravel\Scout\Engines\Engine;
use Elasticsearch\Client as Elastic;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as BaseCollection;

class ElasticsearchEngine extends Engine
{
    /**
     * Index where the models will be saved.
     *
     * @var string
     */
    protected $index;

    /**
     * Elastic where the instance of Elastic|\Elasticsearch\Client is stored.
     *
     * @var object
     */
    protected $elastic;

    /**
     * Create a new engine instance.
     *
     * @param  \Elasticsearch\Client  $elastic
     * @return void
     */
    public function __construct(Elastic $elastic, $index)
    {
        $this->elastic = $elastic;
        $this->index = $index;
    }

    /**
     * Update the given model in the index.
     *
     * @param  Collection  $models
     * @return void
     */
    public function update($models)
    {
        $params['body'] = [];

        $models->each(function($model) use (&$params)
        {
            $params['body'][] = [
                'update' => [
                    '_id' => $model->getKey(),
                    '_index' => $this->index,
                    '_type' => $model->searchableAs(),
                ]
            ];
            $params['body'][] = [
                'doc' => $model->toSearchableArray(),
                'doc_as_upsert' => true
            ];
        });

        $this->elastic->bulk($params);
    }

    /**
     * Remove the given model from the index.
     *
     * @param  Collection  $models
     * @return void
     */
    public function delete($models)
    {
        $params['body'] = [];

        $models->each(function($model) use (&$params)
        {
            $params['body'][] = [
                'delete' => [
                    '_id' => $model->getKey(),
                    '_index' => $this->index,
                    '_type' => $model->searchableAs(),
                ]
            ];
        });

        $this->elastic->bulk($params);
    }

    /**
     * Perform the given search on the engine.
     *
     * @param  Builder  $builder
     * @return mixed
     */
    public function search(Builder $builder)
    {
        return $this->performSearch($builder, array_filter([
            'numericFilters' => $this->filters($builder),
            'size' => $builder->limit,
        ]));
    }

    /**
     * Perform the given search on the engine.
     *
     * @param  Builder  $builder
     * @param  int  $perPage
     * @param  int  $page
     * @return mixed
     */
    public function paginate(Builder $builder, $perPage, $page)
    {
        $result = $this->performSearch($builder, [
            'numericFilters' => $this->filters($builder),
            'from' => (($page * $perPage) - $perPage),
            'size' => $perPage,
        ]);

       $result['nbPages'] = $result['hits']['total']/$perPage;

        return $result;
    }

    /**
     * Perform the given search on the engine.
     *
     * @param  Builder  $builder
     * @param  array  $options
     * @return mixed
     */
    protected function performSearch(Builder $builder, array $options = [])
    {
        $params = [
            'index' => $this->index,
            'type' => $builder->index ?: $builder->model->searchableAs(),
            'body' => [
                'query' => [
                    'bool' => [
                        'must' => [['query_string' => [ 'query' => "*{$builder->query}*"]]]
                    ]
                ]
            ]
        ];

        if ($sort = $this->sort($builder)) {
            $params['body']['sort'] = $sort;
        }

        if (isset($options['from'])) {
            $params['body']['from'] = $options['from'];
        }

        if (isset($options['size'])) {
            $params['body']['size'] = $options['size'];
        }
        // if (isset($options['numericFilters']) && count($options['numericFilters'])) {
        //     $params['body']['query']['bool']['must'] = array_merge($params['body']['query']['bool']['must'],
        //         $options['numericFilters']);
        // }
        //這裡是修改的地方,組合成我們想要的查詢語句
        if(isset($options['numericFilters'][0]['query_string'])) {
            $params['body']['query']['bool']['must'][0]['query_string']['fields'] = $options['numericFilters'][0]['query_string'];
        } else {
            $params['body']['query']['bool']['must'] = array_merge($params['body']['query']['bool']['must'],
                $options['numericFilters']);
        }
        if ($builder->callback) {
            return call_user_func(
                $builder->callback,
                $this->elastic,
                $builder->query,
                $params
            );
        }

        return $this->elastic->search($params);
    }

    /**
     * Get the filter array for the query.
     *
     * @param  Builder  $builder
     * @return array
     */
    protected function filters(Builder $builder)
    {
        return collect($builder->wheres)->map(function ($value, $key) {
            if (is_array($value) && $key != 'query') {
                return ['terms' => [$key => $value]];
            }
            //這裡是修改的地方,$key = 'query',$value =['欄位1','欄位2']。 就是這裡的where('query', ['欄位1','欄位2'])。
            if ($key == 'query') {
                return ['query_string' => $value];
            }
            return ['match_phrase' => [$key => $value]];
        })->values()->all();
    }

    /**
     * Pluck and return the primary keys of the given results.
     *
     * @param  mixed  $results
     * @return \Illuminate\Support\Collection
     */
    public function mapIds($results)
    {
        return collect($results['hits']['hits'])->pluck('_id')->values();
    }

    /**
     * Map the given results to instances of the given model.
     *
     * @param  \Laravel\Scout\Builder  $builder
     * @param  mixed  $results
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @return Collection
     */
    public function map(Builder $builder, $results, $model)
    {
        if ($results['hits']['total'] === 0) {
            return Collection::make();
        }

        $keys = collect($results['hits']['hits'])
                        ->pluck('_id')->values()->all();

        $models = $model->getScoutModelsByIds(
            $builder, $keys
        )->keyBy(function ($model) {
            return $model->getScoutKey();
        });

        return collect($results['hits']['hits'])->map(function ($hit) use ($model, $models) {
            return isset($models[$hit['_id']]) ? $models[$hit['_id']] : null;
        })->filter()->values();
    }

    /**
     * Get the total count from a raw result returned by the engine.
     *
     * @param  mixed  $results
     * @return int
     */
    public function getTotalCount($results)
    {
        return $results['hits']['total'];
    }

    /**
     * Generates the sort if theres any.
     *
     * @param  Builder $builder
     * @return array|null
     */
    protected function sort($builder)
    {
        if (count($builder->orders) == 0) {
            return null;
        }

        return collect($builder->orders)->map(function($order) {
            return [$order['column'] => $order['direction']];
        })->toArray();
    }
}

修改/vendor/laravel/scout/src/Searchable.php

 public function getScoutModelsByIds(Builder $builder, array $ids)
{
    $query = in_array(SoftDeletes::class, class_uses_recursive($this))
                    ? $this->withTrashed() : $this->newQuery();
    //把這行程式碼註釋掉,不然會報錯:Undefined property: Laravel\Scout\Builder::$queryCallback
    // if ($builder->queryCallback) {
    //     call_user_func($builder->queryCallback, $query);
    // }

    return $query->whereIn(
        $this->getScoutKeyName(), $ids
    )->get();
}

其實這修改的原始碼沒有那麼神祕,它只不過是對es官網的查詢介面做的封裝,我們只是把能實現需求的查詢語句組合替換掉原來的查詢語句。所以要想真的明白,還要多看es官網,雖然英文讓人痛疼。程式碼在vendor/tamayo/laravel_scout_elastic/src/ElasticsearchEngine.php裡面 。下面就是es給出的查詢語句。我們在本地測試可以有2種方式。

1. curl
curl -X POST "localhost:9200/estest/_search" -H 'Content-Type: application/json' -d'
{
    "query": {
        "bool" : {
            "must" : {
                "query_string" : {
                    "fields" : ["user_name", "user_email"],
                    "query" : "*新*"
                }
            }
        }
    }
}'
//結果
{
    "took":12,
    "timed_out":false,
    "_shards":{
        "total":5,
        "successful":5,
        "skipped":0,
        "failed":0
    },
    "hits":{
        "total":1,
        "max_score":1,
        "hits":[
            {
                "_index":"estest",
                "_type":"_doc",
                "_id":"1",
                "_score":1,
                "_source":{
                    "user_name":"新超",
                    "user_email":"1046072048@qq.com",
                    "user_created_at":{
                        "date":"2018-11-15 09:10:40.000000",
                        "timezone_type":3,
                        "timezone":"UTC"
                    },
                    "address_home":"望京酒仙橋",
                    "address_created_at":{
                        "date":"2018-11-15 12:22:53.000000",
                        "timezone":"UTC",
                        "timezone_type":3
                    },
                    "address_company":"順義石門地鐵"
                }
            }
        ]
    }
}
  1. postman

    注意,我們用請求方式是post,Headers裡要傳值,不然會報錯。json資料在body.raw下傳。

總結

以前很少寫部落格,以後會堅持寫下去。有什麼錯誤,希望看到的同學幫我指出來。謝謝!
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章