- 投稿日:2020-11-16T23:41:12+09:00
GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)
お題
以前、以下の記事でRelayスタイルのお試し実装をした。
ただ、「前後ページへの移動」と「任意の項目での昇順・降順並べ替え」という要件の組み合わせが想像以上の実装の煩雑さを生み、かなり消化不良だった。
今回、使用するRDBをPostgreSQL前提とするアーキテクチャ上の縛りを入れることで、バックエンド側の実装を(前回よりは)簡略化できないか試してみた。今回のサンプル実装で使った言語やライブラリ等
なお、これら個々の言語やライブラリ等についての説明はしません。
フロントエンド
前回のフロントエンドの記事と同じ。
バックエンド
その他
想定する仕様
何かしらの情報(今回は
Customer
(顧客))を一覧表示するページで以下の機能を持つ。
- 文字列検索フィルタ(部分一致検索)
- 前ページ、次ページ遷移
- 一覧表示要素での昇順、降順並べ替え
- 一覧表示件数の変更
単純に初期ページ表示時に全件取得して、オンメモリでの前ページ、次ページ遷移ではなく、都度(1ページに必要な分だけ)検索。
以下を実行した時は、ページングの途中(例えば2ページ目を表示中)だったとしても、1ページ目の表示に戻る。
- 一覧表示要素での昇順、降順並べ替え
- 一覧表示件数の変更
画面イメージ
初期ページ表示時(デフォルトでは
IDの降順
で並んでいる仕様)2ページ目に遷移時
検索フィルタ使用時
名前の昇順で並べ替え
一覧表示件数を 10件 に変更(+「Age」の降順)
関連記事索引
- 第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サービスを作る」
開発環境
# 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.0IDE - 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.ymlversion: '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
の降順で表示。
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.gopackage 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.gotype 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.gofunc (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.gofunc (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.gofunc 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.goconst 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.gofunc (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.gotype 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.gotype 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.goconst 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.gofunc (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.gofunc (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の状態
画面遷移結果
1ページ目
2ページ目
3ページ目
2ページ目に戻る
IDの昇順に変更
DBの状態
画面遷移結果
1ページ目
2ページ目
3ページ目
2ページ目に戻る
Nameの降順に変更
DBの状態
画面遷移結果
1ページ目
2ページ目
3ページ目
2ページ目に戻る
Ageの昇順、及び、1ページ表示件数「10件」に変更
DBの状態
画面遷移結果
1ページ目
2ページ目
1ページ目に戻る
Nameの昇順、及び、「
k
」でフィルタに変更DBの状態
画面遷移結果
まとめ
これでひとまず「顧客一覧」というページにおけるページング(及び要素別の並べ替えや文字列検索フィルタの組み合わせ)が実現できた。
バックエンドの実装としても、前回のように並べ替え要素の値をCursorに持つ(という暴挙)こともなく一律同じ形式でSQLが叩けるようになった。
ただ、これを機能毎に量産していくのは、あまりにもボイラープレートが過ぎるので、実際に使う際はなるべくテンプレート化しておく必要がある。
あと、ソース中にもコメントでTODO書いてたりするけど、いろいろ課題はある。
- 投稿日:2020-11-16T23:23:40+09:00
Goで香水を実装してみる
はじめに
この記事は,学校の帰りに思いついたものを勢いだけで実装したクソコードを載せているだけのネタ記事です.
間違ってもこれを見てGoの勉強をしようなどと思わないでください.若干 「Javaで湯婆婆を実装してみる」にインスパイアされている部分があります.
実装する
コード
main.gopackage 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
って書けるなーなどと思ったので,他の言語でも実装してみると面白いかもしれません.
- 投稿日:2020-11-16T23:06:02+09:00
Go クラス 基本
最近go言語を使い始めて、なんとなく書けるようになったのでそろそろクラスみたいな書き方をやっていこうと思い、ここに記しておきます!
とりあえず書いてあとで細かく説明していきます。main.gopackage 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) }インスタンス作成は
変数名 := クラス名{プロパティ名: 値, .....}
となります。簡単ですがここまでにします。
- 投稿日:2020-11-16T21:48:55+09:00
関数の引数で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 でステップ実行していくとわかりますが、
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はハマるとよく言われますが、実際にこういうハマりをしないと気づけないものだと思いました..
- 投稿日:2020-11-16T21:48:55+09:00
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 でステップ実行していくとわかりますが、
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はハマるとよく言われますが、実際にこういうハマりをしないと気づけないものだと思いました..
- 投稿日:2020-11-16T17:15:08+09:00
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側に、主キー用のフィールドを追加しておく必要があったようです。
完全に勘違いしていました。
- 投稿日:2020-11-16T08:10:27+09:00
意外と知らないVSCode上でGoを爆速開発するためのたった5つのTips
はじめに
最近イケイケなGo!
今回はVSCodeでGoを爆速開発するためのTipsを5つ(+おまけ1つ)紹介したいと思います。自分もつい最近これらを知って、めっちゃ活用して開発効率を上げています!
良かったら参考にしてください。
(良いと思ったらLGTM頂けると嬉しいです?)Fill Struct
空の構造体にフィールドをセットしてくれてめちゃ便利です。
よく使ってます。使い方
空の構造体にカーソルが当たった状態で、Command Palette(
F1
orCtrl + Shift + P
)でGo: Fill struct
と入力。例
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からでもできます。例
main.gofunc sum(x int, y int) int { return x + y }とあって、sum関数のテストを実装する場合、以下のように
main.go
と同じディレクトリにmain_test.go
を自動生成してくれます。
sum
を選択しておきます。main_test.gopackage 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)
でエラーメッセージを出力してくれるので申し分なし。テスト実行
上記のテストはテストファイル上からワンクリックで実行できます。
こちらは使っている方も多いと思います。以下の赤枠内のボタンをクリックすると、内容に応じたテストを実行できます。
Add Tags To Struct Fields
構造体に自動で構造体タグ(
json:"name"
みたいなの)を付与してくれます。
APIサーバとして使っていて、APIのレスポンスを定義する時やjsonパースする時に便利。使い方
構造体タグを付与したい構造体にカーソルが当たった状態で、右クリックから
Go: Add Tags To Struct Fields
を選択。例
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
orCtrl + Shift + P
)でGo: Generate interface stubs
と入力。
再度入力欄が出るので、(変数名) *(stubの構造体名) (interfaceのpackage名).(interface名)
と入力。例
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の方でも、モダンな技術習得やサービス開発の様子を発信したりしているので良かったらチェックしてみてください!
VSCode上でGoを開発するのに便利なTipsをQiitaにまとめました!
— やぎぬ?行動力エンジニア (@yagi_eng) November 15, 2020
⬇️意外と知らないVSCode上でGoを爆速開発するための5つのTipshttps://t.co/a4DDLHOUZs
ちょくちょくTwitterでつぶやいていたやつです?
自分もよく使ってるんですけどめちゃ捗ってます
Go開発に興味のある方は是非ご覧ください!また、BOT開発を通じてGoとLINE BOTにまとめて入門する記事をZennに掲載していますので、良かったらそちらもご覧ください!
ZennでGoとLINE BOTの記事を書いてみました
— やぎぬ?行動力エンジニア (@yagi_eng) November 7, 2020
⬇️BOT開発を通じてGoとLINE BOTにまとめて入門するhttps://t.co/QqsEESJMKa
5ステップに分け、「Hello Worldから始めて、飲食店検索ができるLINE BOTの実装まで」を解説しています
GoやLINE BOTに興味のある人は是非読んでみてください?
24,000字超え?参考
- 投稿日:2020-11-16T08:10:27+09:00
意外と知らないVSCode上でGoを爆速開発するためのTips5選
はじめに
最近イケイケなGo!
今回はVSCodeでGoを爆速開発するためのTipsを5つ(+おまけ1つ)紹介したいと思います。自分もつい最近これらを知って、めっちゃ活用して開発効率を上げています!
良かったら参考にしてください。
(良いと思ったらLGTM頂けると嬉しいです?)Fill Struct
空の構造体にフィールドをセットしてくれてめちゃ便利です。
よく使ってます。使い方
空の構造体にカーソルが当たった状態で、Command Palette(
F1
orCtrl + Shift + P
)でGo: Fill struct
と入力。例
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からでもできます。例
main.gofunc sum(x int, y int) int { return x + y }とあって、sum関数のテストを実装する場合、以下のように
main.go
と同じディレクトリにmain_test.go
を自動生成してくれます。
sum
を選択しておきます。main_test.gopackage 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)
でエラーメッセージを出力してくれるので申し分なし。テスト実行
上記のテストはテストファイル上からワンクリックで実行できます。
こちらは使っている方も多いと思います。以下の赤枠内のボタンをクリックすると、内容に応じたテストを実行できます。
Add Tags To Struct Fields
構造体に自動で構造体タグ(
json:"name"
みたいなの)を付与してくれます。
APIサーバとして使っていて、APIのレスポンスを定義する時やjsonパースする時に便利。使い方
構造体タグを付与したい構造体にカーソルが当たった状態で、右クリックから
Go: Add Tags To Struct Fields
を選択。例
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
orCtrl + Shift + P
)でGo: Generate interface stubs
と入力。
再度入力欄が出るので、(変数名) *(stubの構造体名) (interfaceのpackage名).(interface名)
と入力。例
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の方でも、モダンな技術習得やサービス開発の様子を発信したりしているので良かったらチェックしてみてください!
VSCode上でGoを開発するのに便利なTipsをQiitaにまとめました!
— やぎぬ?行動力エンジニア (@yagi_eng) November 15, 2020
⬇️意外と知らないVSCode上でGoを爆速開発するための5つのTipshttps://t.co/a4DDLHOUZs
ちょくちょくTwitterでつぶやいていたやつです?
自分もよく使ってるんですけどめちゃ捗ってます
Go開発に興味のある方は是非ご覧ください!また、BOT開発を通じてGoとLINE BOTにまとめて入門する記事をZennに掲載していますので、良かったらそちらもご覧ください!
ZennでGoとLINE BOTの記事を書いてみました
— やぎぬ?行動力エンジニア (@yagi_eng) November 7, 2020
⬇️BOT開発を通じてGoとLINE BOTにまとめて入門するhttps://t.co/QqsEESJMKa
5ステップに分け、「Hello Worldから始めて、飲食店検索ができるLINE BOTの実装まで」を解説しています
GoやLINE BOTに興味のある人は是非読んでみてください?
24,000字超え?参考