Vyhledávání s Algolia a Laravel Scout

Možná jste si na těchto stránkách všimli vyhledávání. Při psaní textu se postupně prohledávají články a podle klíčového slova se zobrazují ty nejvíce relevantní. Celá funkčnost není nijak složitá a prakticky je postavena na dvou věcech - Algolia a Laravel Scout. V tomto článku si vše vysvětlíme a spojíme je dohromady ve velmi jednoduché aplikaci.

Příprava aplikace

Naše jednoduchá aplikace bude obsahovat články. Pro jednoduchost se zaměřím pouze na čtení záznamů a vyhledávání pomocí služby Algolia, nebudu tedy řešit přidávání, editaci ani mazání článků. V první části si připravíme aplikaci a data, ve druhé části se pak zaměříme na importování dat pomocí Laravel Scout a vyhledávání přes klientskou část.

Jako první si vytvoříme nový Laravel projekt:

laravel new algolia-demo

Po nainstalování si pomocí artisanu vytvoříme model, migraci, controller a factory:

php artisan make:model Article -a 

Tabulka

Dalším krokem je vytvoření databázové struktury a naseedování dat. Vycházím z předpokladu, že máme připojení k databázi již připravenou. Prvně je potřeba upravit si migraci, která se nám vytvořila v database/migrations, název bude podobný jako 2019_09_20_154123_create_articles_table.php.

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateArticlesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('title');
            $table->text('content');
            $table->string('slug');
            $table->unsignedBigInteger('user_id');
            $table->timestamps();

            $table->foreign('user_id')->references('id')->on('users');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('articles');
    }
}

Tabulka articles je velmi jednoduchá a obsahuje název článku, obsah a cizí klíč na uživatele, který článek vytvořil. Kromě toho tabulka obsahuje také sloupec slug, který bude obsahovat název článku převeden do URL formy. Díky tomu pak budeme moct odkazovat na článek pomocí názvu namísto defaultního ID. Na závěr si spustíme migrace:

php artisan migrate

Data

Jakmile máme připravenou tabulku pro články, vytvoříme si i fiktivní data. V již vytvořené factory database/factories/ArticleFactory.php si nasimulujeme, jaké data chceme v jednotlivých sloupcích:

<?php

use App\Article;
use App\User;
use Faker\Generator as Faker;
use Illuminate\Database\Eloquent\Factory;
use Illuminate\Support\Str;

/** @var Factory $factory */
$factory->define(Article::class, function (Faker $faker) {
    $title = $faker->sentence;

    return [
        'title' => $title,
        'content' => $faker->paragraphs(3, true),
        'slug' => Str::slug($title),
        'user_id' => factory(User::class)->create()->id,
    ];
});

Název článku jsme si museli uložit do samostatné proměnné, protože chceme, aby se ze stejného názvu vytvořil i slug, pro který využijeme helper slug(). Obsah bude mít vždy tři paragrafy a druhý parametr v metodě paragraphs() je true, protože potřebujeme, aby návratová hodnota z metody byla string namísto pole. Pro jednoduchost budeme předpokládat, že každý článek napsal někdo jiný a proto pro každý článek se vytvoří i nový uživatel.

Poté, co jsme si připravili faktorku, nám už jen stačí upravit database/seeds/DatabaseSedder.php, aby se vytvořilo např. pět článků:

<?php

use App\Article;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        factory(Article::class, 5)->create();
    }
}

A zavolat příkaz pro naseedování do databáze:

php artisan db:seed

Routy a controller

Když jsme si připravili data a databázi, musíme ještě upravit routy a controller, který již máme vytvořený. Jako první si upravíme routy v routes/web.php:

<?php

use App\Http\Controllers\ArticleController;

Route::get('/', [ArticleController::class, 'index']);
Route::get('/clanek/{article}', [ArticleController::class, 'show']);

První routa bude obsahovat pouze samotný formulář pro vyhledávání článku. Druhá routa pak obsahuje pouze detail článku, na který se přesměruje po kliknutí na vyhledaný článek.

Dále si musíme upravit app/Http/Controllers/ArticleController.php, konkrétně metody index() a show():

