20201116のGoに関する記事は8件です。

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

お題

以前、以下の記事でRelayスタイルのお試し実装をした。

ただ、「前後ページへの移動」と「任意の項目での昇順・降順並べ替え」という要件の組み合わせが想像以上の実装の煩雑さを生み、かなり消化不良だった。
今回、使用するRDBをPostgreSQL前提とするアーキテクチャ上の縛りを入れることで、バックエンド側の実装を(前回よりは)簡略化できないか試してみた。

今回のサンプル実装で使った言語やライブラリ等

なお、これら個々の言語やライブラリ等についての説明はしません。

フロントエンド

前回のフロントエンドの記事と同じ。

バックエンド

その他

想定する仕様

何かしらの情報(今回はCustomer(顧客))を一覧表示するページで以下の機能を持つ。

  • 文字列検索フィルタ(部分一致検索)
  • 前ページ、次ページ遷移
  • 一覧表示要素での昇順、降順並べ替え
  • 一覧表示件数の変更

単純に初期ページ表示時に全件取得して、オンメモリでの前ページ、次ページ遷移ではなく、都度(1ページに必要な分だけ)検索。
以下を実行した時は、ページングの途中(例えば2ページ目を表示中)だったとしても、1ページ目の表示に戻る。

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

画面イメージ

初期ページ表示時(デフォルトではIDの降順で並んでいる仕様)

screenshot-localhost_3000-2020.11.15-23_36_44.png

2ページ目に遷移時

screenshot-localhost_3000-2020.11.16-00_59_48.png

検索フィルタ使用時

screenshot-localhost_3000-2020.11.16-01_01_41.png
screenshot-localhost_3000-2020.11.16-01_02_42.png

名前の昇順で並べ替え

1ページ目
screenshot-localhost_3000-2020.11.16-01_03_40.png
2ページ目
screenshot-localhost_3000-2020.11.16-01_03_54.png
3ページ目
screenshot-localhost_3000-2020.11.16-01_04_06.png

一覧表示件数を 10件 に変更(+「Age」の降順)

1ページ目
screenshot-localhost_3000-2020.11.16-01_06_09.png

2ページ目
screenshot-localhost_3000-2020.11.16-01_06_21.png

関連記事索引

開発環境

# OS - 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

v0.13.0

IDE - Goland

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

今回の全ソース

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

実践

DB

PostgreSQL v13 をDocker Compseで動かしておく。(ローカルでしか使わないのでパスワード等、ベタ書き)

docker-compose.yml
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/

上記DBに「customer」テーブルを作成。

CREATE TABLE customer (
  id bigserial NOT NULL,
  name varchar(64) NOT NULL,
  age int NOT NULL,
  PRIMARY KEY (id)
);

customerテーブルのレコードは下記。
※画面の初期表示時と合わせて、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

■schema.graphql

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

schema {
    query: Query
}

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

■customer.graphql

customerConnection クエリ

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

これが、今回、フロントエンドからコールされるクエリ。
それぞれの要素の説明は後述。
要件に即して、以下のフィールドを持つ。

  • 前ページ、次ページ遷移の条件を含む「ページング条件
  • 各要素の昇順、後述並べ替え条件を含む「並べ替え条件
  • 文字列検索フィルタ(部分一致)用の「文字列フィルタ条件

そして、クエリの返却値は、Relayに準拠したConnection形式(これも後述)になっている。

CustomerConnection

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

customerConnection クエリの実行結果格納用。
Relay仕様に準拠(というレベルではないかも。参考レベル。)。

汎用的に扱えるようにConnectionインタフェース(後述)を実装している。
ページ情報(PageInfo)については後述。

CustomerEdge

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

汎用的に扱えるようにEdgeインタフェース(後述)を実装している。
1件分の検索結果をあらわす。データ特定用の「カーソル」という情報を持つ。
Cursorタイプについては後述。

Customer

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

顧客1件分をあらわす。

■pagination.graphql

PageCondition

クエリに渡す「ページング条件」をあらわす型。

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

BackwardPagination

「前ページ」遷移時に渡されるページング条件。

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

ForwardPagination

「次ページ」遷移時に渡されるページング条件。

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

Cursor

カーソルには、DB検索時に振るROW_NUMBERをテーブル名と組み合わせたあとURLエンコードした値を格納する。
後述。

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

■order.graphql

EdgeOrder

