基于迅搜(xunsearch) + Laravel Scout 实现 Laravel 学院全文搜索功能(支持多模型搜索)

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 改成了 xunsearchXUNSEARCH_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 扩展中,我们可以通过在 AppServiceProviderboot 方法中添加以下代码来实现:

// 注册新的搜索引擎
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学院」,显示结果如下:

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;
}

然后将 XunSearchEngineupdate 方法中同步模型数据到索引部分代码修改如下:

$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) 进行问答模型的搜索了。

学院君 has written 1137 articles

Laravel学院院长,终身学习者

积分:144522 等级:P12 职业:手艺人 城市:杭州

31 条回复

  1. ericxilix ericxilix says:
    @ ericxilix

    另外还新增一个abstract public function flush($model);要实现:

    public function flush($model) { $index = $this->xs->index; $index->flushIndex(); }

  2. ericxilix ericxilix says:
    @ 学院君

    报告一个bug. 目前scout 6的map函数如下: abstract public function map(Builder $builder, $results, $model);

    和文章中实现的public function map($results, $model)的入参列表不同, 学院君能否也更新一下?

  3. @ 学院君

    我期望的结果是可以直接通过类似于 Article::search($keyword)->where()->paginate($pageSize, 'page', $page); 或者 Article::search($keyword)->setSort()->paginate($pageSize, 'page', $page); 这样的方法解决,可以不?

登录后才能进行评论,立即登录?