重新考虑使用Relay风格进行分页实现的GraphQL(使用窗口函数版)

请用汉语将下面的句子翻译成中文,只需一种选择:

お題

之前,我在以下文章中尝试实现了Relay样式。

    • GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)

 

    GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)

然而,将“前后页面导航”和“根据任意项目按升序或降序排序”这两个要求结合起来实现比想象中更加繁琐,并且难以适应。这次,我尝试通过在架构中加入将RDB设定为PostgreSQL的限制,看看是否可以简化后端实现(相较于上次)。

我在本次样本实施中使用的语言和库等。

此外,我不会对这些单独的语言、库等进行解释。

前端

和上一篇前端文章相同。

    • Vue.js

 

    • Nuxt.js

 

    • TypeScript

 

    • Nuxt TypeScript

 

    • Vuetify

 

    • Apollo

 

    graphql-code-generator

后端

    • Go

 

    • gqlgen

 

    • SQL Boiler

 

    PostgreSQL

其他

    • GraphQL

 

    • Docker

 

    Docker Compose

希望确认的规格

在显示某种信息(本次为顾客)的页面上,具备以下功能:

    • 文字列検索フィルタ(部分一致検索)

 

    • 前ページ、次ページ遷移

 

    • 一覧表示要素での昇順、降順並べ替え

 

    一覧表示件数の変更

只需一种选择:
简单地在初始页面显示时获取所有记录,而不是在内存中进行上一页、下一页的跳转,而是每次只搜索所需的页数(只搜索所需页面的记录)。
即使在执行以下操作时,处于分页的中间(例如显示第2页),也会返回到第1页的显示。

    • 一覧表示要素での昇順、降順並べ替え

 

    一覧表示件数の変更

画面的图像

初始页面显示时(默认按照ID降序排列的规定)

screenshot-localhost_3000-2020.11.15-23_36_44.png

当切换到第二页时

screenshot-localhost_3000-2020.11.16-00_59_48.png

使用搜索过滤器时

screenshot-localhost_3000-2020.11.16-01_02_42.png

按照名字的升序排列

screenshot-localhost_3000-2020.11.16-01_04_06.png

将列表项数更改为10个(以”年龄”降序排列)

screenshot-localhost_3000-2020.11.16-01_06_09.png
screenshot-localhost_3000-2020.11.16-01_06_21.png

相关文章目录

    • 第12回「GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)」

 

    • 第11回「Dataloadersを使ったN+1問題への対応」

 

    • 第10回「GraphQL(gqlgen)エラーハンドリング」

 

    • 第9回「GraphQLにおける認証認可事例(Auth0 RBAC仕立て)」

 

    • 第8回「GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例」

 

    • 第7回「GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)」

 

    • 第6回「GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)」

 

    • 第5回「DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動」

 

    • 第4回「graphql-codegenでフロントエンドをGraphQLスキーマファースト」

 

    • 第3回「go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)」

 

    • 第2回「NuxtJS(with Apollo)のTypeScript対応」

 

    第1回「frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る」

开发环境

操作系统 – Linux(Ubuntu)

$ cat /etc/os-release 
NAME="Ubuntu"
VERSION="20.04.1 LTS (Focal Fossa)"

后端

编程语言 – Golang

$ go version
go version go1.15.2 linux/amd64

gqlgen-构建GraphQL服务器和客户端的工具包。

v0.13.0

IDE – Goland –
集成开发环境 – Goland

GoLand 2020.2.3
Build #GO-202.7319.61, built on September 16, 2020

本次所有来源

实践

数据库

在本地使用Docker Compose来运行PostgreSQL v13。(由于仅在本地使用,因此可以直接写入密码等信息)

version: '3'

services:
  db:
    restart: always
    image: postgres:13-alpine
    container_name: study-graphql-postgres-container
    ports:
      - "25432:5432"
    environment:
      - DATABASE_HOST=localhost
      - POSTGRES_DB=study-graphql-local-db
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=yuckyjuice
      - PGPASSWORD=yuckyjuice
    volumes:
      - ./local/data:/docker-entrypoint-initdb.d/

在上述的数据库中创建一个名为“customer”的表。

CREATE TABLE customer (
  id bigserial NOT NULL,
  name varchar(64) NOT NULL,
  age int NOT NULL,
  PRIMARY KEY (id)
);
Screenshot at 2020-11-16 01-09-20.png

