Let's Go Further فیلتر کردن، مرتب‌سازی و صفحه‌بندی › بازگرداندن اطلاعات متای صفحه‌بندی
قبلی · فهرست مطالب · بعدی
فصل ۹.۸.

بازگرداندن اطلاعات متای صفحه‌بندی

تا اینجای کار، صفحه‌بندی در endpoint GET /v1/movies ما به خوبی کار می‌کند، اما بهتر خواهد بود اگر بتوانیم اطلاعات اضافی همراه با پاسخ ارسال کنیم. اطلاعاتی مانند شماره صفحه فعلی و آخرین صفحه، و تعداد کل رکوردهای موجود به کلاینت کمک می‌کند تا درک بهتری از پاسخ داشته باشد و پیمایش بین صفحات آسان‌تر شود.

در این فصل، پاسخ را بهبود می‌دهیم تا اطلاعات اضافی صفحه‌بندی را شامل شود، مشابه این:

{
    "metadata": {
        "current_page": 1,
        "page_size": 20,
        "first_page": 1,
        "last_page": 42,
        "total_records": 832
    },
    "movies": [
        {
            "id": 1,
            "title": "Moana",
            "year": 2015,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        },
        ...
    ]
}

محاسبه تعداد کل رکوردها

چالش اصلی در این کار، تولید عدد total_records است. می‌خواهیم این عدد تعداد کل رکوردهای موجود با توجه به فیلترهای title و genres که اعمال شده‌اند را نشان دهد — نه مجموع مطلق رکوردها در جدول movies.

روش خوبی برای این کار، تطبیق کوئری SQL موجود با یک تابع پنجره‌ای است که تعداد کل سطرهای فیلتر شده را می‌شمارد، به این صورت:

SELECT count(*) OVER(), id, created_at, title, year, runtime, genres, version
FROM movies
WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') 
AND (genres @> $2 OR $2 = '{}')     
ORDER BY %s %s, id ASC
LIMIT $3 OFFSET $4

قرار دادن عبارت count(*) OVER() در ابتدای کوئری باعث می‌شود تعداد رکوردهای فیلتر شده به عنوان اولین مقدار در هر سطر قرار بگیرد. چیزی شبیه به این:

 count | id |       created_at       |       title        | year | runtime |          genres           | version 
-------+----+------------------------+--------------------+------+---------+---------------------------+---------
     3 |  1 | 2020-11-27 17:17:25+01 | Moana              | 2015 |     107 | {animation,adventure}     |       1
     3 |  2 | 2020-11-27 18:01:45+01 | Black Panther      | 2018 |     134 | {sci-fi,action,adventure} |       2
     3 |  4 | 2020-11-27 18:02:20+01 | The Breakfast Club | 1985 |      97 | {comedy,drama}            |       6

وقتی PostgreSQL این کوئری SQL را اجرا می‌کند، توالی رویدادها (به صورت بسیار ساده‌شده) به این شکل اتفاق می‌افتد:

  1. عبارت WHERE برای فیلتر کردن داده‌ها در جدول movies و دریافت سطرهای واجد شرایط استفاده می‌شود.
  2. تابع پنجره‌ای count(*) OVER() اعمال می‌شود که تمام سطرهای واجد شرایط را می‌شمارد.
  3. قواعد ORDER BY اعمال شده و سطرهای واجد شرایط مرتب می‌شوند.
  4. قواعد LIMIT و OFFSET اعمال شده و زیرمجموعه مناسبی از سطرهای مرتب شده واجد شرایط بازگردانده می‌شود.

به‌روزرسانی کد

با تمام شدن این توضیح مختصر، بیایید آن را راه‌اندازی کنیم. ابتدا فایل internal/data/filters.go را به‌روزرسانی می‌کنیم تا یک ساختار Metadata جدید برای نگهداری اطلاعات متای صفحه‌بندی و یک تابع کمکی برای محاسبه مقادیر تعریف کنیم. به این صورت:

فایل: internal/data/filters.go
package data

