Blog

🔍 Implementacja Wyszukiwania w Czasie Rzeczywistym z Laravel Scout

Ten przewodnik pokaże Ci, jak zaimplementować zaawansowane wyszukiwanie w czasie rzeczywistym w aplikacji Laravel, wykorzystując Laravel Scout. Dowiesz się, jak stworzyć wydajny system wyszukiwania, który zapewnia natychmiastowe wyniki i świetne doświadczenie użytkownika.

📋 Spis treści


🧠 Wprowadzenie

Wyszukiwanie w czasie rzeczywistym to kluczowa funkcjonalność w nowoczesnych aplikacjach webowych. W tym przewodniku pokażemy, jak zaimplementować system wyszukiwania, który:

  • Dostarcza natychmiastowe wyniki
  • Jest skalowalny i wydajny
  • Zapewnia świetne doświadczenie użytkownika
  • Jest łatwy w utrzymaniu

🛠 Konfiguracja Laravel Scout

Zainstaluj Scout:

composer require laravel/scout

Opublikuj konfigurację:

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

🐳 Dodawanie Typesense do Dockera

Zaktualizuj docker-compose.yml:

typesense:
  image: typesense/typesense:0.25.1
  container_name: blog_typesense
  restart: unless-stopped
  ports:
    - "8108:8108"
  environment:
    - TYPESENSE_API_KEY=your_typesense_api_key
    - TYPESENSE_DATA_DIR=/data
    - TYPESENSE_ENABLE_CORS=true
  volumes:
    - typesense_data:/data
  networks:
    - laravel-network

volumes:
  typesense_data:
    driver: local

Uruchom Typesense:

docker-compose up -d typesense

📦 Instalacja sterownika Typesense dla Scout

composer require typesense/laravel-scout-typesense-engine

⚙️ Konfiguracja Scout i Typesense

W .env:

SCOUT_DRIVER=typesense
SCOUT_ENABLED=true

TYPESENSE_HOST=localhost
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_API_KEY=your_typesense_api_key

W config/scout.php:

'typesense' => [
    'client-settings' => [
        'api_key' => env('TYPESENSE_API_KEY'),
        'nodes' => [[
            'host' => env('TYPESENSE_HOST', 'localhost'),
            'port' => env('TYPESENSE_PORT', '8108'),
            'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
        ]],
        'nearest_node' => [
            'host' => env('TYPESENSE_HOST', 'localhost'),
            'port' => env('TYPESENSE_PORT', '8108'),
            'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
        ],
        'connection_timeout_seconds' => 2,
        'healthcheck_interval_seconds' => 30,
        'num_retries' => 3,
        'retry_interval_seconds' => 1,
    ],
    'model-settings' => [
        App\Models\Post::class => [
            'collection-schema' => [
                'fields' => [
                    ['name' => 'title',         'type' => 'string'],
                    ['name' => 'content',       'type' => 'string'],
                    ['name' => 'tags_names',    'type' => 'string[]'],
                    ['name' => 'published_at',  'type' => 'int32'],
                ],
                'default_sorting_field' => 'published_at',
            ],
            'search-parameters' => [
                'query_by' => 'title,content,tags_names',
                'query_by_weights' => '5,3,2,1',
                'sort_by' => 'published_at:desc',
                'filter_by' => 'published_at:<' . now()->timestamp,
                'drop_tokens_threshold' => 1,
                'typo_tokens_threshold' => 100,
                'prefix' => true,
            ],
        ],
    ],
]

🧩 Przygotowanie modelu do wyszukiwania

W Post.php:

use Laravel\Scout\Searchable;

class Post extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        $this->load(['category', 'tags']);
        
        return [
            'id' => (string) $this->id,
            'title' => $this->title,
            'content' => $this->content,
            'tags_names' => $this->tags->pluck('name')->toArray(),
            'published_at' => $this->published_at->timestamp,
        ];
    }

    public function searchableAs(): string
    {
        return 'posts_index';
    }
}

📥 Indeksowanie danych

php artisan scout:import App\Models\Post

🔙 Endpoint sugestii w backendzie

// routes/api.php
Route::get('/suggest-posts', [BlogController::class, 'suggestPosts']);