GraphQL模式

如果仅涉及到Relay的部分,实际上并不是很复杂。但是这次我们还将”字符串搜索过滤器”和”按每个要素的升序或降序排序”结合在一起,所以定义有点复杂。

$ tree schema/
schema/
├── connection.graphql
├── customer.graphql
├── order.graphql
├── pagination.graphql
├── schema.graphql
└── text_filter.graphql

■模式图.graphql

# Global Object Identification ... 全データを共通のIDでユニーク化
interface Node {
    id: ID!
}

schema {
    query: Query
}

type Query {
    node(id: ID!): Node
}

客户.graphql

顾客连接查询

extend type Query {
  "Relay準拠ページング対応検索によるTODO一覧取得"
  customerConnection(
    "ページング条件"
    pageCondition: PageCondition
    "並び替え条件"
    edgeOrder: EdgeOrder
    "文字列フィルタ条件"
    filterWord: TextFilterCondition
  ): CustomerConnection
}

這是此次從前端呼叫的查詢。
各個元素的說明在後面提及。
根據要求,該查詢應包含以下欄位。

    • 前ページ、次ページ遷移の条件を含む「ページング条件」

 

    • 各要素の昇順、後述並べ替え条件を含む「並べ替え条件」

 

    文字列検索フィルタ(部分一致)用の「文字列フィルタ条件」

而且,查询的返回值符合Relay的Connection格式(稍后会详细介绍)。

顾客连接

"ページングを伴う結果返却用"
type CustomerConnection implements Connection {
  "ページ情報"
  pageInfo: PageInfo!
  "検索結果一覧(※カーソル情報を含む)"
  edges: [CustomerEdge!]!
  "検索結果の全件数"
  totalCount: Int64!
}

用于存储customerConnection查询结果的容器。
遵循Relay规范(或许不完全符合,仅供参考)。

为了通用性,它实现了Connection接口(稍后详述)。
关于页面信息(PageInfo),稍后会提到。

客户边缘

"検索結果(※カーソル情報を含む)"
type CustomerEdge implements Edge {
  node: Customer!
  cursor: Cursor!
}

已经实现了Edge接口(稍后提到),以便能够广泛应用。
表示一条搜索结果。其中包含了数据的特定信息“游标”。
关于游标类型的细节稍后会提到。

顾客

type Customer implements Node {
  "ID"
  id: ID!
  "名前"
  name: String!
  "年齢"
  age: Int!
}

表示一位顾客。

■分页.graphql

页面条件

表示分页条件的类型,用于传递查询的查询条件。

"ページング条件"
input PageCondition {
    "前ページ遷移条件"
    backward: BackwardPagination
    "次ページ遷移条件"
    forward: ForwardPagination
    "現在ページ番号(今回のページング実行前の時点のもの)"
    nowPageNo: Int64!
    "1ページ表示件数"
    initialLimit: Int64!
}

倒序翻页

「前一页」在页面跳转时传递的分页条件。

"前ページ遷移条件"
input BackwardPagination {
    "取得件数"
    last: Int64!
    "取得対象識別用カーソル(※前ページ遷移時にこのカーソルよりも前にあるレコードが取得対象)"
    before: Cursor!
}

页面向前翻页

「次のページ」に移動する際に渡されるページネーションの条件。

"次ページ遷移条件"
input ForwardPagination {
    "取得件数"
    first: Int64!
    "取得対象識別用カーソル(※次ページ遷移時にこのカーソルよりも後ろにあるレコードが取得対象)"
    after: Cursor!
}

光标

光标存储DB搜索时与表名相结合后进行URL编码的ROW_NUMBER值。稍后提及。

"カーソル(1レコードをユニークに特定する識別子)"
scalar Cursor

■订购.图灵

边缘排序

表示排序条件的类型,传递到查询中。

"並び替え条件"
input EdgeOrder {
    "並べ替えキー項目"
    key: OrderKey!
    "ソート方向"
    direction: OrderDirection!
}

订单键