import (
    "math" // New import
    "strings"

    "greenlight.alexedwards.net/internal/validator"
)

...

// Define a new Metadata struct for holding the pagination metadata.
type Metadata struct {
    CurrentPage  int `json:"current_page,omitzero"`
    PageSize     int `json:"page_size,omitzero"`
    FirstPage    int `json:"first_page,omitzero"`
    LastPage     int `json:"last_page,omitzero"`
    TotalRecords int `json:"total_records,omitzero"`
}

// The calculateMetadata() function calculates the appropriate pagination metadata 
// values given the total number of records, current page, and page size values. Note 
// that when the last page value is calculated we are dividing two int values, and 
// when dividing integer types in Go the result will also be an integer type, with 
// the modulus (or remainder) dropped. So, for example, if there were 12 records in total 
// and a page size of 5, the last page value would be (12+5-1)/5 = 3.2, which is then
// truncated to 3 by Go. 
func calculateMetadata(totalRecords, page, pageSize int) Metadata {
    if totalRecords == 0 {
        // Note that we return an empty Metadata struct if there are no records.
        return Metadata{}
    }

    return Metadata{
        CurrentPage:  page,
        PageSize:     pageSize,
        FirstPage:    1,
        LastPage:     (totalRecords + pageSize - 1) / pageSize,
        TotalRecords: totalRecords,
    }
}

سپس باید به متد GetAll() برگردیم و آن را به‌روزرسانی کنیم تا از کوئری SQL جدید (با تابع پنجره‌ای) برای دریافت تعداد کل رکوردها استفاده کند. سپس، اگر همه چیز به درستی کار کرد، از تابع calculateMetadata() برای تولید اطلاعات متای صفحه‌بندی و بازگرداندن آن همراه با داده‌های فیلم استفاده می‌کنیم.

تابع GetAll() را به این صورت به‌روزرسانی کنید:

فایل: internal/data/movies.go
package data

...

// Update the function signature to return a Metadata struct.
func (m MovieModel) GetAll(title string, genres []string, filters Filters) ([]*Movie, Metadata, error) {
    // Update the SQL query to include the window function which counts the total 
    // (filtered) records.
    query := fmt.Sprintf(`
        SELECT count(*) OVER(), id, created_at, title, year, runtime, genres, version
        FROM movies
        WHERE (to_tsvector('simple', title) @@ plainto_tsquery('simple', $1) OR $1 = '') 
        AND (genres @> $2 OR $2 = '{}')     
        ORDER BY %s %s, id ASC
        LIMIT $3 OFFSET $4`, filters.sortColumn(), filters.sortDirection())

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    args := []any{title, pq.Array(genres), filters.limit(), filters.offset()}

    rows, err := m.DB.QueryContext(ctx, query, args...)
    if err != nil {
        return nil, Metadata{}, err // Update this to return an empty Metadata struct.
    }

    defer rows.Close()

    // Declare a totalRecords variable.
    totalRecords := 0
    movies := []*Movie{}

    for rows.Next() {
        var movie Movie

        err := rows.Scan(
            &totalRecords, // Scan the count from the window function into totalRecords.
            &movie.ID,
            &movie.CreatedAt,
            &movie.Title,
            &movie.Year,
            &movie.Runtime,
            pq.Array(&movie.Genres),
            &movie.Version,
        )
        if err != nil {
            return nil, Metadata{}, err // Update this to return an empty Metadata struct.
        }

        movies = append(movies, &movie)
    }

    if err = rows.Err(); err != nil {
        return nil, Metadata{}, err // Update this to return an empty Metadata struct.
    }

    // Generate a Metadata struct, passing in the total record count and pagination
    // parameters from the client.
    metadata := calculateMetadata(totalRecords, filters.Page, filters.PageSize)

    // Include the metadata struct when returning.
    return movies, metadata, nil
}

در نهایت، باید هندلر listMoviesHandler را به‌روزرسانی کنیم تا ساختار Metadata بازگردانده شده توسط GetAll() را دریافت کرده و اطلاعات آن را در پاسخ JSON قرار دهد. به این صورت:

