بازگرداندن اطلاعات متای صفحهبندی
تا اینجای کار، صفحهبندی در 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 را اجرا میکند، توالی رویدادها (به صورت بسیار سادهشده) به این شکل اتفاق میافتد:
- عبارت
WHEREبرای فیلتر کردن دادهها در جدولmoviesو دریافت سطرهای واجد شرایط استفاده میشود. - تابع پنجرهای
count(*) OVER()اعمال میشود که تمام سطرهای واجد شرایط را میشمارد. - قواعد
ORDER BYاعمال شده و سطرهای واجد شرایط مرتب میشوند. - قواعد
LIMITوOFFSETاعمال شده و زیرمجموعه مناسبی از سطرهای مرتب شده واجد شرایط بازگردانده میشود.
بهروزرسانی کد
با تمام شدن این توضیح مختصر، بیایید آن را راهاندازی کنیم. ابتدا فایل internal/data/filters.go را بهروزرسانی میکنیم تا یک ساختار Metadata جدید برای نگهداری اطلاعات متای صفحهبندی و یک تابع کمکی برای محاسبه مقادیر تعریف کنیم. به این صورت:
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() را به این صورت بهروزرسانی کنید:
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 قرار دهد. به این صورت:
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 نیست.