"""
並べ替えのキー

【検討経緯】
汎用的な構造、かつ、タイプセーフにしたく、interface で定義の上、機能毎に input ないし enum で実装しようとした。
しかし、input は interface を実装できない仕様だったので諦めた。
enum に継承機能があればよかったが、それもなかった。
union で CustomerOrderKey や(増えたら)他の機能の並べ替えのキーも | でつなぐ方法も考えたが、
union を input に要素として持たせることはできない仕様だったので、これも諦めた。
とはいえ、並べ替えも共通の仕組みとして提供したく、結果として機能毎の enum フィールドを共通の input 内に列挙していく形にした。
"""
input OrderKey {
    "ユーザー一覧の並べ替えキー"
    customerOrderKey: CustomerOrderKey
}

排序方向

"並べ替え方向"
enum OrderDirection {
    "昇順"
    ASC
    "降順"
    DESC
}

■ 文本过滤器.图灵

文本过滤条件

表示传递给查询的“字符串过滤条件”的类型。

"文字列フィルタ条件"
input TextFilterCondition {
    "フィルタ文字列"
    filterWord: String!
    "マッチングパターン"
    matchingPattern: MatchingPattern!
}

匹配模式

"マッチングパターン種別(※要件次第で「前方一致」や「後方一致」も追加)"
enum MatchingPattern {
    "部分一致"
    PARTIAL_MATCH
    "完全一致"
    EXACT_MATCH
}

■连接图形数据库

(Note: The paraphrase provided above is in simplified Chinese. If you want a paraphrase in traditional Chinese, please let me know.)

scalar Int64

"ページングを伴う結果返却用"
interface Connection {
    "ページ情報"
    pageInfo: PageInfo!
    "結果一覧(※カーソル情報を含む)"
    edges: [Edge!]!
    "検索結果の全件数"
    totalCount: Int64!
}

"ページ情報"
type PageInfo {
    "次ページ有無"
    hasNextPage: Boolean!
    "前ページ有無"
    hasPreviousPage: Boolean!
    "当該ページの1レコード目"
    startCursor: Cursor!
    "当該ページの最終レコード"
    endCursor: Cursor!
}

"検索結果一覧(※カーソル情報を含む)"
interface Edge {
    "Nodeインタフェースを実装したtypeなら代入可能"
    node: Node!
    cursor: Cursor!
}

后端

主函数

这一点不是本次讨论的主题,只需要提供来源。

package main

import (
    "log"
    "net/http"
    "time"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/go-chi/chi"
    "github.com/jmoiron/sqlx"
    _ "github.com/lib/pq"
    "github.com/rs/cors"
    "github.com/sky0621/study-graphql/try01/src/backend/graph"
    "github.com/sky0621/study-graphql/try01/src/backend/graph/generated"
    "github.com/volatiletech/sqlboiler/v4/boil"
)

func main() {
    // MEMO: ローカルでしか使わないので、ベタ書き
    dsn := "host=localhost port=25432 dbname=study-graphql-local-db user=postgres password=yuckyjuice sslmode=disable"
    db, err := sqlx.Connect("postgres", dsn)
    if err != nil {
        log.Fatal(err)
    }

    boil.DebugMode = true

    var loc *time.Location
    loc, err = time.LoadLocation("Asia/Tokyo")
    if err != nil {
        log.Fatal(err)
    }
    boil.SetLocation(loc)

    r := chi.NewRouter()
    r.Use(corsHandlerFunc())
    r.Handle("/", playground.Handler("GraphQL playground", "/query"))
    r.Handle("/query",
        handler.NewDefaultServer(
            generated.NewExecutableSchema(
                generated.Config{
                    Resolvers: &graph.Resolver{
                        DB: db,
                    },
                },
            ),
        ),
    )

    if err := http.ListenAndServe(":8080", r); err != nil {
        panic(err)
    }
}

func corsHandlerFunc() func(h http.Handler) http.Handler {
    return cors.New(cors.Options{
        AllowedOrigins:   []string{"*"},
        AllowedMethods:   []string{"GET", "POST"},
        AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
        ExposedHeaders:   []string{"Link"},
        AllowCredentials: true,
        MaxAge:           300, // Maximum value not ignored by any of major browsers
    }).Handler
}

获取支持分页的客户列表解析器

这是负责本次主题的资源。

大致上说,以下步骤需要完成:
1. 定义一个用于构建搜索用SQL语句所需参数的结构体。
2. 如果从GraphQL客户端传入了搜索字符串的指定,则将其反映在上述结构体中。
3. 如果从GraphQL客户端传入了分页的指定(即指示是初始页面显示还是向前或向后移动到某一页),则将其反映在上述结构体中。
4. 如果从GraphQL客户端传入了排序的指定,则将其反映在上述结构体中。
5. 执行搜索用SQL语句。
6. 将搜索结果转换为Relay格式并返回。