use Illuminate\View\View;
use App\Article;

/**
 * @return View
 */
public function index(): View
{
    return view('index');
}

/**
 * @param Article $article
 * @return View
 */
public function show(Article $article): View
{
    return view('detail', ['article' => $article]);
}

Obě metody vracejí pouze pohledy. Metoda show() akorát navíc i proměnnou $article, která obsahuje instanci modelu Article.

Algolia

Algolia je vyhledávácí služba, která nabízí fulltextové vyhledávání s real-time výsledky. Pokud neočekáváte velké množství vyhledávání a máte méně než 10 tisíc záznamů, stačí vám Community verze, která je bezplatná. Abychom mohli Algolii využívat, je potřeba si vytvořit účet. Po přihlášení do Algolie si vytvořte novou aplikaci a v ní nový index (název zvolte podle záznamů, které tam budete mít). Jelikož naše jednoduchá aplikace bude obsahovat články, vytvořil jsem nový index s názvem "articles".

Laravel Scout

Laravel Scout nabízí jednoduché řešení pro přidání fulltextového vyhledávání do Eloquent modelů. Díky model observers (eventy hlídající změny) Scout automaticky synchronizuje indexy s Eloquent záznamy. Protože budeme využívat službu Algolia, můžeme rovnou využít jejich knihovny Scout Extended:

composer require algolia/scout-extended

Po nainstalování Scout Extended si zkopírujeme Scout konfiguraci pomocí artisan příkazu. Tento příkaz zkopíruje konfiguraci do souboru config/scout.php:

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

Dalším krokem je do souboru .env přidat API klíče, díky kterým dokáže naše aplikace komunikovat s Algolia:

ALGOLIA_APP_ID=VaseIdAplikace
ALGOLIA_SECRET=VasAdminApiKlic

Tyto API klíče najdete po přihlášení do vašeho Algolia účtu a po přepnutí do aplikace, kterou jste si tam vytvořili - v levém menu je API keys. ALGOLIA_APP_ID najdete v sekci Application ID. ALGOLIA_SECRET je pak v sekci Admin API Key, kde hodnota je schovaná (tento klíč by jste neměli nikde v aplikaci zobrazovat).

Import dat

Protože naše aplikace bude vyhledávat mezi články, musíme upravit model app/Article.php:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;

class Article extends Model
{
    use Searchable;

    /**
     * Get the route key for the model.
     *
     * @return string
     */
    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}

Jako první si všimněte traitu Searchable. Tento trait poskytuje Laravel Scout a stará se o zavolání eventu při změně záznamu a o synchronizaci s Algolií. Díky tomu více nemusíme řešit přidání dalšího záznamu, editaci či odstranění, protože trait vždy udržuje kontakt se službou a předává jí o tom informace.

Další změnou je metoda getRouteKeyName(). Tuto metodu jsme museli přidat kvůli tomu, abychom při implicitním bindování mohli využívat hodnotu ve sloupci slug. Jinak řečeno, při zavolání url clanek/nejaky-nazev se vyhledá článek podle hodnoty ve sloupci slug. Pokud se slug nenajde, vrátí se automaticky chyba 404. Pokud bychom nepřidali tuto metodu, pak Laravel automaticky považuje za výchozí sloupec id, takže by naše url musely vypadat např. jako clanek/4, což nevypadá moc dobře.

Protože máme již připravené data v databázi, zbývá nám pouze tyto záznamy importovat do Algolie. Tady bych chtěl jen upozornit, že Algolia provádí vyhledávání pouze nad daty, které jsou do ní naimportovány. Protože se však jedná o externí službu, tak nedoporučuji importovat data, která jsou nějak citlivé či je potřeba je mít nějak zabezpečené. V našem případě se však jedná o soubor článků, takže můžeme záznamy do Algolie jednoduše naimportovat pomocí příkazu:

php artisan scout:import

Formulář pro vyhledávání

V ArticleController jsme si pro úvodní stránku s formulářem definovali pohled index.blade.php, který ještě nemáme ale vytvořený. Využijeme tedy výchozí pohled resources/views/welcome.blade.php a přejmenujeme ho na index.blade.php. V něm budeme mít toto:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Algolia search</title>

    <link href="{{ asset('css/algolia.css') }}" rel="stylesheet">
</head>
<body>
<form onsubmit="return false;" class="searchbox">
    <div role="search" class="searchbox__wrapper">
        <input id="search-input" type="search" name="search" placeholder="Vyhledat článek..." autocomplete="off" required="required" class="searchbox__input">
        <button type="submit" class="searchbox__submit">
            <svg class="aa-input-icon" viewBox="0 0 40 40">
                <path d="M26.806 29.012a16.312 16.312 0 0 1-10.427 3.746C7.332 32.758 0 25.425 0 16.378 0 7.334 7.333 0 16.38 0c9.045 0 16.378 7.333 16.378 16.38 0 3.96-1.406 7.593-3.746 10.426L39.547 37.34c.607.608.61 1.59-.004 2.203a1.56 1.56 0 0 1-2.202.004L26.807 29.012zm-10.427.627c7.322 0 13.26-5.938 13.26-13.26 0-7.324-5.938-13.26-13.26-13.26-7.324 0-13.26 5.936-13.26 13.26 0 7.322 5.936 13.26 13.26 13.26z" fill-rule="evenodd"/>
            </svg>
        </button>
        <button type="reset" title="Clear the search query." class="searchbox__reset hide">
            <svg role="img" aria-label="Reset" viewBox="0 0 40 40">
                <path d="M16.228 20L1.886 5.657 0 3.772 3.772 0l1.885 1.886L20 16.228 34.343 1.886 36.228 0 40 3.772l-1.886 1.885L23.772 20l14.342 14.343L40 36.228 36.228 40l-1.885-1.886L20 23.772 5.657 38.114 3.772 40 0 36.228l1.886-1.885L16.228 20z" fill-rule="evenodd"/>
            </svg>
        </button>
    </div>
</form>

<script src="//cdn.jsdelivr.net/algoliasearch/3/algoliasearch.min.js"></script>
<script src="//cdn.jsdelivr.net/autocomplete.js/0/autocomplete.min.js"></script>
<script src="{{ asset('js/algolia.js') }}"></script>

</body>
</html>

Obsah je celkem jednoduchý a myslím, že není potřeba ho nějak moc vysvětlovat. Jedná se o jednoduchý formulář s jedním inputem, na který navážeme autocomplete a vytahování dat z Algolie, kdy využijeme externí knihovny Algolia Search a Autocomplete s javascriptem. Aby formulář nějak vypadal, tak si ho trochu nastylujeme. Vytvoříme si tedy soubor public/css/algolia.css a vložíme do něj tyto styly:

.searchbox {
    display: inline-block;
    position: relative;
    width: 500px;
    height: 37px;
    white-space: nowrap;
    box-sizing: border-box;
    font-size: 13px;
    font-family: arial, sans-serif;
}

.searchbox .algolia-autocomplete {
    display: block;
    height: 100%;
}

.searchbox__wrapper {
    width: 100%;
    height: 100%;
}

.searchbox__input {
    display: inline-block;
    -webkit-transition: box-shadow .4s ease, background .4s ease;
    transition: box-shadow .4s ease, background .4s ease;
    border: 0;
    border-radius: 19px;
    box-shadow: inset 0 0 0 1px #D9D9D9;
    background: #FFFFFF;
    padding: 0;
    padding-right: 30px;
    padding-left: 37px;
    width: 500px;
    height: 100%;
    vertical-align: middle;
    white-space: normal;
    font-size: inherit;
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
}

.searchbox__input::-webkit-search-decoration, .searchbox__input::-webkit-search-cancel-button, .searchbox__input::-webkit-search-results-button, .searchbox__input::-webkit-search-results-decoration {
    display: none;
}

.searchbox__input:hover {
    box-shadow: inset 0 0 0 1px silver;
}

.searchbox__input:focus, .searchbox__input:active {
    outline: 0;
    box-shadow: inset 0 0 0 1px #4098CE;
    background: #FFFFFF;
}

.searchbox__input::-webkit-input-placeholder {
    color: #AAAAAA;
}

.searchbox__input::-moz-placeholder {
    color: #AAAAAA;
}

.searchbox__input:-ms-input-placeholder {
    color: #AAAAAA;
}

.searchbox__input::placeholder {
    color: #AAAAAA;
}

.searchbox__submit {
    position: absolute;
    top: 0;
    right: inherit;
    left: 0;
    margin: 0;
    border: 0;
    border-radius: 18px 0 0 18px;
    background-color: rgba(255, 255, 255, 0);
    padding: 0;
    width: 37px;
    height: 100%;
    vertical-align: middle;
    text-align: center;
    font-size: inherit;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
}

.searchbox__submit::before {
    display: inline-block;
    margin-right: -4px;
    height: 100%;
    vertical-align: middle;
    content: '';
}

.searchbox__submit:hover, .searchbox__submit:active {
    cursor: pointer;
}

.searchbox__submit:focus {
    outline: 0;
}

.searchbox__submit svg {
    width: 17px;
    height: 17px;
    vertical-align: middle;
    fill: #666666;
}

.searchbox__reset {
    position: absolute;
    top: 8px;
    right: 8px;
    margin: 0;
    border: 0;
    background: none;
    cursor: pointer;
    padding: 0;
    font-size: inherit;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
    fill: rgba(0, 0, 0, 0.5);
}

.searchbox__reset.hide {
    display: none;
}

.searchbox__reset:focus {
    outline: 0;
}

.searchbox__reset svg {
    display: block;
    margin: 4px;
    width: 13px;
    height: 13px;
}

.searchbox__input:valid ~ .searchbox__reset {
    display: block;
    -webkit-animation-name: sbx-reset-in;
    animation-name: sbx-reset-in;
    -webkit-animation-duration: .15s;
    animation-duration: .15s;
}

.aa-dropdown-menu {
    position: relative;
    top: -6px;
    border-radius: 3px;
    margin: 6px 0 0;
    padding: 0;
    text-align: left;
    height: auto;
    position: relative;
    background: transparent;
    border: none;
    width: 500px;
    left: 0 !important;
    box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2), 0 2px 3px 0 rgba(0, 0, 0, 0.1);
}

.aa-dropdown-menu:before {
    position: absolute;
    content: '';
    width: 14px;
    height: 14px;
    background: #fff;
    z-index: 0;
    top: -7px;
    border-top: 1px solid #D9D9D9;
    border-right: 1px solid #D9D9D9;
    transform: rotate(-45deg);
    border-radius: 2px;
    z-index: 999;
    display: block;
    left: 24px;
}

.aa-dropdown-menu .aa-suggestions {
    position: relative;
    z-index: 1000;
}

.aa-dropdown-menu [class^="aa-dataset-"] {
    position: relative;
    border: solid 1px #D9D9D9;
    border-radius: 3px;
    overflow: auto;
    padding: 8px 8px 8px;
}

.aa-dropdown-menu * {
    box-sizing: border-box;
}

.aa-suggestion {
    font-size: 1.1em;
    padding: 4px 4px 0;
    display: block;
    width: 100%;
    height: 38px;
    clear: both;
}

.aa-suggestion span {
    white-space: nowrap !important;
    text-overflow: ellipsis;
    overflow: hidden;
    display: block;
    float: left;
    line-height: 2em;
    width: calc(100% - 30px);
}

.aa-suggestion.aa-cursor {
    background: #eee;
}

.aa-suggestion em {
    color: #4098CE;
}

.aa-suggestion img {
    float: left;
    vertical-align: middle;
    height: 30px;
    width: 20px;
    margin-right: 6px;
}

Algolia client

A teď ta nejdůležitější část. Vytvoříme si soubor public/js/algolia.js s tímto obsahem:

var client = algoliasearch("VaseIdAplikace", "VasPublicApiKlic");
var index = client.initIndex('articles');
var myAutocomplete = autocomplete('#search-input', {hint: false}, [
    {
        source: autocomplete.sources.hits(index, {hitsPerPage: 5}),
        displayKey: 'title',
        templates: {
            suggestion: function (suggestion) {
                var sugTemplate = "<span>" + suggestion._highlightResult.title.value + "</span>";
                return sugTemplate;
            },
            empty: function (result) {
                return 'Bohužel jsme nenašli žádný článek pro slovo "' + result.query + '"';
            }
        }
    }
]).on('autocomplete:selected', function (event, suggestion, dataset) {
    window.location.href = window.location.origin + '/clanek/' + suggestion.slug;
});

document.querySelector(".searchbox [type='reset']").addEventListener("click", function () {
    document.querySelector(".aa-input").focus();
    this.classList.add("hide");
    myAutocomplete.autocomplete.setVal("");
});

document.querySelector("#search-input").addEventListener("keyup", function () {
    var searchbox = document.querySelector(".aa-input");
    var reset = document.querySelector(".searchbox [type='reset']");
    if (searchbox.value.length === 0) {
        reset.classList.add("hide");
    } else {
        reset.classList.remove('hide');
    }
});

Kód trochu rozeberu.

var client = algoliasearch("VaseIdAplikace", "VasPublicApiKlic");

Jako první si zavoláme funkci algoliasearch, kde jako první parametr vložíme naše ID aplikace (stejně jako dříve v Laravel Scout). Druhý parametr je pak ze sekce (přihlášení do Algolie -> vybrání naší aplikace -> API Keys) Search-Only API Key. Jak jsem již psal, Admin API key nesmíme nikde klientovi zobrazit, proto by tak získal plný API přístup do naší aplikace. Z toho důvodu Algolia poskytuje veřejný API klíč, který slouží pouze pro vyhledávání.

var index = client.initIndex('articles');

Do funkce initIndex vložíme název indexu, který jsme si vytvořili společně s aplikací v Algolia. V mém případě to bylo articles.

hitsPerPage: 5

Zobrazíme pět článků

templates: {
    suggestion: function (suggestion) {
        var sugTemplate = "<span>" + suggestion._highlightResult.title.value + "</span>";
        return sugTemplate;
    },
    empty: function (result) {
        return 'Bohužel jsme nenašli žádný článek pro slovo "' + result.query + '"';
    }
}

Zde máme možnost upravit si, jaké data budeme v návrzích (po napsání nějakého textu do vyhledávání) zobrazovat. V našem případě nám stačí zobrazit pouze název článku. Samozřejmě zde můžeme zobrazit i další potřebné informace, obrázky či data před zobrazení nějak upravit (např. upravit formát čísla apod.). Callback empty nám pak vrací string s informací v případě, že nebyla nalezena žádná shoda.

.on('autocomplete:selected', function (event, suggestion, dataset) {
    window.location.href = window.location.origin + '/clanek/' + suggestion.slug;
});

Tato metoda slouží pro zpracování toho, co se má stát po kliknutí na daný vyhledaný článek. Protože v javascriptu nemůžeme využít Laravel helperu pro vygenerování routy, musíme url adresu definovat ručně (na základě routy v web.php), na kterou budeme po vybrání přesměrováni.

Detail článku

V ArticleController v metodě show() vracíme pohled, který ještě nemáme vytvořený. Vytvoříme tedy pohled resources/views/detail.blade.php a pro jednoduchost v něm zobrazíme pouze název a obsah:

<h1>{{ $article->title }}</h1>
<p>{{ $article->content }}</p>
<p><a href={{ url('/') }}>Zpět na úvodní stránku</a></p>

Závěr

Na této jednoduché aplikaci jsem se pokusil vysvětlit implementaci autocomplete společně s Algolia službou a využitím Laravel Scout. Jedná se o velmi stručnou ukázku, další možnosti pak najdete v jejich dokumentacích (např. synchronizovat jen některé části modelu, vyhledávat jen v určitých sloupcích apod.).

Aplikaci také lze snadno rozšířit o create, update, delete metody a vidět tak synchronizaci mezi aplikací a Algolií. Další možností je (také od Algolia) využít InstantSearch, která nabízí mnoho dalších vychytávek. Pro lepší reaktivitu můžeme namísto čistého javascriptu využít Vue.js apod. Možností jak rozšířit vyhledávání je mnoho.

Celé demo pak najdete zde: https://github.com/jerryklimcik/algolia-demo