基于迅搜(xunsearch) + Laravel Scout 实现 Laravel 学院全文搜索功能(支持多模型搜索)
概述
Laravel Scout 为 Eloquent 模型全文搜索实现提供了简单的、基于驱动的解决方案。通过使用模型观察者,Scout 会自动同步更新模型记录的索引,非常方便,易于上手,学院的文章搜索功能正好可以通过它来实现。
Laravel Scout 基于模型 + 底层搜索驱动扩展包来实现模型的全文搜索,目前,Scout 默认通过 Algolia 驱动提供搜索功能,不过,编写自定义驱动很简单,我们可以很轻松地通过自己的搜索实现来扩展 Scout。Algolia 毕竟是收费 API,而且是国外的服务,国内访问速度和可用性上不能保证,所以很自然被略过,接下来的选择就是自己搭建搜索引擎了,中文搜索有多种解决方案,比如轻量级的迅搜(xunsearch)、coreseek(sphinx变种,支持中文搜索),适用于中小型应用,还有适用于大型应用的 Elasticsearch。
对于学院的规模来说使用迅搜就够了,简单易上手,只需少许步骤就可以快速搭建其自己的搜索引擎,而且它们的客户中就有国内著名的编程社区 segmentfault,有这样的背书也可以让我们放心使用。
安装迅搜服务端
在服务器上安装迅搜很简单,只需以下几步即可:
wget http://www.xunsearch.com/download/xunsearch-full-latest.tar.bz2
tar -xjf xunsearch-full-latest.tar.bz2 xunsearch
cd xunsearch/
sudo sh setup.sh
安装完成后,通过以下命令启动:
sudo bin/xs-ctl.sh start
以上命令默认在本地回环地址(127.0.0.1)8383/8384上监听服务,如果你有多台机器需要访问迅搜服务端,需要通过以下命令启动:
bin/xs-ctl.sh -b inet start
以上过程没有报错,就意味着迅搜已经正常启动了。
如果通过 Docker 启动迅搜服务的话,对应 Dockerfile 如下:
# xunsearch-dev docker
# created by hightman.20150826
#
# START COMMAND:
# docker run -d --name xunsearch -p 8383:8383 -p 8384:8384 \
# -v /var/xunsearch/data:/usr/local/xunsearch/data hightman/xunsearch:latest
#
FROM ubuntu:14.04
MAINTAINER hightman, hightman@twomice.net
# Install required packages
RUN apt-get update -qq
RUN apt-get install -qy --no-install-recommends \
wget make gcc g++ bzip2 zlib1g-dev
# Download & Install xunsearch-latest
RUN cd /root && wget -qO - http://www.xunsearch.com/download/xunsearch-full-latest.tar.bz2 | tar xj
RUN cd /root/xunsearch-full-* && sh setup.sh --prefix=/usr/local/xunsearch
RUN echo '' >> /usr/local/xunsearch/bin/xs-ctl.sh \
&& echo 'tail -f /dev/null' >> /usr/local/xunsearch/bin/xs-ctl.sh
# Configure it
VOLUME /usr/local/xunsearch/data
EXPOSE 8383
EXPOSE 8384
WORKDIR /usr/local/xunsearch
RUN echo "#!/bin/sh" > bin/xs-docker.sh \
&& echo "rm -f tmp/pid.*" >> bin/xs-docker.sh \
&& echo "echo -n > tmp/docker.log" >> bin/xs-docker.sh \
&& echo "bin/xs-indexd -l tmp/docker.log -k start" >> bin/xs-docker.sh \
&& echo "sleep 1" >> bin/xs-docker.sh \
&& echo "bin/xs-searchd -l tmp/docker.log -k start" >> bin/xs-docker.sh \
&& echo "sleep 1" >> bin/xs-docker.sh \
&& echo "tail -f tmp/docker.log" >> bin/xs-docker.sh
ENTRYPOINT ["sh"]
CMD ["bin/xs-docker.sh"]
安装相关 PHP 扩展包
首先通过 Composer 安装 xunsearch
扩展包:
composer require hightman/xunsearch
安装完迅搜扩展包后,在 Laravel 中使用 Scout 也需要安装对应扩展包:
composer require laravel/scout
将配置文件 scout.php
发布到 config
目录下:
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
在 scout.php
中新增 xunsearch
相关配置:
'xunsearch' => [
'host' => env('XUNSEARCH_HOST', '127.0.0.1'),
]
接下来需要修改 .env
中的相关配置:
SCOUT_DRIVER=xunsearch
XUNSEARCH_HOST=迅搜服务端IP地址
SCOUT_PREFIX=academy_
SCOUT_QUEUE=true
注意到我们将 SCOUT_DRIVER
改成了 xunsearch
,XUNSEARCH_HOST
必须与你安装迅搜所在的服务器IP一致,最后我们将索引构建设置为通过队列异步执行,学院君通过 Laravel Horizon 实现队列系统,关于这方面的内容请移步对应文档查看,这里不再单独介绍。
学院暂时只支持文章搜索,所以需要为对应模型中添加如下代码以支持自动更新索引和搜索:
use Searchable;
索引配置文件
由于我们只是对学院文章进行搜索,所以只要为其定义相应的索引配置文件即可,在 config
目录下创建xs_article.ini
:
project.name = academy_article
project.default_charset = utf-8
server.index = xunsearch服务端IP:8383 // 不配置的话默认为127.0.0.1:8383
server.search = xunsearch服务端IP:8384 // 不配置的话默认为127.0.0.1:8384
[pid]
type = id
[title]
type = title
[summary]
[content]
type = body
[tag_text]
type = both
[category_id]
type = numeric
index = self
[author]
index = both
[author_id]
type = numeric
[view_count]
type = numeric
[vote_count]
type = numeric
[comment_count]
type = numeric
[publish_time]
索引哪些字段由你自己决定,这里只是个参考,关于字段明细介绍,请参考迅搜官方文档,这里不在赘述,要想了解迅搜搜索引擎工作流程和原理,请务必先仔细阅读一遍迅搜官方文档。
编写迅搜 Scout 扩展类
要实现基于迅搜驱动的搜索功能,还需要为其编写 Scout 扩展 XunSearchEnginge
:
<?php
namespace App\Services\SearchEngine;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Builder;
use Laravel\Scout\Engines\Engine;
class XunSearchEngine extends Engine
{
/**
* @var \XS
*/
protected $xs;
public function __construct(\XS $xs)
{
$this->xs = $xs;
}
/**
* 更新给定模型索引
*
* @param \Illuminate\Database\Eloquent\Collection $models
* @return void
*/
public function update($models)
{
if ($models->isEmpty()) {
return;
}
if ($this->usesSoftDelete($models->first()) && config('scout.soft_delete', false)) {
$models->each->pushSoftDeleteMetadata();
}
$index = $this->xs->index;
$models->map(function ($model) use ($index) {
$array = $model->toSearchableArray();
if (empty($array)) {
return;
}
$doc = new \XSDocument;
$data = [
'pid' => $model->id,
'title' => $model->title,
'summary' => $model->summary,
'content' => $model->content,
'tag_text' => $model->tag_text,
'category_id' => $model->category_id,
'author' => $model->author->name,
'author_id' => $model->user_id,
'view_count' => $model->view_count,
'vote_count' => $model->vote_count,
'comment_count' => $model->comment_count,
'publish_time' => $model->posted_at
];
$doc->setFields($data);
$index->update($doc);
});
$index->flushIndex();
}
/**
* 从索引中移除给定模型
*
* @param \Illuminate\Database\Eloquent\Collection $models
* @return void
*/
public function delete($models)
{
$index = $this->xs->index;
$models->map(function ($model) use ($index) {
$index->del($model->getKey());
});
$index->flushIndex();
}
/**
* 通过迅搜引擎执行搜索
*
* @param \Laravel\Scout\Builder $builder
* @return mixed
*/
public function search(Builder $builder)
{
return $this->performSearch($builder, array_filter(['hitsPerPage' => $builder->limit]));
}
/**
* 分页实现
*
* @param \Laravel\Scout\Builder $builder
* @param int $perPage
* @param int $page
* @return mixed
*/
public function paginate(Builder $builder, $perPage, $page)
{
return $this->performSearch($builder, [
'hitsPerPage' => $perPage,
'page' => $page - 1,
]);
}
/**
* 返回给定搜索结果的主键
*
* @param mixed $results
* @return \Illuminate\Support\Collection
*/
public function mapIds($results)
{
return collect($results)
->pluck('pid')->values();
}
/**
* 将搜索结果和模型实例映射起来
*
* @param mixed $results
* @param \Illuminate\Database\Eloquent\Model $model
* @return \Illuminate\Database\Eloquent\Collection
*/
public function map($results, $model)
{
if (count($results) === 0) {
return Collection::make();
}
$keys = collect($results)
->pluck('pid')->values()->all();
$models = $model->getScoutModelsByIds($keys)->keyBy($model->getKeyName());
return Collection::make($results)->map(function ($hit) use ($model, $models) {
$key = $hit['pid'];
if (isset($models[$key])) {
return $models[$key];
}
})->filter();
}
/**
* 返回搜索结果总数
*
* @param mixed $results
* @return int
*/
public function getTotalCount($results)
{
return $this->xs->search->getLastCount();
}
protected function usesSoftDelete($model)
{
return in_array(SoftDeletes::class, class_uses_recursive($model));
}
// 执行搜索功能
protected function performSearch(Builder $builder, array $options = [])
{
$search = $this->xs->search;
if ($builder->callback) {
return call_user_func(
$builder->callback,
$search,
$builder->query,
$options
);
}
$search->setFuzzy()->setQuery($builder->query);
collect($builder->wheres)->map(function ($value, $key) use ($search) {
$search->addRange($key, $value, $value);
});
$offset = 0;
$perPage = $options['hitsPerPage'];
if (!empty($options['page'])) {
$offset = $perPage * $options['page'];
}
return $search->setLimit($perPage, $offset)->search();
}
/**
* 获取中文分词
* @param $text
* @return array
*/
public function getScwsWords($text)
{
$tokenizer = new \XSTokenizerScws();
return $tokenizer->getResult($text);
}
}
以上代码包含搜索、索引构建、删除、分页等所有功能,接下来需要做的就是将其绑定到 Scout 扩展中,我们可以通过在 AppServiceProvider
的 boot
方法中添加以下代码来实现:
// 注册新的搜索引擎
resolve(EngineManager::class)->extend('xunsearch', function ($app) {
$xs = new \XS(config_path('xs_article.ini'));
return new XunSearchEngine($xs);
});
演示搜索功能
完成以上所有工作后,就可以在更新/新增文章模型后对其进行搜索了,更新/新增模型后可以在 Horizon 后台看到队列中的索引更新/新增记录:
队列任务执行完成后,就可以通过搜索框进行搜索了,执行搜索的代码实现也很简单:
$keyword = $request->get('keyword');
$page = $request->get('page') ? : 1;
$pageSize = $request->get('page_size') ? : 10;
$articles = Article::search($keyword)->paginate($pageSize, 'page', $page);
以上是一个分页搜索,比如我们搜索「Laravel学院」,显示结果如下:
多模型搜索支持
=============== 2018.11.04 更新 ===============
上述实现只能对文章进行搜索,并且将索引字段硬编码到引擎类 XunSearchEngine
中,如果后续需要对更多模型进行搜索,比如问答模块要支持搜索功能,现在的实现就不能满足了,比较偷懒的实现是为每个索引生成不同的引擎实例,然后将 XunSearchEngine
类的 update
方法中索引字段同步部分迁移出去。下面给出一个简单的实现示例:
因为学院主要还是文章搜索,所以保留文章搜索引擎 xunsearch
作为主搜索引擎,即默认引擎,所以注册该引擎的地方保持不变,我们在 Article
模型类中定义一个新方法用于将模型字段数据同步到搜索索引字段:
public function searchableIndexData()
{
$indexData = [
'pid' => $this->id,
'title' => $this->title,
'summary' => $this->summary,
'content' => $this->content,
'tag_text' => $this->tag_text,
'category_id' => $this->category_id,
'author' => $this->author->name,
'author_id' => $this->user_id,
'view_count' => $this->view_count,
'vote_count' => $this->vote_count,
'comment_count' => $this->comment_count,
'publish_time' => $this->posted_at
];
return $indexData;
}
然后将 XunSearchEngine
的 update
方法中同步模型数据到索引部分代码修改如下:
$doc = new \XSDocument;
$doc->setFields($model->searchableIndexData());
$index->update($doc);
这样就将这部门硬编码重构出去了,接下来,要实现问答模块搜索,需要在其模型类 Discussion
中使用 Searchable
Trait:
use SoftDeletes, Searchable;
然后在这个模型类中通过重写 Searchable
中的 searchableUsing
方法来定义该模型搜索使用的搜索引擎实例:
public function searchableUsing()
{
$xs = new \XS(config_path('xs_discussion.ini'));
return new XunSearchEngine($xs);
}
我们为 Discussion
模型创建了新的索引配置文件 xs_discussion.ini
,具体配置参考 xs_article.ini
定义即可,这里不再赘述,然后返回新的引擎实例用于问答模型搜索,当然还要在模型类中定义模型字段同步方法:
public function searchableIndexData()
{
$indexData = [
'pid' => $this->id,
'title' => $this->title,
'description' => $this->desscription,
'tag_text' => $this->tag_text,
'category_id' => $this->category_id,
'author' => $this->author->name,
'author_id' => $this->user_id,
'view_count' => $this->view_count,
'comment_count' => $this->comment_count,
'publish_time' => $this->posted_at
];
return $indexData;
}
这样我们就完成了基于迅搜 + Laravel Scout 的多模型搜索功能的实现,在终端运行如下命令初始化问答模型索引数据:
php artisan scout:import "App\Models\Discussion"
这样就可以在应用中通过 Discussion::search('问题描述')->paginate($pageSize, 'page', $page)
进行问答模型的搜索了。
36 Comments
假如有多个配置文件,如何使用?
不同配置文件对应不同实例 使用的时候切换呗
// 注册新的搜索引擎 resolve(EngineManager::class)->extend('xunsearch', function ($app) { $xs = new \XS(config_path('xs_article.ini')); return new XunSearchEngine($xs); }); 注册新的搜索引擎的时候不是把 xs_article.ini 写死在这里了吗?
而且实现引擎类的时候也把update方法的 $data写死了?
要怎么改才能适配不同的配置文件呢
你要脱离我这个实现 我这个只是参考的
有什么教程介绍吗,就是可以适应多个配置文件的方案。万分感谢!!!
我本周写一个吧 正好我要实现问答模块的搜索 借这个机会做下
好,谢谢老铁啦
学院哥,你的编写迅搜 Scout 扩展类 map方法有问题。:getScoutModelsByIds() must be an instance of Laravel\Scout\Builder, array given
做了一个不那么优雅的实现,权当参考吧:https://xueyuanjun.com/post/9485.html#toc_6
谢谢啦