func (r *queryResolver) CustomerConnection(ctx context.Context, pageCondition *model.PageCondition, edgeOrder *model.EdgeOrder, filterWord *model.TextFilterCondition) (*model.CustomerConnection, error) {
    /*
     * SQL構築に必要な各種要素の保持用
     */
    params := searchParam{
        // 情報取得先のテーブル名
        tableName: boiled.TableNames.Customer,

        // 並び順のデフォルトはIDの降順
        orderKey:       boiled.CustomerColumns.ID,
        orderDirection: model.OrderDirectionDesc.String(),
    }

    /*
     * 検索文字列フィルタ設定
     * TODO: 複数カラムにフィルタを適用したい場合など、ここで AND でつなぐか buildSearchQueryMod() を拡張するか検討が必要
     */
    filter := filterWord.MatchString()
    if filter != "" {
        params.baseCondition = fmt.Sprintf("%s LIKE '%s'", boiled.CustomerColumns.Name, filter)
    }

    /*
     * ページング設定
     */
    if pageCondition.IsInitialPageView() {
        // ページング指定無しの初期ページビュー
        params.rowNumFrom = 1
        params.rowNumTo = pageCondition.InitialLimit
    } else {
        // 前ページへの遷移指示
        if pageCondition.Backward != nil {
            key, err := decodeCustomerCursor(pageCondition.Backward.Before)
            if err != nil {
                log.Print(err)
                return nil, err
            }
            params.rowNumFrom = key - pageCondition.Backward.Last
            params.rowNumTo = key - 1
        }
        // 次ページへの遷移指示
        if pageCondition.Forward != nil {
            key, err := decodeCustomerCursor(pageCondition.Forward.After)
            if err != nil {
                log.Print(err)
                return nil, err
            }
            params.rowNumFrom = key + 1
            params.rowNumTo = key + pageCondition.Forward.First
        }
    }

    /*
     * 並び順の指定
     */
    if edgeOrder.CustomerOrderKeyExists() {
        params.orderKey = edgeOrder.Key.CustomerOrderKey.String()
        params.orderDirection = edgeOrder.Direction.String()
    }

    /*
     * 検索実行
     */
    var records []*CustomerWithRowNum
    if err := boiled.Customers(buildSearchQueryMod(params)).Bind(ctx, r.DB, &records); err != nil {
        log.Print(err)
        return nil, err
    }

    /*
     * ページング後の次ページ、前ページの存在有無判定のために必要な
     * 検索文字列フィルタ適用後の結果件数保持用
     */
    var totalCount int64 = 0
    {
        var err error
        if filter == "" {
            totalCount, err = boiled.Customers().Count(ctx, r.DB)
        } else {
            totalCount, err = boiled.Customers(qm.Where(boiled.CustomerColumns.Name+" LIKE ?",
                filterWord.MatchString())).Count(ctx, r.DB)
        }
        if err != nil {
            log.Print(err)
            return nil, err
        }
    }

    /*
     * Relay返却形式
     */
    result := &model.CustomerConnection{
        TotalCount: totalCount,
    }

    /*
     * 検索結果をEdgeスライス形式に変換
     */
    var edges []*model.CustomerEdge
    for _, record := range records {
        edges = append(edges, &model.CustomerEdge{
            Node: &model.Customer{
                ID:   strconv.Itoa(int(record.ID)),
                Name: record.Name,
                Age:  record.Age,
            },
            Cursor: createCursor("customer", record.RowNum),
        })
    }
    result.Edges = edges

    // 検索結果全件数と1ページあたりの表示件数から、今回の検索による総ページ数を算出
    totalPage := pageCondition.TotalPage(totalCount)

    /*
     * クライアント側での画面表示及び次回ページングに必要な情報
     */
    pageInfo := &model.PageInfo{
        HasNextPage:     (totalPage - pageCondition.MoveToPageNo()) >= 1, // 遷移後も、まだ先のページがあるか
        HasPreviousPage: pageCondition.MoveToPageNo() > 1,                // 遷移後も、まだ前のページがあるか
    }
    if len(edges) > 0 {
        pageInfo.StartCursor = edges[0].Cursor
        pageInfo.EndCursor = edges[len(edges)-1].Cursor
    }
    result.PageInfo = pageInfo

    return result, nil
}

