API Filtering: gọi dữ liệu như dân sành cà phê

API Filtering: gọi dữ liệu như dân sành cà phê

Table of Contents

Năm đầu đi làm, mình từng viết một endpoint GET /api/menus trả về… toàn bộ menu. 200 items mỗi lần gọi. JSON nặng 1.2MB. Frontend chỉ cần tên + giá của 10 món đang active. Mình nhớ câu đầu tiên anh lead nói: “Mày đang gửi cả cái kho hàng cho người ta chỉ cần xem menu đấy à?”

Bài học đó mất 1 tuần refactor. Từ đó mỗi lần thiết kế API, mình luôn nghĩ đến quán cà phê: khách gọi iced Americano, barista không bưng ra cả quầy - họ ghi order rõ ràng, pha đúng món, và đưa đúng ly. API cũng phải vậy.

Đây là 3 pattern mình dùng để API “gọi món” thay vì “bưng cả quầy.” Toàn bộ code đều bằng Go, chạy được, copy-paste vào dự án là dùng luôn.

![API Filtering(https://blog-bucket.luandnh.com/images/covers/api-filtering.webp)

1. Chỉ món đang bán - filter query params

Lọc cơ bản nhất: client chỉ muốn item đang active.

GET /api/products?status=active

Backend Go parse đơn giản:

func parseFilters(q url.Values) [Filter {
    var filters [Filter
    for key, values := range q {
        switch {
        case strings.HasSuffix(key, "_gte"):
            field := strings.TrimSuffix(key, "_gte")
            filters = append(filters, Filter{field, ">=", parseFloat(values[0)})
        case strings.HasSuffix(key, "_lte"):
            field := strings.TrimSuffix(key, "_lte")
            filters = append(filters, Filter{field, "<=", parseFloat(values[0)})
        case strings.HasSuffix(key, "_in"):
            field := strings.TrimSuffix(key, "_in")
            filters = append(filters, Filter{field, "IN", strings.Split(values[0, ",")})
        case strings.HasSuffix(key, "_like"):
            field := strings.TrimSuffix(key, "_like")
            filters = append(filters, Filter{field, "LIKE", "%" + values[0 + "%"})
        default:
            // exact match
            filters = append(filters, Filter{key, "=", values[0})
        }
    }
    return filters
}

Pitfall mình từng dính: dùng _like trên cột không có full-text index. 100K records × LIKE ‘%keyword%’ = sequential scan. Nếu cần tìm kiếm mờ, đẩy qua Elasticsearch hoặc ít nhất dùng PostgreSQL pg_trgm.

Mấy toán tử nên hỗ trợ:

Toán tửVí dụKhi dùng
_eq (hoặc default)status=activeLọc chính xác
_necategory_ne=archivedLoại trừ
_gte / _lteprice_gte=50000Khoảng giá
_instatus_in=active,pendingNhiều giá trị
_likename_like=coffeeTìm kiếm text (cẩn thận index!)

2. Xếp món mới nhất lên trên - sorting

GET /api/posts?sort=created_at&order=desc

Code Go:

func applySort(db *gorm.DB, q url.Values) *gorm.DB {
    sortField := q.Get("sort")
    order := q.Get("order")

    if sortField == "" {
        return db.Order("created_at DESC") // default
    }

    // whitelist fields - đừng để client sort cột arbitrary!
    allowed := map[stringbool{
        "created_at": true, "price": true, "name": true, "updated_at": true,
    }
    if !allowed[sortField {
        return db // ignore invalid field
    }

    if order == "asc" {
        return db.Order(sortField + " ASC")
    }
    return db.Order(sortField + " DESC")
}

Mình từng làm sai: cho phép ?sort=anything không whitelist. Client nhập ?sort=1;DROP TABLE products;-- thì không sao vì GORM escape, nhưng ?sort=json_data query 300ms vì cột không có index. Whitelist + index là mandatory, không phải optional.

-- Index cột sort phổ biến để tránh full scan
CREATE INDEX idx_products_created_at ON products(created_at DESC);
CREATE INDEX idx_products_price ON products(price ASC);

3. Tôi chỉ cần tên và giá - field selection

GET /api/products?fields=name,price,thumbnail

Đây là optimization bị bỏ quên nhiều nhất. JSON response 20 trường với nested objects nặng hơn nhiều so với 3 trường phẳng.

func applyFieldSelection(db *gorm.DB, q url.Values) *gorm.DB {
    fields := q.Get("fields")
    if fields == "" {
        return db // return all columns
    }

    selected := strings.Split(fields, ",")
    allowed := map[stringbool{
        "id": true, "name": true, "price": true,
        "thumbnail": true, "status": true, "created_at": true,
    }

    var columns [string
    for _, f := range selected {
        if allowed[strings.TrimSpace(f) {
            columns = append(columns, strings.TrimSpace(f))
        }
    }

    if len(columns) == 0 {
        return db // fallback to all if invalid
    }
    return db.Select(columns)
}

Trong dự án food delivery, mình giảm được 40% payload size chỉ bằng cách thêm field selection cho mobile client. Trên 3G, 40% là đáng kể.

Order quan trọng: Filter → Sort → Paginate

Khi kết hợp cả 3:

GET /api/products?category=tea&price_gte=30000&sort=price&order=asc&page=1&limit=20

Flow trong backend:

func ListProducts(ctx context.Context, q url.Values) ([Product, int64, error) {
    db := db.WithContext(ctx).Model(&Product{})

    db = applyFilters(db, q)      // 1. Filter
    db = applySort(db, q)         // 2. Sort
    // 3. Paginate (SAU filter+sort)
    var total int64
    db.Count(&total)

    var products [Product
    offset, limit := parsePagination(q)
    db.Offset(offset).Limit(limit).Find(&products)

    return products, total, nil
}

Đừng paginate trước filter - bạn sẽ bị thiếu dữ liệu và total count sai. Lỗi này mình thấy ở 2/3 codebase junior khi review.

Bonus: Test luôn filter của bạn

func TestListProducts_FilterByStatus(t *testing.T) {
    // seed 3 active + 2 inactive
    seedProducts(t, 3, "active")
    seedProducts(t, 2, "inactive")

    req, _ := http.NewRequest("GET", "/api/products?status=active", nil)
    products, _, err := handler.ListProducts(req.Context(), req.URL.Query())

    require.NoError(t, err)
    assert.Len(t, products, 3)
}

Bottom line

API tốt không phải cái trả nhiều dữ liệu nhất - mà là cái cho client đủ và đúng. Filtering, sorting, field selection tốn 30 phút implement nhưng tiết kiệm hàng giờ debug, giảm payload, giảm load DB. Ai cũng biết nhưng ít ai làm đủ cả 3.

Bạn đã từng gặp endpoint nào trả về 1MB JSON trong khi frontend chỉ cần 3 field chưa? Hay vẫn đang maintain một cái? Kể mình nghe câu chuyện của bạn đi - mấy bài học từ “api béo” bao giờ cũng đáng giá nhất. ☕

Share :

Related Posts

Context trong Go: truyền dữ liệu, deadline, cancel - dùng đúng hay chết hiệu năng

Context trong Go: truyền dữ liệu, deadline, cancel - dùng đúng hay chết hiệu năng

Hồi mới code Go, mình có một bug production kỳ lạ: cứ sau 3-4 ngày chạy, service tự dưng ngốn 8GB RAM rồi OOM. Mò suốt chiều thứ Sáu mới phát hiện: một cái goroutine không bao giờ kết thúc vì mình quên truyền context xuống gRPC call. Mỗi request leak một goroutine, sau 100K request thì server “đội mồ” luôn.

Read More