概述

Laravel Scout 为 Eloquent 模型全文搜索实现提供了简单的、基于驱动的解决方案。通过使用模型观察者,Scout 会自动同步更新模型记录的索引,非常方便,易于上手,学院的文章搜索功能正好可以通过它来实现。

Laravel Scout 基于模型 + 底层搜索驱动扩展包来实现模型的全文搜索,目前,Scout 默认通过 Algolia 驱动提供搜索功能,不过,编写自定义驱动很简单,我们可以很轻松地通过自己的搜索实现来扩展 Scout。Algolia 毕竟是收费 API,而且是国外的服务,国内访问速度和可用性上不能保证,所以很自然被略过,接下来的选择就是自己搭建搜索引擎了,中文搜索有多种解决方案,比如轻量级的迅搜(xunsearch)、coreseek(sphinx变种,支持中文搜索),适用于中小型应用,还有适用于大型应用的 Elasticsearch。

因为之前项目用到过ElasticSearch所以一时激动直接就安装上了,无奈忽略了服务器内存,一但启动,整个服务器负载瞬间挤满,所以碍于模糊搜索不准确以及服务器不够给力(主要原因当然是穷 5 5 5~ ~ ~)我们改用xunsearch完成相关功能。坑蛮多的,这边也寻找到了不少轻量化的全文搜索,大家可以选个一样替换 1.meilisearch 官网 2.sonic 仓库地址 3.zinc 仓库地址 4.redisSearch仓库 5.TntSearch相关导航

安装迅搜服务端

  • 1.下载迅搜(个人放在了usr/loacl文件夹下)

wget http://www.xunsearch.com/download/xunsearch-full-latest.tar.bz2

  • 2.解压文件(这里遇到了报错,但因为用宝塔的原因所以跳过命令直接右键解压了)

tar -xjf xunsearch-full-latest.tar.bz2 xunsearch

  • 3.进入迅搜所在文件夹

cd xunsearch/

  • 4.启动迅搜安装程序

sudo sh setup.sh

到这里我们就安装完了迅搜

file

  • 5.进入安装后的文件夹(上图中已有安装路径)

cd /usr/local/xunsearch

  • 6.启动迅搜服务

bin/xs-ctl.sh restart

出现以下提示则代表已经完成启动

file

安装相关PHP扩展包

  • 1.首先安装xunsearch扩展包

composer require hightman/xunsearch

  • 2.然后安装Scout扩展包(因为项目laravel版本为5.7最新的scout要laravel8以上所以我这里需要在后面追加 "*")

composer require laravel/scout

  • 3.用过laravel的大概都知道下一个流程了,发布配置文件到config

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

  • 4.进入config文件夹在scout.php文件中添加xunsearch相关配置
'xunsearch' => [
    'host' => env('XUNSEARCH_HOST', '127.0.0.1'),
]

如图所示

file

  • 5.接下来要修改.env中的相关配置: (这里通过 Laravel Horizon 实现队列系统,关于这方面的内容请移步对应文档查看,这里不再单独介绍。)
SCOUT_DRIVER=xunsearch
XUNSEARCH_HOST=迅搜服务端IP地址
SCOUT_PREFIX=academy_
SCOUT_QUEUE=true
  • 6.因为是线上直接修改,线上项目中有缓存配置,我们要清理重置
php artisan config:clear
php artisan config:cache
  • 7.目前作为示例只用于文章索引索引在对应文章模型中添加引用项(在class下面引用)

use Searchable;

索引配置文件

由于只对文章搜索,所以只要为其定义相应的索引配置文件即可,在config目录下创建xs_articles.ini


project.name = env('APP_NAME')
project.default_charset = utf-8
; 索引服务端配置,默认值为 8383
server.index = 8383
; 搜索服务端配置,默认值为 8384
server.search = 8384

[id]
type = id

[title]
type = title
tokenizer = scws([3])

[content]
type = body

关于字段索引的相关配置参考官方网站->迅搜官方文档

编写Scout扩展类

要实现基于迅搜驱动的搜索功能,还需要为其编写 Scout 扩展 XunSearchEnginge: 该文件位于App\Services(没有则新建)

<?php

namespace App\Services;

use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Laravel\Scout\Builder;

class SearchEngine
{
    protected $xs;

    public function __construct(\XS $xs)
    {
        $this->xs = $xs;
    }

    /**
     * 更新给定模型索引
     *
     * @param \Illuminate\Database\Eloquent\Collection $models
     * @return void
     * @throws \XSException
     */
    public function update($models)
    {
        if ($models->isEmpty()) {
            return;
        }

        // if ($this->usesSoftDelete($models->first()) && config('scout.soft_delete', false)) {
        //     $models->each->pushSoftDeleteMetadata();
        // }
        Log::info('Update Index');
        $index = $this->xs->index;
        $models->map(function ($model) use ($index) {
            $array = $model->toSearchableArray();
            if (empty($array)) {
                return;
            }
            $doc = new \XSDocument;
            $data = [
                'id' => $model->id,
                'filename' => $model->filename,
                'share_user' => $model->share_user,
            ];
            $doc->setFields($data);
            $index->update($doc);
        });
        $index->flushIndex();
    }

    /**
     * 从索引中移除给定模型
     *
     * @param \Illuminate\Database\Eloquent\Collection $models
     * @return void
     * @throws \XSException
     */
    public function delete($models)
    {
        $index = $this->xs->index;
        $models->map(function ($model) use ($index) {
            Log::info('Deleted:' . $model->getKey());
            $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('id')->values();
    }

    /**
     * 将搜索结果和模型实例映射起来
     *
     * @param Builder $builder
     * @param \Illuminate\Database\Eloquent\Model $model
     * @param mixed $results
     * @return \Illuminate\Database\Eloquent\Collection
     */
    public function map(Builder $builder, $results, $model)
    {
        if (count($results) === 0) {
            return Collection::make();
        }

        $keys = collect($results)
            ->pluck('id')->values()->unique()->all();
        $models = $model->getScoutModelsByIds($builder, $keys)->keyBy($model->getKeyName());
        return Collection::make($results)->map(function ($hit) use ($model, $models) {
            $key = $hit['id'];
            if (isset($models[$key])) {
                return $models[$key];
            }
        })->filter();
    }

    /**
     * 返回搜索结果总数
     *
     * @param mixed $results
     * @return int
     */
    public function getTotalCount($results)
    {
        return ceil($this->xs->search->getLastCount() / 2);
    }

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

需要修改$data下的内容为你的索引配置项

//需要修改的内容
$data = [
    'id' => $model->id,
    'filename' => $model->filename,
    'share_user' => $model->share_user,
];

以上代码包含搜索、索引构建、删除、分页等所有功能,接下来需要做的就是将其绑定到 Scout 扩展中,我们可以通过在 AppServiceProvider(app/Providers) 的 boot 方法中添加以下代码来实现:

// 注册新的搜索引擎
resolve(EngineManager::class)->extend('xunsearch', function ($app) {
    $xs = new \XS(config_path('xs_disk.ini'));
    return new SearchEngine($xs);
});

搜索语句示例

$disks = Disk::search($request->keyword)->paginate(20);

配置完我的没有生效,官方文档也比较混乱所以将config文件夹下的xs_disk.ini复制到了xunsearch的php目录(具体路径为/usr/local/xunsearch/sdk/php/util)然后通过以下语句导入

/usr/local/xunsearch/sdk/php/util/index.php --rebuild --source=mysql://数据库用户名:数据库密码@数据库地址(我的在本地所以默认localhost)/数据库名 --sql="select * from 表名" --project=配置文件名(我的是xs_disk.ini)