构建用于搜索的SQL查询语句所需的参数的结构体。

    params := searchParam{
        // 情報取得先のテーブル名
        tableName: boiled.TableNames.Customer,

        // 並び順のデフォルトはIDの降順
        orderKey:       boiled.CustomerColumns.ID,
        orderDirection: model.OrderDirectionDesc.String(),
    }

以上所述的实体是以下内容。基本上根据从GraphQL客户端传递的条件进行覆盖,但对于在未指定时需要默认值的情况,在开头进行初始化。
(在构建基于传入searchParam的SQL语句的函数中,实际上也进行了初始化)

type searchParam struct {
    orderKey       string
    orderDirection string
    tableName      string
    baseCondition  string
    rowNumFrom     int64
    rowNumTo       int64
}

搜索字符串过滤器设置

    /*
     * 検索文字列フィルタ設定
     * TODO: 複数カラムにフィルタを適用したい場合など、ここで AND でつなぐか buildSearchQueryMod() を拡張するか検討が必要
     */
    filter := filterWord.MatchString()
    if filter != "" {
        params.baseCondition = fmt.Sprintf("%s LIKE '%s'", boiled.CustomerColumns.Name, filter)
    }

下面的函数用于构建搜索字符串。

func (c *TextFilterCondition) MatchString() string {
    if c == nil {
        return ""
    }
    if c.FilterWord == "" {
        return ""
    }
    matchStr := "%" + c.FilterWord + "%"
    if c.MatchingPattern == MatchingPatternExactMatch {
        matchStr = c.FilterWord
    }
    return matchStr
}

匹配模式,暂时只准备了完全匹配和部分匹配,但如果需要的话,可以增加前向匹配和后向匹配。

// マッチングパターン種別(※要件次第で「前方一致」や「後方一致」も追加)
type MatchingPattern string

const (
    // 部分一致
    MatchingPatternPartialMatch MatchingPattern = "PARTIAL_MATCH"
    // 完全一致
    MatchingPatternExactMatch MatchingPattern = "EXACT_MATCH"
)

翻译成中文后如下:

分页设置

当用户打开初始页面时(也就是打开屏幕,并且改变排序选项或更改显示列表数量的情况下),请参考下列内容。

    if pageCondition.IsInitialPageView() {
        // ページング指定無しの初期ページビュー
        params.rowNumFrom = 1
        params.rowNumTo = pageCondition.InitialLimit
    } else {
        〜〜〜
    }

判断是否为初始页面可以采用以下方法。

func (c *PageCondition) IsInitialPageView() bool {
    if c == nil {
        return true
    }
    return c.Backward == nil && c.Forward == nil
}

接下来,页面之间的跳转路径如下:

        〜〜〜
    } else {
        // 前ページへの遷移指示
        if pageCondition.Backward != nil {
            key, err := decodeCustomerCursor(pageCondition.Backward.Before)
            if err != nil {
                log.Print(err)
                return nil, err
            }
            params.rowNumFrom = key - pageCondition.Backward.Last
            params.rowNumTo = key - 1
        }
        // 次ページへの遷移指示
        if pageCondition.Forward != nil {
            key, err := decodeCustomerCursor(pageCondition.Forward.After)
            if err != nil {
                log.Print(err)
                return nil, err
            }
            params.rowNumFrom = key + 1
            params.rowNumTo = key + pageCondition.Forward.First
        }
    }

在这里重要的是解码游标。
游标以URL编码的形式为”customer+ROW_NUMBER”。
ROW_NUMBER是一个预设值,表示对于搜索结果,不论筛选内容还是升序或降序排序,都假定为连续编号的序号。

decodeCustomerCursor(~~~~)

通过以下方式进行解码。

func decodeCustomerCursor(cursor string) (int64, error) {
    modelName, key, err := decodeCursor(cursor)
    if err != nil {
        return 0, err
    }
    if modelName != "customer" {
        return 0, errors.New("not customer")
    }
    return key, nil
}

“decodeCursor(~~~~)”的定义如下。

const cursorSeps = "#####"