فایل: cmd/api/movies.go
package main

...

func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title  string
        Genres []string
        data.Filters
    }

    v := validator.New()

    qs := r.URL.Query()

    input.Title = app.readString(qs, "title", "")
    input.Genres = app.readCSV(qs, "genres", []string{})

    input.Filters.Page = app.readInt(qs, "page", 1, v)
    input.Filters.PageSize = app.readInt(qs, "page_size", 20, v)

    input.Filters.Sort = app.readString(qs, "sort", "id")
    input.Filters.SortSafelist = []string{"id", "title", "year", "runtime", "-id", "-title", "-year", "-runtime"}

    if data.ValidateFilters(v, input.Filters); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Accept the metadata struct as a return value.
    movies, metadata, err := app.models.Movies.GetAll(input.Title, input.Genres, input.Filters)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Include the metadata in the response envelope.
    err = app.writeJSON(w, http.StatusOK, envelope{"movies": movies, "metadata": metadata}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

خوشبختانه API را مجدداً راه‌اندازی کنید و این قابلیت جدید را با ارسال درخواست‌های مختلف به endpoint GET /v1/movies آزمایش کنید. باید ببینید که اطلاعات متای صفحه‌بندی صحیح اکنون در پاسخ قرار گرفته است. به عنوان مثال:

$ curl "localhost:4000/v1/movies?page=1&page_size=2"
{
    "metadata": {
        "current_page": 1,
        "page_size": 2,
        "first_page": 1,
        "last_page": 2,
        "total_records": 3
    },
    "movies": [
        {
            "id": 1,
            "title": "Moana",
            "year": 2015,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        },
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}

و اگر درخواستی با فیلتر اعمال شده ارسال کنید، باید ببینید که مقدار last_page و تعداد total_records تغییر می‌کند تا فیلترهای اعمال شده را منعکس کند. به عنوان مثال، با درخواست فقط فیلم‌هایی با ژانر “adventure” می‌توانیم ببینیم که تعداد total_records به 2 کاهش می‌یابد:

$ curl "localhost:4000/v1/movies?genres=adventure"
{
    "metadata": {
        "current_page": 1,
        "page_size": 20,
        "first_page": 1,
        "last_page": 1,
        "total_records": 2
    },
    "movies": [
        {
            "id": 1,
            "title": "Moana",
            "year": 2015,
            "runtime": "107 mins",
            "genres": [
                "animation",
                "adventure"
            ],
            "version": 1
        },
        {
            "id": 2,
            "title": "Black Panther",
            "year": 2018,
            "runtime": "134 mins",
            "genres": [
                "sci-fi",
                "action",
                "adventure"
            ],
            "version": 2
        }
    ]
}

در نهایت، اگر درخواستی با مقدار صفحه بیش از حد بالا ارسال کنید، باید پاسخی با شیء متای خالی و آرایه فیلم‌های خالی دریافت کنید، به این صورت:

$ curl "localhost:4000/v1/movies?page=100"
{
    "metadata": {},
    "movies": []
}

در چند فصل اخیر، مجبور شدیم کار زیادی روی endpoint GET /v1/movies انجام دهیم. اما نتیجه نهایی واقعاً قدرتمند است. کلاینت اکنون کنترل زیادی روی محتوای پاسخ خود دارد، با پشتیبانی از فیلتر کردن، صفحه‌بندی و مرتب‌سازی.

با ساختار Filters که ایجاد کرده‌ایم، چیزی داریم که به راحتی می‌توانیم آن را در هر endpoint دیگری که به قابلیت صفحه‌بندی و مرتب‌سازی نیاز دارد استفاده کنیم. و اگر یک قدم به عقب برداریم و به کد نهایی نگاه کنیم که در listMoviesHandler و متد GetAll() مدل پایگاه داده خود نوشتی‌ایم، کد خیلی بیشتری نسبت به نسخه‌های اولیه endpoint نیست.