Implementacja Wyszukiwania w Czasie Rzeczywistym z Laravel Scout

5 min. czytaniaZaktualizowano

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.

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:

bash
composer require laravel/scout

Opublikuj konfigurację:

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

Dodawanie Typesense do Dockera

Zaktualizuj docker-compose.yml:

yaml
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:

bash
docker-compose up -d typesense

Instalacja sterownika Typesense dla Scout

bash
composer require typesense/laravel-scout-typesense-engine

Konfiguracja Scout i Typesense

W .env:

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:

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:

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

bash
php artisan scout:import App\Models\Post

Endpoint sugestii w backendzie

php
// 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:

tsx
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:

tsx
<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

Powiązane artykuły

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.