func decodeCursor(cursor string) (string, int64, error) {
    byteArray, err := base64.RawURLEncoding.DecodeString(cursor)
    if err != nil {
        return "", 0, err
    }
    elements := strings.SplitN(string(byteArray), cursorSeps, 2)
    key, err := strconv.Atoi(elements[1])
    if err != nil {
        return "", 0, err
    }
    return elements[0], int64(key), nil
}

请参考以下关于如何根据上述逻辑获取当前要显示页面的记录的示意图。

現在、以下の状態とする。
・1ページあたりの表示件数は、5件
・IDの降順で並んだ状態
・2ページ目を表示している状態

          1ページ目     2ページ目      3ページ目
 ROW_NUMBER: [1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]

■「前ページ」に遷移する指示の場合
 1ページ目の 1 〜 5 のレコードが欲しい。
 pageCondition.Backward.Before をデコードしたROW_NUMBERに(2ページ目の先頭レコードを示す)[6] が入っている。
 また、pageCondition.Backward.Last には1ページあたりの表示件数 [5件] が入っている。
 よって、以下の計算で取得したい範囲を決める。
 From:6 - 5 = 1
 To :6 - 1 = 5

■「次ページ」に遷移する指示の場合
 3ページ目の 11 〜 15 のレコードが欲しい。
 pageCondition.Forward.After をデコードしたROW_NUMBERに(2ページ目の末尾レコードを示す)[10] が入っている。
 また、pageCondition.Forward.First には1ページあたりの表示件数 [5件] が入っている。
 よって、以下の計算で取得したい範囲を決める。
 From:10 + 1 = 11
 To :10 + 5 = 15

排序顺序的规定

    if edgeOrder.CustomerOrderKeyExists() {
        params.orderKey = edgeOrder.Key.CustomerOrderKey.String()
        params.orderDirection = edgeOrder.Direction.String()
    }

CustomerOrderKeyExists()的定义如下。

func (o *EdgeOrder) CustomerOrderKeyExists() bool {
    if o == nil {
        return false
    }
    if o.Key == nil {
        return false
    }
    if o.Key.CustomerOrderKey == nil {
        return false
    }
    return o.Key.CustomerOrderKey.IsValid()
}

以下是有关「顾客」信息的排序关键字候选项。

type CustomerOrderKey string

const (
    // ID
    CustomerOrderKeyID CustomerOrderKey = "ID"
    // ユーザー名
    CustomerOrderKeyName CustomerOrderKey = "NAME"
    // 年齢
    CustomerOrderKeyAge CustomerOrderKey = "AGE"
)

执行搜索

    var records []*CustomerWithRowNum
    if err := boiled.Customers(buildSearchQueryMod(params)).Bind(ctx, r.DB, &records); err != nil {
        log.Print(err)
        return nil, err
    }

首先,定义了一个名为 CustomerWithRowNum 的结构体,其作为切片的类型为 records。
boiled.Customer 是由SQL Boiler从数据库表定义自动生成的结构体。
通过封装,将其以名为 RowNum 的形式保存为 SQL 语句中的 row_num。
通过这种方式,在接收执行 SQL 语句的结果时,无需每次都创建符合表定义的结构体,只需根据需要灵活添加所需的元素即可。

type CustomerWithRowNum struct {
    RowNum          int64 `boil:"row_num"`
    boiled.Customer `boil:",bind"`
}

接下来,buildSearchQueryMod(params)的定义如下。

// TODO: とりあえず雑に作った。複数テーブルへの対応等、どこまで汎用性を持たせるかは要件次第。
func buildSearchQueryMod(p searchParam) qm.QueryMod {
    if p.baseCondition == "" {
        p.baseCondition = "true"
    }
    q := `
        SELECT row_num, * FROM (
            SELECT ROW_NUMBER() OVER (ORDER BY %s %s) AS row_num, *
            FROM %s
            WHERE %s
        ) AS tmp
        WHERE row_num BETWEEN %d AND %d
    `
    sql := fmt.Sprintf(q,
        p.orderKey, p.orderDirection,
        p.tableName,
        p.baseCondition,
        p.rowNumFrom, p.rowNumTo,
    )
    return qm.SQL(sql)
}

使用PostgreSQL的Window函数(ROW_NUMBER()),对应用了指定字符串搜索过滤器和排序的结果进行编号。
从这个结果中提取所需范围的ROW_NUMBER。
通过ROW_NUMBER的范围指定,在“上一页”或“下一页”中,无论排序元素是什么,是升序还是降序,都可以以相同的机制获取。

将搜索结果转换为Relay格式并返回

應用搜索字串過濾後的結果數量

根据评论中所提到的,通过搜索字符串过滤器获取筛选搜索结果的数量,在页面跳转后,仍然存在前一个(或下一个)页面(* 通过返回此信息,前端可以在UI设计上控制[Prev]按钮和[Next]按钮的启用或禁用。

    /*
     * ページング後の次ページ、前ページの存在有無判定のために必要な
     * 検索文字列フィルタ適用後の結果件数保持用
     */
    var totalCount int64 = 0
    {
        var err error
        if filter == "" {
            totalCount, err = boiled.Customers().Count(ctx, r.DB)
        } else {
            totalCount, err = boiled.Customers(qm.Where(boiled.CustomerColumns.Name+" LIKE ?",
                filterWord.MatchString())).Count(ctx, r.DB)
        }
        if err != nil {
            log.Print(err)
            return nil, err
        }
    }

使用SQL Boiler时,只需使用自动生成的源码,以 boiled.Customers().Count(ctx, r.DB) 的方式就能获取顾客表的全部记录数。
如果想要添加搜索条件,只需按照上述源码的方式,在 boiled.Customers(xxxx) 的xxxx部分以SQL Boiler提供的写法来编写即可。

转交物品归还方式

在 Relay 所需的返回格式中,所需要的只有 “edges” 和 “pageInfo”,但基于 UI 设计的需要,通常也希望包含数量信息,因此也定义了 “totalCount”。
详见:https://relay.dev/graphql/connections.htm#sec-Connection-Types

    /*
     * Relay返却形式
     */
    result := &model.CustomerConnection{
        TotalCount: totalCount,
    }
边缘

光标的解码就像之前提到的那样,但是编码的部分在这里出现。
为每个搜索结果生成一个光标,从ROW_NUMBER生成光标。
通过将其返回给前端,前端可以通过简单地将光标作为参数附加,实现分页,而无需特别指定获取范围或其他操作即可实现下一页的页面跳转。

    /*
     * 検索結果をEdgeスライス形式に変換
     */
    var edges []*model.CustomerEdge
    for _, record := range records {
        edges = append(edges, &model.CustomerEdge{
            Node: &model.Customer{
                ID:   strconv.Itoa(int(record.ID)),
                Name: record.Name,
                Age:  record.Age,
            },
            Cursor: createCursor("customer", record.RowNum),
        })
    }
    result.Edges = edges

CustomerEdge 采用以下结构。

// 検索結果一覧(※カーソル情報を含む)
type CustomerEdge struct {
    Node   *Customer `json:"node"`
    Cursor string    `json:"cursor"`
}

modelName 和 key 是 createCursor 函数的定义。

const cursorSeps = "#####"

func createCursor(modelName string, key int64) string {
    return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s%s%d", modelName, cursorSeps, key)))
}
页面信息 (Page information)

由于存在在这里计算并返回的信息,前端处理被轻量化。
所需的页面信息如下:

// ページ情報
type PageInfo struct {
    // 次ページ有無
    HasNextPage bool `json:"hasNextPage"`
    // 前ページ有無
    HasPreviousPage bool `json:"hasPreviousPage"`
    // 当該ページの1レコード目
    StartCursor string `json:"startCursor"`
    // 当該ページの最終レコード
    EndCursor string `json:"endCursor"`
}

首先,为了判断是否存在下一页,需要计算出“总页数”。

    // 検索結果全件数と1ページあたりの表示件数から、今回の検索による総ページ数を算出
    totalPage := pageCondition.TotalPage(totalCount)

TotalPage(~~)的定义如下。

func (c *PageCondition) TotalPage(totalCount int64) int64 {
    if c == nil {
        return 0
    }
    var targetCount int64 = 0
    if c.Backward == nil && c.Forward == nil {
        targetCount = c.InitialLimit
    } else {
        if c.Backward != nil {
            targetCount = c.Backward.Last
        }
        if c.Forward != nil {
            targetCount = c.Forward.First
        }
    }
    return int64(math.Ceil(float64(totalCount) / float64(targetCount)))
}

利用以上的信息,可以判断出“下一页的存在与否”如下。

    /*
     * クライアント側での画面表示及び次回ページングに必要な情報
     */
    pageInfo := &model.PageInfo{
        HasNextPage:     (totalPage - pageCondition.MoveToPageNo()) >= 1, // 遷移後も、まだ先のページがあるか
        HasPreviousPage: pageCondition.MoveToPageNo() > 1,                // 遷移後も、まだ前のページがあるか
    }

上面提及的MoveToPageNo()的定义也可用于判断前一页的存在与否。

func (c *PageCondition) MoveToPageNo() int64 {
    if c == nil {
        return 1 // 想定外のため初期ページ
    }
    if c.Backward == nil && c.Forward == nil {
        return c.NowPageNo // 前にも後ろにも遷移しないので
    }
    if c.Backward != nil {
        if c.NowPageNo <= 2 {
            return 1
        }
        return c.NowPageNo - 1
    }
    if c.Forward != nil {
        return c.NowPageNo + 1
    }
    return 1 // 想定外のため初期ページ
}

最后,从此次搜索的页面记录中分别提取出第一个和最后一个游标。

    if len(edges) > 0 {
        pageInfo.StartCursor = edges[0].Cursor
        pageInfo.EndCursor = edges[len(edges)-1].Cursor
    }
    result.PageInfo = pageInfo

这个光标在前端中,在下一页页面跳转时,如果要跳转到“上一页”,将使用“StartCursor”,如果要跳转到“下一页”,将使用“EndCursor”。

PageCondition
    Backward
        Before  ・・・ StartCursor
    Forward
        After   ・・・ EndCursor

前端

以下是源代码。

https://github.com/sky0621/study-graphql/tree/v0.10.0/try01/src/frontend

由于这篇文章的结构与之前写过的文章相同,所以省略了解释。
请参考下文。
在GraphQL中使用Relay样式实现分页(第二部分:前端)。

确认行动

在首次页面显示时,按照ID的降序排列

数据库的状态

Screenshot at 2020-11-16 23-12-21.png

画面过渡效果

第一页
screenshot-localhost_3000-2020.11.16-23_17_37.png
第二页
screenshot-localhost_3000-2020.11.16-23_17_55.png
第三页
screenshot-localhost_3000-2020.11.16-23_18_09.png
Screenshot at 2020-11-16 23-21-30.png
回到第二页
screenshot-localhost_3000-2020.11.16-23_24_45.png

将ID按升序进行更改

数据库的状态

Screenshot at 2020-11-16 23-25-51.png

画面的转换效果

第一页
screenshot-localhost_3000-2020.11.16-23_26_42.png
第二页
screenshot-localhost_3000-2020.11.16-23_26_54.png
第三页
screenshot-localhost_3000-2020.11.16-23_27_05.png
返回第二页
screenshot-localhost_3000-2020.11.16-23_27_18.png

按照Name的降序进行更改

数据库的状态

Screenshot at 2020-11-16 23-29-36.png

画面过渡效果

第一页
screenshot-localhost_3000-2020.11.16-23_31_17.png
第二页
screenshot-localhost_3000-2020.11.16-23_31_29.png
第三页
screenshot-localhost_3000-2020.11.16-23_31_40.png
回到第二页
screenshot-localhost_3000-2020.11.16-23_32_22.png

将年龄按升序排列,并将每页显示数量更改为”10个”。

数据库的状态

Screenshot at 2020-11-16 23-33-46.png

画面切换的结果

第一页
screenshot-localhost_3000-2020.11.16-23_35_09.png
请将下面这句话用中文进行同义转述:

第二页

screenshot-localhost_3000-2020.11.16-23_35_22.png
回到第一页
screenshot-localhost_3000-2020.11.16-23_35_34.png

按照Name进行升序排列,并更改为以”k”作为过滤器。

数据库的状态

Screenshot at 2020-11-16 23-39-52.png

画面切换的结果

screenshot-localhost_3000-2020.11.16-23_40_35.png

总结

目前,已经实现了”客户列表”页面的分页功能(以及元素排序和字符串搜索过滤的组合)。作为后端实现,现在可以在不像上一次那样将排序元素的值储存在光标中(这是一种极端的做法),而是可以统一以相同的格式执行SQL查询。

然而,只是简单地将它们大规模复制到每个功能中是太繁琐了,所以在实际使用时需要尽可能将其模板化。

此外,在代码中还有一些注释中标记的待办事项,问题还是有很多的。

bannerAds