クエリに渡す「並べ替え条件」をあらわす型。

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

OrderKey

"""
並べ替えのキー

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

OrderDirection

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

■text_filter.graphql

TextFilterCondition

クエリに渡す「文字列フィルタ条件」をあらわす型。

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

MatchingPattern

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

■connection.graphql

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!
}

バックエンド

main関数

このへんは今回の主題ではないのでソースの提示だけ。

server.go
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形式に変換して返却

graph/customer.resolvers.go(抜粋)
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文を構築する関数の中でも、実は初期化してたりするのだけど)

search.go
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)
    }

検索用の文字列は以下の関数で構築。

model/expansion.go
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
}

マッチングパターンは、とりあえず完全一致と部分一致だけ用意してるけど、必要に応じて前方一致や後方一致も増やせばいい。

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

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

ページング設定

初期ページ表示時(要するに、画面を最初に開いた時や、並び替えの項目を変えたり、一覧表示件数を変えたりしたタイミングを想定)の時は下記。

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

初期ページかどうかは以下のようにして判断。

model/expansion.go
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
        }
    }

ここで重要なのがカーソルのデコード。
カーソルは「customer+ ROW_NUMBER」の形でURLエンコードされている。
ROW_NUMBERは、検索の内容(絞り込みだろうと並びが昇順でも降順でも)がどうであれ、その結果に対して連番が振られる想定の番号である前提。

decodeCustomerCursor(~~~~)

以下のようにしてデコード。

graph/customer.go
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(~~~~)の定義は下記。

graph/util.go
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()の定義は下記。

model/expansion.go
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()
}

「顧客」情報に関する並べ替えのキー候補は下記。

model/modege_gen.go
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
    }

まず、records としてスライスの型としている CustomerWithRowNum の構造体は下記。
boiled.Customer は、SQL BoilerがDBのテーブル定義から自動生成する構造体。
これをラップして、SQL文で row_num として受け取る ROW_NUMBER を RowNum という名前で保持。
こうすることで、SQL文の実行結果を受け取る時に、テーブル定義に即した構造体をいちいち作る必要もなく、追加したい要素だけよしなに追加できる。

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

続いて、 buildSearchQueryMod(params) の定義は下記。

graph/search.go
// 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) と書くだけで customer テーブルの全件数が取得できる。
検索条件を追加したい場合は上記ソースの通り、 boiled.Customers(xxxx)xxxx の部分にSQL Boilerが用意した記述方法で書けばいいだけ。

Relay返却形式

Relayが求める返却形式では、必要なのは「edges」と「pageInfo」だけなのだけど、UIデザイン上、件数も普通は欲しいよねということで totalCount も定義。
https://relay.dev/graphql/connections.htm#sec-Connection-Types

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

カーソルのデコードは先述の通りだけど、エンコードの方は、ここで登場。
検索結果1件1件に対して 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 は以下の構造。

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

createCursor(modelName, key) の定義は下記。

graph/util.go
const cursorSeps = "#####"

func createCursor(modelName string, key int64) string {
    return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s%s%d", modelName, cursorSeps, key)))
}
pageInfo

ここで計算して返却する情報があるから、フロントエンドでの処理が軽量化される。
ページ情報として必要なのは下記。

model/models_gen.go
// ページ情報
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(~~)の定義は下記。

model/expansion.go
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()の定義は下記。

model/expansion.go
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の降順)

DBの状態

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

画面遷移結果

1ページ目

screenshot-localhost_3000-2020.11.16-23_17_37.png

2ページ目

screenshot-localhost_3000-2020.11.16-23_17_55.png

3ページ目

screenshot-localhost_3000-2020.11.16-23_18_09.png

3ページ目のGraphQLレスポンスデータ
Screenshot at 2020-11-16 23-21-30.png

2ページ目に戻る

screenshot-localhost_3000-2020.11.16-23_24_45.png

IDの昇順に変更

DBの状態

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

画面遷移結果

1ページ目

screenshot-localhost_3000-2020.11.16-23_26_42.png

2ページ目

screenshot-localhost_3000-2020.11.16-23_26_54.png

3ページ目

screenshot-localhost_3000-2020.11.16-23_27_05.png

2ページ目に戻る

screenshot-localhost_3000-2020.11.16-23_27_18.png

Nameの降順に変更

DBの状態

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

画面遷移結果

1ページ目

screenshot-localhost_3000-2020.11.16-23_31_17.png

2ページ目

screenshot-localhost_3000-2020.11.16-23_31_29.png

3ページ目

screenshot-localhost_3000-2020.11.16-23_31_40.png

2ページ目に戻る

screenshot-localhost_3000-2020.11.16-23_32_22.png

Ageの昇順、及び、1ページ表示件数「10件」に変更

DBの状態

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

画面遷移結果

1ページ目

screenshot-localhost_3000-2020.11.16-23_35_09.png

2ページ目

screenshot-localhost_3000-2020.11.16-23_35_22.png

1ページ目に戻る

screenshot-localhost_3000-2020.11.16-23_35_34.png

Nameの昇順、及び、「k」でフィルタに変更

DBの状態

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

画面遷移結果

screenshot-localhost_3000-2020.11.16-23_40_35.png

まとめ

これでひとまず「顧客一覧」というページにおけるページング(及び要素別の並べ替えや文字列検索フィルタの組み合わせ)が実現できた。
バックエンドの実装としても、前回のように並べ替え要素の値をCursorに持つ(という暴挙)こともなく一律同じ形式でSQLが叩けるようになった。
ただ、これを機能毎に量産していくのは、あまりにもボイラープレートが過ぎるので、実際に使う際はなるべくテンプレート化しておく必要がある。
あと、ソース中にもコメントでTODO書いてたりするけど、いろいろ課題はある。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goで香水を実装してみる

はじめに

この記事は,学校の帰りに思いついたものを勢いだけで実装したクソコードを載せているだけのネタ記事です.
間違ってもこれを見てGoの勉強をしようなどと思わないでください.

若干 「Javaで湯婆婆を実装してみる」にインスパイアされている部分があります.

実装する

コード

main.go
package main

import (
    "fmt"
)

type human struct {
    Dolce   bool
    Gabbana bool
    Kousui  perfume
}

type perfume struct {
}

func (p *perfume) say() {
    fmt.Println(`僕がフラれるんだ`)
}

func main() {

    Kimi := human{true, true, perfume{}}

    if Kimi.Dolce && Kimi.Gabbana {
        Kimi.Kousui.say()
    }
}

これが実際のコードです.fmtパッケージしか使っていないので,基本的な環境構築ができていればこのままコピペで動くはずです.
今回実装したのは,曲の中で最も有名であろう,「君のドルチェ&ガッバーナの その香水のせいだよ」の部分になります.実装するにあたって,歌詞を調べてみると他にも実装したら面白そうな部分があったので,暇なときにでも完全版を作りたいです(適当)

解説

構造体

type human struct {
    Dolce   bool
    Gabbana bool
    Kousui  perfume
}

type perfume struct {
}

今回,使用した構造体は

  • human
  • perfume

の2つです.
humanは歌詞中の「君」を表現するために使用しました.
perfumeは名前の通り「香水」の部分です.この機能なら構造体にする必要もなかったのですが,次項で解説するメソッドを使用したかったため構造体にしています.

条件式

    if Kimi.Dolce && Kimi.Gabbana {
        Kimi.Kousui.say()
    }

ここが一番実装したかった部分です.

perfumeを構造体にして,sayメソッドを追加しているので,「KimiのDolce && Gabbanaの そのKousuiのsayだよ」というように実装できます.

おまけ

このコードでは,Dolce と Gabbanaの値が一定のため,何度実行しても同じことの繰り返しでまたフラれてしまいます.
そこで,

package main

import (
    "fmt"
    "math/rand"
)

type human struct {
    Dolce   bool
    Gabbana bool
    Kousui  perfume
}

type perfume struct {
}

func (p *perfume) say() {
    fmt.Println(`僕がフラれるんだ`)
}

func main() {

    Kimi := human{true, true, perfume{}}

    if rand.Intn(2) == 0 {
        Kimi.Dolce = false
    }

    if rand.Intn(2) == 0 {
        Kimi.Gabbana = false
    }

    if Kimi.Dolce && Kimi.Gabbana {
        Kimi.Kousui.say()
    }
}

というようにrand.Intn(2)を追加して25%の確率で香水のせいにするように変更してみました.

あとがき

こんな雑な記事をここまで読んでいただき,ありがとうございました.
今回はGoで香水(Go水)を実装しましたが,Pythonで書けばif Dolce and Gabbanaって書けるなーなどと思ったので,他の言語でも実装してみると面白いかもしれません.

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Go クラス 基本

最近go言語を使い始めて、なんとなく書けるようになったのでそろそろクラスみたいな書き方をやっていこうと思い、ここに記しておきます!
とりあえず書いてあとで細かく説明していきます。

main.go
package main

import "fmt"

type Human struct {
    Age  int
    Name string
}

func (h Human) getName() string {
    return h.Name
}

func (h *Human) Birthday(newAge int) {
    h.Age = newAge
}

func main() {
    v := Human{Age: 3, Name: "mike"} // インスタンス作成
    fmt.Println(v.getName())
    v.Birthday(24)
}

クラス定義

type Human struct {
    Age  int
    Name string
}

クラス定義はstructという構造体という概念を使用し定義します。構造体はフィールドをプロパティを持つことができます。

メソッド定義

func (h Human) getName() string {
    return h.Name
}

func (h *Human) Birthday(newAge int) {
    h.Age = newAge
}

クラスに紐ずくメソッドは上記のように定義します。
func (変数名 クラス名) 関数名 リターンする値 {
処理
}
となります。goは関数の中でプロパティの値を変更させる場合にはクラス名の横にを付けます。これはインスタンス変数のアドレスを指しています。アドレス変数の中身を変更することで実際の値を変更することができます。対してプロパティの中身を変更しない場合はは使用しなくて良いです。

インスタンス作成

func main() {
    v := Human{Age: 3, Name: "mike"} // インスタンス作成
    fmt.Println(v.getName())
    v.Birthday(24)
}

インスタンス作成は
変数名 := クラス名{プロパティ名: 値, .....}
となります。

簡単ですがここまでにします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

関数の引数でnilを指定したときにハマった話

前置き

goのnilの話は n番煎じだと思いますが、ハマってしまった記念に記事を書きます..

実際のコードはもうちょっと長くてフレームワークの中だったりするので、原因の特定が大変でしたが、エッセンスだけ抜き出したものを書きます。

このようなメソッドを作りました(失敗例)

ちなみに、テストコードで何度もHTTPRequestを投げるのでラッパー関数のつもりでした。

func Request(method, url string, body *strings.Reader) error {
    request, err := http.NewRequest(method, url, body)
    if err != nil {
        return err
    }
    request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

    client := new(http.Client)
    resp, _ := client.Do(request)
    defer resp.Body.Close()
    byteArray, _ := ioutil.ReadAll(resp.Body)
    fmt.Println(string(byteArray))
    return nil
}

このメソッドをこのように使うことを想定しています。

func main() {

    values := url.Values{
        "hoge": []string{"fuga"},
    }

    err := Request("POST", "https://google.com", strings.NewReader(values.Encode()))
    if err != nil {
        log.Fatal(err)
    }

}

Requestの第3引数は、 雑に 特に何も考えずにstrings.NewReader の戻り型をそのまま指定しました。

nilを指定する

さて、POSTで何もパラメータを送るものがないとき

    err := Request("POST", "https://google.com", nil)
    if err != nil {
        log.Fatal(err)
    }

と指定するわけなのですが、これを実行すると

実行時エラーでクラッシュします。

strings.(*Reader).Len(...)
        /usr/local/Cellar/go/1.15.3/libexec/src/strings/reader.go:26
net/http.NewRequestWithContext(0x1314620, 0xc00001a0b0, 0x12baeec, 0x3, 0x12be7e1, 0x12, 0x13102c0, 0x0, 0x121018d, 0x0, ...)
        /usr/local/Cellar/go/1.15.3/libexec/src/net/http/request.go:889 +0x2a4
net/http.NewRequest(...)
        /usr/local/Cellar/go/1.15.3/libexec/src/net/http/request.go:813
main.Request(0x12baeec, 0x3, 0x12be7e1, 0x12, 0x0, 0x0, 0x0)

ちなみに、直接 http.NewRequest の第3引数にnilを指定したら大丈夫です。

    req, err := http.NewRequest("POST", "https://google.com", nil)
    if err != nil {
        log.Fatal(err)
    }

エラーの理由は?

IntelliJ IDEA でステップ実行していくとわかりますが、

https://github.com/golang/go/blob/f2eea4c1dc37886939c010daff89c03d5a3825be/src/net/http/request.go#L887

if body != nil {

Go の nil は型がついているセマンティクスですので、 *strings.NewReaderの型を持ったまま 関数内部に入ってしまい、下のコード時で body自体はnilであるのに
body 中身自体の型は *strings.NewReader で、bodyの宣言は io.Readerですので
nil判定的にはtrueになり (意図せず?) if の中に入ってしまいエラーになっているということになります。

参考:
https://qiita.com/umisama/items/e215d49138e949d7f805

関数の引数でnilを指定すると、関数宣言の引数の型になる

req, err := http.NewRequest("POST", "https://google.com", nil)

さてここで指定された nil ですが、 NewRequest の定義から
io.Reader の nil になります。 (io.Reader が interface でも型とされます)

どうすればよかったか

func Request(method, url string, body *strings.Reader)
の引数を
func Request(method, url string, body io.Reader)

としていればよかったので、テストで使い捨ての関数とはいえ、
関数の引数は、その呼び出す方の型ではなく、中で使っている関数の型に合わせるのが無難でした。

結論

http.NewRequest の第3引数で nilを指定するときは io.Readerの型になるように指定する。

関数の引数は、なるべく中で呼ばれる型に寄せて宣言する。

nilはハマるとよく言われますが、実際にこういうハマりをしないと気づけないものだと思いました..

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Goで関数の引数でnilを指定したときにハマった話

前置き

goのnilの話は n番煎じだと思いますが、ハマってしまった記念に記事を書きます..

実際のコードはもうちょっと長くてフレームワークの中だったりするので、原因の特定が大変でしたが、エッセンスだけ抜き出したものを書きます。

このようなメソッドを作りました(失敗例)

ちなみに、テストコードで何度もHTTPRequestを投げるのでラッパー関数のつもりでした。

func Request(method, url string, body *strings.Reader) error {
    request, err := http.NewRequest(method, url, body)
    if err != nil {
        return err
    }
    request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

    client := new(http.Client)
    resp, _ := client.Do(request)
    defer resp.Body.Close()
    byteArray, _ := ioutil.ReadAll(resp.Body)
    fmt.Println(string(byteArray))
    return nil
}

このメソッドをこのように使うことを想定しています。

func main() {

    values := url.Values{
        "hoge": []string{"fuga"},
    }

    err := Request("POST", "https://google.com", strings.NewReader(values.Encode()))
    if err != nil {
        log.Fatal(err)
    }

}

Requestの第3引数は、 雑に 特に何も考えずにstrings.NewReader の戻り型をそのまま指定しました。

nilを指定する

さて、POSTで何もパラメータを送るものがないとき

    err := Request("POST", "https://google.com", nil)
    if err != nil {
        log.Fatal(err)
    }

と指定するわけなのですが、これを実行すると

実行時エラーでクラッシュします。

strings.(*Reader).Len(...)
        /usr/local/Cellar/go/1.15.3/libexec/src/strings/reader.go:26
net/http.NewRequestWithContext(0x1314620, 0xc00001a0b0, 0x12baeec, 0x3, 0x12be7e1, 0x12, 0x13102c0, 0x0, 0x121018d, 0x0, ...)
        /usr/local/Cellar/go/1.15.3/libexec/src/net/http/request.go:889 +0x2a4
net/http.NewRequest(...)
        /usr/local/Cellar/go/1.15.3/libexec/src/net/http/request.go:813
main.Request(0x12baeec, 0x3, 0x12be7e1, 0x12, 0x0, 0x0, 0x0)

ちなみに、直接 http.NewRequest の第3引数にnilを指定したら大丈夫です。

    req, err := http.NewRequest("POST", "https://google.com", nil)
    if err != nil {
        log.Fatal(err)
    }

エラーの理由は?

IntelliJ IDEA でステップ実行していくとわかりますが、

https://github.com/golang/go/blob/f2eea4c1dc37886939c010daff89c03d5a3825be/src/net/http/request.go#L887

if body != nil {

Go の nil は型がついているセマンティクスですので、 *strings.NewReaderの型を持ったまま 関数内部に入ってしまい、下のコード時で body自体はnilであるのに
body 中身自体の型は *strings.NewReader で、bodyの宣言は io.Readerですので
nil判定的にはtrueになり (意図せず?) if の中に入ってしまいエラーになっているということになります。

参考:
https://qiita.com/umisama/items/e215d49138e949d7f805

関数の引数でnilを指定すると、関数宣言の引数の型になる

req, err := http.NewRequest("POST", "https://google.com", nil)

さてここで指定された nil ですが、 NewRequest の定義から
io.Reader の nil になります。 (io.Reader が interface でも型とされます)

どうすればよかったか

func Request(method, url string, body *strings.Reader)
の引数を
func Request(method, url string, body io.Reader)

としていればよかったので、テストで使い捨ての関数とはいえ、
関数の引数は、その呼び出す方の型ではなく、中で使っている関数の型に合わせるのが無難でした。

結論

http.NewRequest の第3引数で nilを指定するときは io.Readerの型になるように指定する。

関数の引数は、なるべく中で呼ばれる型に寄せて宣言する。

nilはハマるとよく言われますが、実際にこういうハマりをしないと気づけないものだと思いました..

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Gormのhas manyなテーブルのAutoMigrate()でneed to define a foreign keyと怒られる

ドキュメントのhas manyを見る

https://gorm.io/docs/has_many.html

追記

根本的に間違っていました。
同じような間違いをしている誰かの参考になればと思います。

エラー内容

// Vote model
type Vote struct {
    gorm.Model
    ID          uint
    Title       string
    Description string
    WorkSheets  []Worksheet
}

// Worksheet model
type Worksheet struct {
    gorm.Model
    ID         uint
    Text       string
    VoteNumber uint
}

VoteのWorksheetsには特に外部キーの指定等をしていません
すると・・・

invalid field found for struct main.Vote's field WorkSheets,
need to define a foreign key for relations or it need to implement the Valuer/Scanner interface

外部キーを定義しなさいと怒られます。

改善コード

// Vote model
type Vote struct {
    gorm.Model
    ID          uint
    Title       string
    Description string
    //外部キーの定義を追加
    WorkSheets  []Worksheet `gorm:"foreignKey:ID"`
}

// Worksheet model
type Worksheet struct {
    gorm.Model
    ID         uint
    Text       string
    VoteNumber uint
}

WorksheetのIDを外部キーにしています

本当の解決

// Vote model
type Vote struct {
    gorm.Model
    Title       string
    Description string
    WorkSheets  []Worksheet
}

// Worksheet model
type Worksheet struct {
    gorm.Model
    Text       string
  //追加
    VoteID     int
    VoteNumber int
}

has manyのmany側に、主キー用のフィールドを追加しておく必要があったようです。
完全に勘違いしていました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

意外と知らないVSCode上でGoを爆速開発するためのたった5つのTips

はじめに

最近イケイケなGo!
今回はVSCodeでGoを爆速開発するためのTipsを5つ(+おまけ1つ)紹介したいと思います。

自分もつい最近これらを知って、めっちゃ活用して開発効率を上げています!
良かったら参考にしてください。
(良いと思ったらLGTM頂けると嬉しいです?)

Fill Struct

空の構造体にフィールドをセットしてくれてめちゃ便利です。
よく使ってます。

使い方

空の構造体にカーソルが当たった状態で、Command Palette(F1 or Ctrl + Shift + P)でGo: Fill structと入力。

fill.png

package main

type person struct {
    name string
    age  int
    job  string
}

func main() {
    p := person{}
}

とあった場合に、以下のように自動生成してくれます。
person{}にカーソルを当てておきます。

func main() {
    p := person{
        name: "",
        age:  0,
        job:  "",
    }
}

これで大きめ構造体でも簡単に宣言ができて便利ですね。

Generate tests

テストコードをある程度自動生成してくれます。
対応するテストファイルがない場合は、テストファイルも生成してくれます。
テストコードの実装って面倒なのでかなり重宝してます。

使い方

テストを実装したい関数を選択して、右クリックからGo: Generate Unit Tests For Functionを選択。
Command Paletteからでもできます。

test.png

main.go
func sum(x int, y int) int {
    return x + y
}

とあって、sum関数のテストを実装する場合、以下のようにmain.goと同じディレクトリにmain_test.goを自動生成してくれます。
sumを選択しておきます。

main_test.go
package main

import "testing"

func Test_sum(t *testing.T) {
    type args struct {
        x int
        y int
    }
    tests := []struct {
        name string
        args args
        want int
    }{
        // TODO: Add test cases.
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := sum(tt.args.x, tt.args.y); got != tt.want {
                t.Errorf("sum() = %v, want %v", got, tt.want)
            }
        })
    }
}

TODOの中は例えば以下のように書きます。

tests := []struct {
        name string
        args args
        want int
    }{
        // TODO: Add test cases.
        {
            name: "1+2=3の確認",
            args: args{
                x: 1,
                y: 2,
            },
            want: 3,
        },
        {
            name: "1+(-2)=-1の確認",
            args: args{
                x: 1,
                y: -2,
            },
            want: -1,
        },
    }

testsの中のテストケースを回して、エラーがあった場合はt.Errorf("sum() = %v, want %v", got, tt.want)でエラーメッセージを出力してくれるので申し分なし。

テスト実行

上記のテストはテストファイル上からワンクリックで実行できます。
こちらは使っている方も多いと思います。

以下の赤枠内のボタンをクリックすると、内容に応じたテストを実行できます。

test_excute.PNG

Add Tags To Struct Fields

構造体に自動で構造体タグ(json:"name"みたいなの)を付与してくれます。
APIサーバとして使っていて、APIのレスポンスを定義する時やjsonパースする時に便利。

使い方

構造体タグを付与したい構造体にカーソルが当たった状態で、右クリックからGo: Add Tags To Struct Fieldsを選択。

tags.png

type person struct {
    Name string
    Age  int
    Job  string
}

とあった場合に、以下のように自動生成してくれます。
構造体内にカーソルを当てておきます。

type person struct {
    Name string `json:"name,omitempty"`
    Age  int    `json:"age,omitempty"`
    Job  string `json:"job,omitempty"`
}

Generate interface stubs

interfaceからスタブを自動生成してくれます。

使い方

空のスタブを書いて改行し、Command Palette(F1 or Ctrl + Shift + P)でGo: Generate interface stubsと入力。
再度入力欄が出るので、(変数名) *(stubの構造体名) (interfaceのpackage名).(interface名)と入力。

stub.png

package car

// 関数名や引数は超適当です。。
type Car interface {
    drive(int) string
    stop(int, int) string
    turnLeft(string) int
    turnRight(string, string) (int, error)
}

type carImpl struct{}

とあった場合に、以下のように自動生成してくれます。
type carImpl struct{}の一行下にカーソルを置いておきます。
2回目の入力ではc *carImpl car.Carと入力。
go.modを置いておかないとpackageを認識しないので置いておく必要があります。

package car

type Car interface {
    drive(int) string
    stop(int, int) string
    turnLeft(string) int
    turnRight(string, string) (int, error)
}

type carImpl struct{}
func (c *carImpl) drive(_ int) string {
    panic("not implemented") // TODO: Implement
}

func (c *carImpl) stop(_ int, _ int) string {
    panic("not implemented") // TODO: Implement
}

func (c *carImpl) turnLeft(_ string) int {
    panic("not implemented") // TODO: Implement
}

func (c *carImpl) turnRight(_ string, _ string) (int, error) {
    panic("not implemented") // TODO: Implement
}

おまけ

ご存知の方も多いと思いますが、VSCode使っててGoの補完とかLintが効かなくなった場合は、Command PaletteからGo: Restart Language Serverと入力して実行するとだいたい解決すると思います。

さいごに

Twitterの方でも、モダンな技術習得やサービス開発の様子を発信したりしているので良かったらチェックしてみてください!

また、BOT開発を通じてGoとLINE BOTにまとめて入門する記事をZennに掲載していますので、良かったらそちらもご覧ください!

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

意外と知らないVSCode上でGoを爆速開発するためのTips5選

はじめに

最近イケイケなGo!
今回はVSCodeでGoを爆速開発するためのTipsを5つ(+おまけ1つ)紹介したいと思います。

自分もつい最近これらを知って、めっちゃ活用して開発効率を上げています!
良かったら参考にしてください。
(良いと思ったらLGTM頂けると嬉しいです?)

Fill Struct

空の構造体にフィールドをセットしてくれてめちゃ便利です。
よく使ってます。

使い方

空の構造体にカーソルが当たった状態で、Command Palette(F1 or Ctrl + Shift + P)でGo: Fill structと入力。

fill.png

package main

type person struct {
    name string
    age  int
    job  string
}

func main() {
    p := person{}
}

とあった場合に、以下のように自動生成してくれます。
person{}にカーソルを当てておきます。

func main() {
    p := person{
        name: "",
        age:  0,
        job:  "",
    }
}

これで大きめ構造体でも簡単に宣言ができて便利ですね。

Generate tests

テストコードをある程度自動生成してくれます。
対応するテストファイルがない場合は、テストファイルも生成してくれます。
テストコードの実装って面倒なのでかなり重宝してます。

使い方

テストを実装したい関数を選択して、右クリックからGo: Generate Unit Tests For Functionを選択。
Command Paletteからでもできます。

test.png

main.go
func sum(x int, y int) int {
    return x + y
}

とあって、sum関数のテストを実装する場合、以下のようにmain.goと同じディレクトリにmain_test.goを自動生成してくれます。
sumを選択しておきます。

main_test.go
package main

import "testing"

func Test_sum(t *testing.T) {
    type args struct {
        x int
        y int
    }
    tests := []struct {
        name string
        args args
        want int
    }{
        // TODO: Add test cases.
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := sum(tt.args.x, tt.args.y); got != tt.want {
                t.Errorf("sum() = %v, want %v", got, tt.want)
            }
        })
    }
}

TODOの中は例えば以下のように書きます。

tests := []struct {
        name string
        args args
        want int
    }{
        // TODO: Add test cases.
        {
            name: "1+2=3の確認",
            args: args{
                x: 1,
                y: 2,
            },
            want: 3,
        },
        {
            name: "1+(-2)=-1の確認",
            args: args{
                x: 1,
                y: -2,
            },
            want: -1,
        },
    }

testsの中のテストケースを回して、エラーがあった場合はt.Errorf("sum() = %v, want %v", got, tt.want)でエラーメッセージを出力してくれるので申し分なし。

テスト実行

上記のテストはテストファイル上からワンクリックで実行できます。
こちらは使っている方も多いと思います。

以下の赤枠内のボタンをクリックすると、内容に応じたテストを実行できます。

test_excute.PNG

Add Tags To Struct Fields

構造体に自動で構造体タグ(json:"name"みたいなの)を付与してくれます。
APIサーバとして使っていて、APIのレスポンスを定義する時やjsonパースする時に便利。

使い方

構造体タグを付与したい構造体にカーソルが当たった状態で、右クリックからGo: Add Tags To Struct Fieldsを選択。

tags.png

type person struct {
    Name string
    Age  int
    Job  string
}

とあった場合に、以下のように自動生成してくれます。
構造体内にカーソルを当てておきます。

type person struct {
    Name string `json:"name,omitempty"`
    Age  int    `json:"age,omitempty"`
    Job  string `json:"job,omitempty"`
}

Generate interface stubs

interfaceからスタブを自動生成してくれます。

使い方

空のスタブを書いて改行し、Command Palette(F1 or Ctrl + Shift + P)でGo: Generate interface stubsと入力。
再度入力欄が出るので、(変数名) *(stubの構造体名) (interfaceのpackage名).(interface名)と入力。

stub.png

package car

// 関数名や引数は超適当です。。
type Car interface {
    drive(int) string
    stop(int, int) string
    turnLeft(string) int
    turnRight(string, string) (int, error)
}

type carImpl struct{}

とあった場合に、以下のように自動生成してくれます。
type carImpl struct{}の一行下にカーソルを置いておきます。
2回目の入力ではc *carImpl car.Carと入力。
go.modを置いておかないとpackageを認識しないので置いておく必要があります。

package car

type Car interface {
    drive(int) string
    stop(int, int) string
    turnLeft(string) int
    turnRight(string, string) (int, error)
}

type carImpl struct{}
func (c *carImpl) drive(_ int) string {
    panic("not implemented") // TODO: Implement
}

func (c *carImpl) stop(_ int, _ int) string {
    panic("not implemented") // TODO: Implement
}

func (c *carImpl) turnLeft(_ string) int {
    panic("not implemented") // TODO: Implement
}

func (c *carImpl) turnRight(_ string, _ string) (int, error) {
    panic("not implemented") // TODO: Implement
}

おまけ

ご存知の方も多いと思いますが、VSCode使っててGoの補完とかLintが効かなくなった場合は、Command PaletteからGo: Restart Language Serverと入力して実行するとだいたい解決すると思います。

さいごに

Twitterの方でも、モダンな技術習得やサービス開発の様子を発信したりしているので良かったらチェックしてみてください!

また、BOT開発を通じてGoとLINE BOTにまとめて入門する記事をZennに掲載していますので、良かったらそちらもご覧ください!

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む