Laravel5.5 使用 Elasticsearch 做引擎,scout 全文搜索
背景
最近几个项目要实现全文搜索功能,所以学习了一下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上。这样会减少我们数据不同步问题。
搜索例子。
<?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":"顺义石门地铁"
}
}
]
}
}
- postman
注意,我们用请求方式是post,Headers里要传值,不然会报错。json数据在body.raw下传。
总结
以前很少写博客,以后会坚持写下去。有什么错误,希望看到的同学帮我指出来。谢谢!
本作品采用《CC 协议》,转载必须注明作者和本文链接
推荐文章: