Blog

🔎 Elasticsearch z Laravel: Kompletny przewodnik

Ten przewodnik pokazuje, jak zintegrować Elasticsearch z Laravel.

📋 Spis treści


1. Dlaczego Elasticsearch?

Eloquent Laravel jest świetny dla większości zapytań, ale gdy potrzebujesz wyszukiwania pełnotekstowego, agregacji lub wyszukiwania geograficznego, Elasticsearch jest najlepszym rozwiązaniem. Czysta integracja z Laravel zapewnia, że Twój kod pozostanie łatwy w utrzymaniu i testowaniu.


2. Instalacja i konfiguracja

Najpierw zainstaluj oficjalnego klienta PHP dla Elasticsearch:

composer require elasticsearch/elasticsearch

Następnie dodaj Elasticsearch do pliku docker-compose.yml:

services:
    ...
    elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:9.0.1
    container_name: laravel_elasticsearch
    environment:
        - discovery.type=single-node
        - ES_JAVA_OPTS=-Xms1g -Xmx1g
        - xpack.security.enabled=false
        - xpack.monitoring.collection.enabled=true
    ports:
        - "9200:9200"
    volumes:
        - esdata:/usr/share/elasticsearch/data
    networks:
        - laravel-network

voluems:
    ...
    esdata:
      driver: local

Następnie utwórz plik konfiguracyjny dla Elasticsearch:

touch config/elasticsearch.php
<?php

return [
    'host' => env('ELASTICSEARCH_HOST', 'http://elasticsearch:9200'),
];

3. Warstwa serwisu: Opakowanie klienta Elasticsearch

Aby zachować czysty i testowalny kod, zamknij całą logikę Elasticsearch w dedykowanym serwisie.

app/Services/ElasticsearchService.php

<?php

namespace App\Services;

use Elastic\Elasticsearch\ClientBuilder;

class ElasticsearchService
{
    public function client()
    {
        return ClientBuilder::create()
            ->setHosts([config('elasticsearch.host')])
            ->build();
    }

    public function createArticlesIndex(): void
    {
        $params = [
            'index' => 'articles',
            'body' => [
                'settings' => [
                    'analysis' => [
                        'analyzer' => [
                            'autocomplete' => [
                                'tokenizer' => 'autocomplete',
                                'filter' => ['lowercase'],
                            ],
                        ],
                        'tokenizer' => [
                            'autocomplete' => [
                                'type' => 'edge_ngram',
                                'min_gram' => 1,
                                'max_gram' => 20,
                                'token_chars' => ['letter', 'digit'],
                            ],
                        ],
                    ],
                ],
                'mappings' => [
                    'properties' => [
                        'title' => [
                            'type' => 'text',
                            'analyzer' => 'autocomplete',
                            'search_analyzer' => 'standard',
                        ],
                        'tags' => [
                            'type' => 'keyword',
                        ],
                        'user' => [
                            'type' => 'keyword',
                        ],
                        'location' => [
                            'type' => 'geo_point',
                        ],
                        'city_name' => [
                            'type' => 'keyword',
                        ],
                    ],
                ],
            ],
        ];

        if ($this->client()->indices()->exists(['index' => 'articles'])->asBool()) {
            $this->client()->indices()->delete(['index' => 'articles']);
        }

        $this->client()->indices()->create($params);
    }

    public function index(array $params)
    {
        return $this->client()->index($params);
    }
}

4. Obiekt transferu danych (DTO) dla filtrów wyszukiwania

Użycie DTO (z spatie/laravel-data) utrzymuje logikę wyszukiwania w czystości i zapewnia bezpieczeństwo typów.

app/DTO/ArticleFilterData.php

<?php

namespace App\DTO;

use Spatie\LaravelData\Data;

class ArticleFilterData extends Data
{
    public function __construct(
        public ?string $q = '',
        public ?string $tag = null,
        public ?string $city = null,
        public ?int $radius = null,
        public ?float $lat = null,
        public ?float $lon = null,
        public ?int $page = 1,
        public ?int $size = 20,
    ) {}
}

5. Serwis wyszukiwania: Logika biznesowa dla wyszukiwania artykułów

Ten serwis buduje zapytanie Elasticsearch na podstawie DTO i zwraca ustrukturyzowane wyniki.

app/Services/ArticleSearchService.php

<?php

namespace App\Services;

use App\DTO\ArticleFilterData;
use Illuminate\Support\Arr;