// BlogController.php
public function suggestPosts(Request $request)
{
    $query = $request->input('query');
    
    if (trim($query) === '') {
        return response()->json([]);
    }

    $results = Post::getSuggestions($query);

    return response()->json($results);
}

// Post.php
public static function getSuggestions(string $query)
{
    return self::search($query)
        ->query(fn (Builder $query) => $query->where('published_at', '<=', now()))
        ->take(5)
        ->get()
        ->map(fn ($post) => [
            'id' => $post->id,
            'title' => $post->title,
            'slug' => $post->slug,
        ])
        ->values();
}

💻 Frontend: Natychmiastowe wyszukiwanie w React

Przykład paska wyszukiwania w React:

const [search, setSearch] = useState(filters?.search);
const [sort, setSort] = useState(filters?.sort);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [suggestionsOpen, setSuggestionsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement>(null);

const handleSearch = useCallback((value: string) => {
    setSearch(value);
    setHighlightedIndex(-1);
}, []);

const handleSort = useCallback((value: string) => {
    setSort(value);
}, []);

const handleSuggestionSelect = useCallback(
    (suggestion: Suggestion) => {
        setSuggestionsOpen(false);
        setHighlightedIndex(-1);
        router.visit(route('blog.show', { post: suggestion.slug }));
    },
    [],
);

const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
        if (e.key === 'ArrowDown') {
            e.preventDefault();
            setHighlightedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
        } else if (e.key === 'ArrowUp') {
            e.preventDefault();
            setHighlightedIndex((prev) => Math.max(prev - 1, 0));
        } else if (e.key === 'Enter' && highlightedIndex >= 0) {
            e.preventDefault();
            const selected = suggestions[highlightedIndex];
            if (selected) {
                handleSuggestionSelect(selected);
            }
        } else if (e.key === 'Escape') {
            setSuggestionsOpen(false);
        }
    },
    [suggestions, highlightedIndex, handleSuggestionSelect],
);

useEffect(() => {
    const timeout = setTimeout(() => {
        if (search && search.length >= 2) {
            axios
                .get(route('blog.suggest-posts', { query: search }))
                .then((res) => res.data)
                .then(setSuggestions);
        } else {
            setSuggestions([]);
        }

        const params = {
            search,
            sort,
        };

        router.reload({
            only: ['posts'],
            data: params,
        });
    }, 400);
    return () => clearTimeout(timeout);
}, [sort, search]);

useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
        if (!inputRef.current?.contains(e.target as Node)) {
            setSuggestionsOpen(false);
        }
    };
    window.addEventListener('click', handleClickOutside);
    return () => window.removeEventListener('click', handleClickOutside);
}, []);

Pole wejściowe z rozwijaną listą sugestii:

<div className="relative w-full" ref={inputRef}>
    <Input
        className="w-full"
        placeholder={__('Search posts...')}
        value={search}
        onChange={(e) => handleSearch(e.target.value)}
        onFocus={() => setSuggestionsOpen(true)}
        onKeyDown={handleKeyDown}
        aria-label={__('Search posts')}
        aria-expanded={suggestionsOpen}
        aria-controls="search-suggestions"
        role="combobox"
    />
    {suggestionsOpen && suggestions.length > 0 && (
        <ul
            id="search-suggestions"
            className="absolute z-10 mt-1 w-full rounded-md border bg-white shadow-md dark:bg-zinc-900"
            role="listbox"
        >
            {suggestions.map((s, i) => (
                <li
                    key={s.id}
                    role="option"
                    aria-selected={i === highlightedIndex}
                    className={`cursor-pointer px-4 py-2 ${
                        i === highlightedIndex ? 'bg-muted font-medium' : 'hover:bg-muted'
                    }`}
                    onMouseDown={() => handleSuggestionSelect(s)}
                >
                    {s.title}
                </li>
            ))}
        </ul>
    )}
</div>

Podsumowanie

Masz teraz system wyszukiwania gotowy do produkcji z:

  • Wyszukiwaniem w czasie rzeczywistym
  • Przygotowaniem modelu do wyszukiwania
  • Endpointem sugestii w backendzie
  • Natychmiastowym wyszukiwaniem w React

Śledź mnie na LinkedIn, aby otrzymywać więcej wskazówek o Laravel i DevOps!

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.