【Go Web开发】返回分页元数据

2022-02-27  本文已影响0人  Go语言由浅入深

上一篇文章,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()方法中编写的代码,会发现这里的代码并不比之前的接口代码多多少。

上一篇 下一篇

猜你喜欢

热点阅读