Laravel Scout 和 Meilisearch 入门
发布时间 作者 Steve McDougall
我们之前都遇到过需要在应用中添加搜索功能的情况,大多数时候我们会选择 Algolia 和 Laravel Scout - 因为它们开箱即用且能够取得很好的效果。但现在出现了一个(相对)新的竞争者,meilisearch。Meilisearch 在功能上与 Algolia 很相似,但它是一个使用 Rust 编程语言构建的开源项目。因此,你可以免费在本地运行它,或者在生产环境中使用 Laravel Forge 等工具启动一个服务器。
本教程将指导你如何使用 Meilisearch 和 Laravel Scout 入门,以便你了解它们之间的设置差异 - 然后做出选择。
我们从一个新的 Laravel 应用开始,我通常使用 Laravel 安装程序,因为我在本地大量使用 Valet - 但本教程应该适用于 Valet 和 Docker。
运行以下命令之一,为本演示创建一个新应用
laravel new search-demo
使用 Laravel 安装程序
composer create-project laravel/laravel search-demo
使用 Composer create-project
curl -s "https://laravel.build/search-demo" | bash
使用 Laravel build 和 Sail
无论你选择以上哪种方式,你都会在名为 search-demo
的新目录下获得一个 Laravel 项目,这意味着我们已经准备好了。
composer require laravel/scout
第一步,我们要 安装 Laravel Scout,运行以下 Composer 命令
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
这会将 Scout 安装到我们的 Laravel 应用中,这样我们就可以开始与可能用到的任何搜索驱动程序进行交互。下一步是发布 Laravel Scout 的配置,运行以下 Artisan 命令
这样我们就完成了配置,可以根据需要修改新创建的 config/scout.php
文件,但你可能希望保持其标准设置。
- 此时,我们有一些驱动程序选项。本教程将介绍如何使用 Meilisearch,但 Laravel Scout 还提供了以下驱动程序选项:
- Algolia:使用第三方 Algolia 服务。
- Meilisearch:使用开源 Meilisearch 服务。
- Collection:使用数据库作为搜索服务 - 仅支持 MySQL 和 PostgreSQL。
null:不使用驱动程序 - 通常用于测试。
composer require meilisearch/meilisearch-php
要开始使用 Meilisearch 驱动程序,我们需要安装一个新包,它将允许 Scout 使用 Meilisearch SDK,因此运行以下 Composer 命令
SCOUT_DRIVER=meilisearchMEILISEARCH_HOST=http://127.0.0.1:7700MEILISEARCH_KEY=masterKey
Laravel 文档中指出你还需要安装 http-interop/http-factory-guzzle
,但是如果你查看 Meilisearch-PHP 库,它现在已包含在该依赖项中。因此,我们可以跳过这一步,或者如果你觉得更方便,也可以安装它。下一步是在 .env
文件中设置一些环境变量
MEILISEARCH_KEY
环境变量很有意思,如果你在本地安装 Meilisearch,则可以在启动服务时传递一个可选标志来设置它。在生产环境中,你需要确保将其设置为安全措施,但在本地,如果你愿意,可以将其保留为空。我个人建议设置它,因为这是一个好习惯,它会提醒我需要设置它。
我们已经安装了 Laravel Scout,并且已经安装并配置了 Meilisearch 客户端。下一步是考虑数据,就像所有好的应用一样 - 它需要数据。在本演示中,我们将使用一个相当基本的示例,这样我们就可以专注于 Meilisearch 和 Scout 主题,而不会在演示代码逻辑中迷失方向。这将是一个简单的博客应用,其中包含博客文章和类别,这样我们就可以索引所需的所有内容。
php artisan make:model Category -mf
运行以下 Artisan 命令,创建一个名为 Category
的新 Eloquent 模型,请注意额外的标志,它们将用于创建迁移和工厂,这里很重要
public function up(){ Schema::create('categories', static function (Blueprint $table): void { $table->id(); $table->string('name'); $table->string('slug')->unique(); $table->boolean('searchable')->default(true); $table->timestamps(); });}
我们的 Category 将是一个相对较轻的模型,因此我将向你展示迁移代码,并让你自己处理模型,使用 fillable
或 guarded
属性,具体取决于你的个人偏好。
class CategoryFactory extends Factory{ protected $model = Category::class; public function definition(): array { $name = $this->faker->unique()->word(); return [ 'name' => $name, 'slug' => Str::slug( title: $name, ), 'searchable' => $this->faker->boolean( chanceOfGettingTrue: 85, ), ]; } public function searchable(): static { return $this->state(fn (array $attributes): array => [ 'searchable' => true, ]); } public function nonsearchable(): static { return $this->state(fn (array $attributes): array => [ 'searchable' => false, ]); }}
这里我们有一个名称、一个 slug 和一个可搜索的布尔标志。这让我们可以完全隐藏某些类别,使其不参与搜索 - 有时这可能会有用。根据你的习惯填写 Eloquent 模型,下一步是创建模型工厂
我们让类别可搜索的默认可能性很高,但我们还添加了一些额外的状态方法,以便在测试时控制这种可能性。这为我们提供了最全面的测试覆盖范围。
php artisan make:model Post -mf
接下来,我们需要另一个名为 Post
的模型,它将是我们搜索的主要入口点,因此运行以下 Artisan 命令
public function up(): void{ Schema::create('posts', static function (Blueprint $table): void { $table->id(); $table->string('title'); $table->string('slug')->unique(); $table->mediumText('content'); $table->boolean('published')->default(true); $table ->foreignId('category_id') ->index()->constrained()->cascadeOnDelete(); $table->timestamps(); });}
同样,我将向你展示迁移,并让你自己处理模型的 fillable
或 guarded
属性 - 因为这完全取决于个人偏好。
class PostFactory extends Factory{ protected $model = Post::class; public function definition(): array { $title = $this->faker->unique()->sentence(); return [ 'title' => $title, 'slug' => Str::slug( title: $title, ), 'content' => $this->faker->paragraph(), 'published' => $this->faker->boolean( chanceOfGettingTrue: 85, ), 'category_id' => Category::factory(), ]; } public function published(): static { return $this->state(fn (array $attributes): array => [ 'published' => true, ]); } public function draft(): static { return $this->state(fn (array $attributes): array => [ 'published' => false, ]); }}
下一步是填写 Post 模型的工厂
与 Category 模型一样,我们也有一个布尔标志 - 但这一次它是用于指示模型是否已发布 - 这样我们就可以拥有草稿状态。我们在工厂中添加了额外的状态方法,以便在测试环境中对其进行良好的控制。
最后,你可以在模型中添加关系,Category 模型应该 HasMany
Posts,而 Post 模型应该 BelongsTo
Category。
现在我们的数据已经全部建模并准备好了,我们需要播种一些数据。但在播种之前,我们需要安装 Meilisearch。如果你使用的是 Laravel Sail,这很简单,你只需在告诉 Sail 安装时传递一个选项即可 - 然而,在 Laravel Valet 中情况有所不同。安装说明可以在 Meilisearch 文档 中找到,操作起来相对容易,如果遇到任何问题,请确保检查本地运行 Meilisearch 的要求。
class DatabaseSeeder extends Seeder{ use WithoutModelEvents; public function run(): void { $categories = Category::factory(10)->create(); $categories->each(function (Category $category) { $this->command->getOutput()->info( message: "Creating posts for category: [$category->name]", ); $bar = $this->command->getOutput()->createProgressBar(100); for ($i = 0; $i < 100; $i++) { $bar->advance(); Post::factory()->create(); } $bar->finish(); }); }}
假设你已经启动并运行了 Meilisearch,让我们看看如何播种数据。我将在播种程序中添加一个进度条,这样我就能知道它是否正常工作,但如果你不想添加,可以跳过这一步
现在我不想让播种有任何副作用,因为我希望控制这种行为 - 所以,我使用了 WithoutModelEvents
特性来阻止这些行为。我们在这里创建了 10 个 Category,然后为每个 Category 创建一个进度条,并为其创建 100 个 Post。这在运行播种程序时提供了可视化输出,并确保每个 Category 都有 Post - 这样,当我们搜索时,就可以看到可用的内容。
class Post extends Model{ use Searchable; use HasFactory; // Other model stuff here ...}
现在我们已经有了一些数据,我们可以开始让 Post 模型可搜索了。要做到这一点,我们只需将 Laravel Scout 中的 Searchable
特性添加到模型中即可
class Post extends Model{ use Searchable; use HasFactory; protected $with = [ 'category' ]; // Other model stuff here ...}
现在我们的模型可搜索了,我们可以开始在模型中添加一些控制,以控制我们希望它如何被搜索。在几乎 99% 的情况下,我需要使用 Post 模型,也希望使用 Category - 所以,我将告诉 Eloquent 模型始终加载 Category 模型。
class Post extends Model{ use Searchable; use HasFactory; protected $with = [ 'category' ]; public function searchable(): bool { return $this->published || $this->category->searchable; } // Other model stuff here ...}
现在,我们可以添加一个新方法,允许 Laravel Scout 检查模型是否可以被搜索或添加到索引中
class Post extends Model{ use Searchable; use HasFactory; protected $with = [ 'category' ]; public function searchable(): bool { return $this->published || $this->category->searchable; } public function toSearchableArray(): array { return [ 'title' => $this->title, 'slug' => $this->slug, 'content' => $this->content, 'category' => [ 'name' => $this->category->name, 'slug' => $this->category->slug, ] ]; } // Other model stuff here ...}
我们希望在每个帖子中添加类别信息,以便我们可以在 UI 上正确地显示帖子本身的信息,例如 "帖子标题(类别名称)" 之类的东西。
最后,我们得到了一些可以索引和搜索的东西,所以让我们将所有 Post 记录导入 meilisearch。
php artisan scout:import "App\Models\Post"
这将显示将 500 条记录的块添加到 scout 的输出。所以现在我们有了可以搜索的东西,我们需要考虑如何搜索。在使用 Scout 时,您可以使用模型上的静态 search 方法进行简单搜索——您将查询传递给它,它将返回已填充的模型,或者您可以开始查看过滤器等等。所以让我们看看控制器中的基本搜索,并从那里进行重构。
class SearchController extends Controller{ public function __invoke(Request $request): JsonResponse { return new JsonResponse( data: Post::search( query: trim($request->get('search')) ?? '', )->get(), status: Response::HTTP_OK, ); }}
现在,让我们在我们的 api 路由下注册这条路由,以便我们可以在不创建 UI 的情况下查看结果。
Route::get( 'search', App\Http\Controllers\SearchController::class)->name('search');
现在,我们可以查看基于搜索查询参数的搜索的 JSON 输出,看看并测试它如何响应。尝试针对完整词和部分词进行搜索。这是 Laravel Scout 和 Meilisearch 的基础知识,我们现在可以索引模型并针对它们进行搜索——所以从这个角度来看,我们很好。下一步是考虑如何获得更多内容。
过滤器是很棒的东西,它允许我们通过简单地请求它们,在搜索中获得更有针对性的结果。因此,我们将在 Post 模型中添加一些过滤器,以便我们可以轻松地过滤查询。这是我的方法,但它不必是你的方法,所以请带着一点盐度来看待我要做的事情,并根据自己的需要进行调整。
class Post extends Model{ use Searchable; use HasFactory; protected $with = [ 'category' ]; public function searchable(): bool { return $this->published || $this->category->searchable; } public function toSearchableArray(): array { return [ 'title' => $this->title, 'slug' => $this->slug, 'content' => $this->content, 'category' => [ 'name' => $this->category->name, 'slug' => $this->category->slug, ] ]; } public static function getSearchFilterAttributes(): array { return [ 'category.name', 'category.slug', ]; } // Other model stuff here ...}
我在模型中添加了一个静态函数来定义搜索过滤器属性,正如你所看到的,我希望能够按类别名称或 slug 进行过滤。下一步是创建一个命令来将这些可过滤属性注册到 meilisearch。我通常创建一个控制台命令来执行此操作,因为 scout 默认情况下没有方法来执行此操作。
php artisan make:command Search/SetupSearchFilters
然后添加以下代码片段。
class SetupSearchFilters extends Command{ protected $signature = 'scout:filters {index : The index you want to work with.} '; protected $description = 'Register filters against a search index.'; public function handle(Client $client): int { $index = $this->argument( key: 'index', ); $model = match($index) { 'posts' => Post::class, }; try { $this->info( string: "Updating filterable attributes for [$model] on index [$index]", ); $client->index( uid: $index, )->updateFilterableAttributes( filterableAttributes: $model::getSearchFilterAttributes(), ); } catch (ApiException $exception) { $this->warn( string: $exception->getMessage(), ); return self::FAILURE; } return 0; }}
我们在这里做的是传递一个索引,以防我们将来扩展要索引的内容,然后使用 match/switch 语句将其与模型匹配。然后,由于控制台命令的工作方式,我们可以在 handle 方法中解析 meilisearch 客户端——并使用它来更新索引,同时获取搜索过滤器属性。如果失败,我们将显示异常并返回失败。
现在,我们可以使用以下命令运行它。
php artisan scout:filters 'posts'
如果一切按计划进行,meilisearch 现在将了解索引上可用的过滤器。所以让我们看看是否可以做到?我们将重构 SearchController 以接受过滤器进行搜索。
class SearchController extends Controller{ public function __invoke(Request $request): JsonResponse { return new JsonResponse( data: Post::search( query: $request->get('search'), callback: function (Indexes $meilisearch, string $query, array $options) use ($request) { if ($request->has(key: 'category.slug')) { $options['filter'] = "category.slug = {$request->get(key: 'category.slug')}"; } return $meilisearch->search( query: $query, options: $options, ); }, )->get(), status: Response::HTTP_OK, ); }}
现在,如果你在搜索中添加另一个查询参数 category.slug={something}
,那么你应该会获得你正在执行的搜索的过滤结果,我的当前结果看起来像:/api/search?search=rem&category.slug=voluptatibus
,它很好地过滤了结果。你可以根据需要尽可能多地扩展这些,包括类别名称的过滤器,甚至更多,具体取决于你选择如何建模数据。你甚至可以创建过滤器来根据时间进行过滤,如果你需要的话。
这只是将 Laravel Scout 集成到你的应用程序中并使用过滤器进行微调的一种方式,如果你需要的话。Laravel Scout 可以使用许多驱动程序,并且创建自己的驱动程序并非不可能——事实上,如果它们适合你的用例,你已经可以使用几个开源驱动程序了!
你是如何处理应用程序的搜索的?你尝试过 meilisearch 吗?在推特上告诉我们,并让我们知道你对这篇文章的看法!