0

Series Laravel & Elasticsearch Thực chiến #3: Chinh phục Query DSL – Xây dựng API Tìm kiếm Đa điều kiện

1. Tư duy truy vấn: must vs filter

Trong Elasticsearch, để kết hợp nhiều điều kiện tìm kiếm, chúng ta sử dụng bool query. Có 2 vũ khí quan trọng nhất bạn cần nắm vững để tối ưu hiệu năng:

  • must (Giống LIKE trong SQL): Dùng cho tìm kiếm toàn văn bản (Full-text search). Nó không chỉ tìm xem từ khóa có xuất hiện không, mà còn tính toán điểm số (Score) để xem kết quả nào "khớp" nhất và xếp lên đầu. Quá trình này tiêu tốn CPU.
  • Ví dụ: Tìm các giao dịch có chứa từ "mất kết nối", "hỏng thẻ" trong trường error_notes.
  • filter (Giống = trong SQL): Dùng cho tìm kiếm chính xác (Exact match). Nó chỉ trả lời câu hỏi Yes/No (Có khớp mã thẻ này không?) và bỏ qua bước tính điểm. Tuyệt vời hơn, ES sẽ tự động cache kết quả của filter, giúp các lần tìm kiếm sau nhanh như chớp.
  • Ví dụ: Lọc theo card_id, station_id, hoặc khoảng thời gian tapped_at.

2. Xây dựng Controller Tìm kiếm

Hãy mở Terminal (bắn lệnh qua Docker) để tạo một Controller:

d-artisan make:controller Api/TransactionSearchController

Mở file app/Http/Controllers/Api/TransactionSearchController.php và bắt đầu "lắp ráp" API. Chúng ta sẽ inject Client của Elasticsearch (đã đăng ký ở Bài 1) thẳng vào Controller.

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Elastic\Elasticsearch\Client;
use Illuminate\Http\Request;

class TransactionSearchController extends Controller
{
    protected $elasticsearch;

    // Inject Raw Client vào Controller
    public function __construct(Client $elasticsearch)
    {
        $this->elasticsearch = $elasticsearch;
    }

    public function search(Request $request)
    {
        $must = [];
        $filter = [];

        // 1. Full-text search trên trường text (Ghi chú lỗi)
        // Yêu cầu tính toán độ phù hợp (Score) nên dùng MUST
        if ($request->has('keyword') && $request->keyword != '') {
            $must[] = [
                'match' => [
                    'error_notes' => $request->input('keyword')
                ]
            ];
        }

        // 2. Exact match trên trường keyword (Mã thẻ)
        // Chỉ cần khớp chính xác, không cần tính điểm nên dùng FILTER để ăn Cache
        if ($request->has('card_id') && $request->card_id != '') {
            $filter[] = [
                'term' => [
                    'card_id' => $request->input('card_id')
                ]
            ];
        }

        // 3. Lọc theo trạng thái giao dịch
        if ($request->has('status') && $request->status != '') {
            $filter[] = [
                'term' => [
                    'status' => $request->input('status')
                ]
            ];
        }

        // Khởi tạo Query tổng
        $query = [
            'bool' => [
                'must' => $must,
                'filter' => $filter
            ]
        ];

        // Nếu API gọi lên mà không truyền tham số gì, ta trả về tất cả (match_all)
        if (empty($must) && empty($filter)) {
            $query = ['match_all' => new \stdClass()];
        }

        // Đóng gói Payload gửi sang ES
        $params = [
            'index' => 'afc_transactions',
            'body'  => [
                'query' => $query,
                'size'  => 50, // Trả về tối đa 50 kết quả
                'sort'  => [
                    ['tapped_at' => ['order' => 'desc']] // Sắp xếp mới nhất lên đầu
                ]
            ]
        ];

        try {
            // "Bóp cò" bắn truy vấn sang Elasticsearch
            $response = $this->elasticsearch->search($params);
            
            // Bóc tách kết quả từ mảng lồng nhau của ES
            $hits = $response['hits']['hits'];
            $total = $response['hits']['total']['value'];
            
            // Map lại mảng để lấy phần '_source' (dữ liệu thật của chúng ta)
            $data = array_map(function ($hit) {
                return $hit['_source'];
            }, $hits);

            return response()->json([
                'success' => true,
                'total'   => $total,
                'data'    => $data
            ]);

        } catch (\Exception $e) {
            return response()->json([
                'success' => false, 
                'message' => 'Lỗi truy vấn ES: ' . $e->getMessage()
            ], 500);
        }
    }
}

3. Đăng ký Route và Test API

Mở file routes/api.php và thêm route cho API vừa viết:

use App\Http\Controllers\Api\TransactionSearchController;

Route::get('/transactions/search', [TransactionSearchController::class, 'search']);

Bây giờ, bạn có thể dùng Postman hoặc trình duyệt gọi thử API với các kịch bản khác nhau:

Kết hợp cả hai (Tìm mô tả lỗi trên 1 thẻ cụ thể): GET http://localhost:8000/api/transactions/search?card_id=METRO-9988&keyword=timeout

Bạn sẽ kinh ngạc khi thấy API trả về kết quả chỉ trong khoảng 5-10 mili-giây, cho dù bên dưới có hàng triệu dòng dữ liệu.

Tổng kết Bài 3

Bằng việc hiểu rõ và tách biệt giữa must (tìm kiếm tính điểm) và filter (lọc không tính điểm, có cache), bạn đã viết được một API tìm kiếm cực kỳ tối ưu và đúng chuẩn kiến trúc hệ thống dữ liệu lớn. Kỹ năng cấu trúc mảng đa chiều này (Array to JSON) chính là cách mà Raw Client của Elasticsearch hoạt động.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.