readonly class ArticleSearchService
{
    public function __construct(private ElasticsearchService $es) {}

    public function search(ArticleFilterData $filters): array
    {
        $queryBody = [
            'index' => 'articles',
            'from' => ($filters->page - 1) * $filters->size,
            'size' => $filters->size,
            'body' => [
                'query' => [
                    'bool' => [
                        'must' => $this->buildMustQueries($filters),
                        'filter' => $this->buildFilterQueries($filters),
                    ],
                ],
            ],
        ];

        $results = $this->es->client()->search($queryBody);

        return [
            'articles' => collect($results['hits']['hits'])->pluck('_source')->all(),
            'total' => $results['hits']['total']['value'] ?? 0,
            'filters' => $filters->toArray(),
        ];
    }

    private function buildMustQueries(ArticleFilterData $filters): array
    {
        $must = [];

        if ($filters->q) {
            $must[] = mb_strlen($filters->q) <= 2
                ? ['wildcard' => ['title' => "*{$filters->q}*"]]
                : [
                    'multi_match' => [
                        'query' => $filters->q,
                        'fields' => ['title^2', 'tags'],
                        'fuzziness' => 'auto',
                        'operator' => 'and',
                        'minimum_should_match' => '100%',
                    ],
                ];
        }

        if ($filters->tag) {
            $must[] = ['term' => ['tags' => $filters->tag]];
        }

        return $must;
    }

    private function buildFilterQueries(ArticleFilterData $filters): array
    {
        $filter = [];

        if ($filters->city) {
            $filter[] = ['term' => ['city_name' => $filters->city]];
        }

        if ($filters->lat && $filters->lon && $filters->radius > 0) {
            $filter = array_filter($filter, fn($f) => !Arr::has($f, 'term.city_name'));
            $filter[] = [
                'geo_distance' => [
                    'distance' => "{$filters->radius}km",
                    'location' => [
                        'lat' => $filters->lat,
                        'lon' => $filters->lon,
                    ],
                ],
            ];
        }

        return $filter;
    }
}

6. Komenda do tworzenia indeksu

app/Console/Commands/ReindexArticlesElasticsearch.php

<?php

namespace App\Console\Commands;

use App\Jobs\ReindexArticles;
use App\Services\ElasticsearchService;
use Elastic\Elasticsearch\Exception\AuthenticationException;
use Elastic\Elasticsearch\Exception\ClientResponseException;
use Elastic\Elasticsearch\Exception\MissingParameterException;
use Elastic\Elasticsearch\Exception\ServerResponseException;
use Illuminate\Console\Command;

class ReindexArticlesElasticsearch extends Command
{
    protected $signature = 'es:reindex-articles';

    protected $description = 'Tworzy indeks artykułów z mapowaniem i reindeksuje artykuły do Elasticsearch';

    /**
     * @throws AuthenticationException
     * @throws ClientResponseException
     * @throws ServerResponseException
     * @throws MissingParameterException
     */
    public function handle(ElasticsearchService $es): void
    {
        $this->info('Tworzenie indeksu articles z mapowaniem...');
        $es->createArticlesIndex();
        $this->info('Reindeksowanie artykułów...');
        ReindexArticles::dispatchSync();
        $this->info('Gotowe!');
    }
}

7. Zadanie do reindeksacji artykułów

app/Jobs/ReindexArticles.php

<?php

namespace App\Jobs;

use App\Models\Article;
use App\Models\User;
use App\Services\ElasticsearchService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class ReindexArticles implements ShouldQueue
{
    use Queueable;

    public function handle(ElasticsearchService $es): void
    {
        Article::with(['user', 'tags'])->chunk(100, function ($articles) use ($es) {
            foreach ($articles as $article) {

                /** @var User $user */
                $user = $article->user;

                $es->index([
                    'index' => 'articles',
                    'id' => $article->id,
                    'body' => [
                        'title' => $article->title,
                        'slug' => $article->slug,
                        'content' => $article->content,
                        'user' => $user->name,
                        'tags' => $article->tags->pluck('name')->toArray(),
                        'location' => $article->location ?? null,
                        'city_name' => $article->city_name ?? null,
                    ],
                ]);
            }
        });
    }
}

8. Obserwator dla modelu Article

app/Observers/ArticleObserver.php

<?php

namespace App\Observers;

use App\Jobs\ReindexArticles;
use App\Models\Article;
use App\Services\ElasticsearchService;

class ArticleObserver
{
    public function deleted(Article $article): void
    {
        $es = app(ElasticsearchService::class)->client();

        try {
            $es->delete([
                'index' => 'articles',
                'id' => $article->id,
            ]);
        } catch (\Exception $e) {
            \Log::info($e->getMessage());
        }
    }

    public function saved(Article $article)
    {
        ReindexArticles::dispatch();
    }
}

9. Podsumowanie

Masz teraz system wyszukiwania gotowy do produkcji z:

  • Wyszukiwaniem w czasie rzeczywistym
  • Zaawansowanym filtrowaniem i sortowaniem
  • Optymalizacją wydajności
  • Obsługą błędów
  • Czystym, łatwym w utrzymaniu kodem

To podejście utrzymuje Twój kod Laravel w czystości, testowalności i gotowości do produkcji.


Śledź mnie na LinkedIn po więcej wskazówek o Laravel i DevOps!

Chcesz dowiedzieć się więcej o implementacji wyszukiwania w Laravel? Daj znać w komentarzach poniżej!

Kod źródłowy

Pełną implementację i więcej przykładów znajdziesz w repozytorium GitHub.

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.