上一篇文章,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值。我们需要根据过滤器,过滤之后的总数。而不是数据库中movies表中的所有电影记录总数。
要做到这一点,一个简单的方法是调整我们现有的SQL查询,包括一个window函数,它计算过滤后的总行数,像这样:
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、window函数count(*) OVER()被执行,计算所有过滤后的行数。
3、执行ORDER BY规则,对查询出来的行进行排序。
4、执行LIMIT和OFFSET规则,返回符合要求的部分,即分页功能。
更新代码
有了上面的解释,我们开始修改代码。首先更新internal/data/filters.go文件,定义Metadata结构体存储分页元数据,并添加帮助函数来计算值。如下所示:
File:internal/data/filters.go
package data
...
type Metadata struct {
CurrentPage int `json:"current_page,omitempty"`
PageSize int `json:"page_size,omitempty"`
FirstPage int `json:"first_page,omitempty"`
LastPage int `json:"last_page,omitempty"`
TotalRecords int `json:"total_records,omitempty"`
}
// 计算Metadata各字段值,例如,如果查询总的行数为12, pageSize等于5,最后一页等于math.Ceil(12/5)=3
func calculateMetadata(totalRecords, page, pageSize int) Metadata {
if totalRecords == 0 {
return Metadata{}
}
return Metadata{
CurrentPage: page,
PageSize: pageSize,
FirstPage: 1,
LastPage: int(math.Ceil(float64(totalRecords) / float64(pageSize))),
TotalRecords: totalRecords,
}
}
然后,我们需要更新GetAll()方法,以使用新的SQL查询(使用window函数)来获得总行数。然后,如果一切正常,我们将使用calculateMetadata()函数来生成分页元数据,并将其与查询数据一起返回。
File: internal/data/movies.go
package data
...
func (m MovieModel)GetAll(title string, genres []string, filters Filters) ([]*Movie, Metadata, error) {
// 使用window函数,计算查询总数
query := fmt.Sprintf(`
SELECT count(*) OVER(), id, create_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())
//创建3s超时上下文实例
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel()
//因为SQL查询中有好几个占位符参数,所以我们用一个切片存放。注意我们调用limit()和offset()方法
args := []interface{}{title, pq.Array(genres), filters.limit(), filters.offset()}
rows, err := m.DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, Metadata{}, err
}
defer rows.Close()
totalRecords := 0
var movies []*Movie
for rows.Next(){
var movie Movie
err := rows.Scan(
&totalRecords,
&movie.ID,
&movie.CreateAt,
&movie.Title,
&movie.Year,
&movie.Runtime,
pq.Array(&movie.Genres),
&movie.Version,
)
if err != nil {
return nil, Metadata{}, err
}
movies = append(movies, &movie)
}
if err = rows.Err(); err != nil {
return nil, Metadata{}, err
}
// 根据查询数据生成Metadata实例
metadata := calculateMetadata(totalRecords, filters.Page, filters.PageSize)
return movies, metadata, nil
}
最后,还需要更新接口处理程序listMoviesHandler来接收GetAll()方法返回的Metadata结构体实例,并将其添加到JSON返回给客户端。如下所示:
File: 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
}
//调用 GetAll()方法从数据库中查询movie记录
movies, metadata, err := app.models.Movies.GetAll(input.Title, input.Genres, input.Filters)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
err = app.writeJSON(w, http.StatusOK, envelope{"movies": movies, "metadata": metadata}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
重启服务,然后测试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": 2016,
"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": 2016,
"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
}
]
}
最后,如果你用一个很大的page值请求,会得到一个空的元数据对象和movies数组的响应,像这样:
$ curl "localhost:4000/v1/movies?page=100"
{
"metadata": {},
"movies": null
}
在过去的几节中,我们在GET /v1/movies接口上做了大量工作。但最终的结果很好的。客户端现在可以对其响应包含的内容进行很多控制,支持过滤、分页和排序。
通过创建Filters结构体,可以轻松地将一些公共的东西放到需要分页和排序功能的接口中。如果回头看一下我们在listMovieHandler和数据库模型的GetAll()方法中编写的代码,会发现这里的代码并不比之前的接口代码多多少。
网友评论