Apollo Client (React) 触ってみた。 Query編

Apolloのドキュメントを読んだ時のメモ

https://www.apollographql.com/docs/

まずは簡単なgraphQL APIを構築 https://github.com/DaisukeMatsumoto0925/graph_practice

Front Next.ts (codegen) Backend Go (gqlgen)

Apolloの構築

const createApolloClient = () => {
  return new ApolloClient({
    link: new HttpLink({
      uri: 'http://localhost:3000/query',
    }),
    cache: new InMemoryCache(),
  });
 };

const client = createApolloClient()
function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  )
}

以上、Reduxの構築に比べると圧倒的コード量の少なさ。

useQuery

https://www.apollographql.com/docs/react/data/queries/

apolloで使えるhooks。 GraphQLのQueryを叩く

以下のようにgaraphQLのAPIを叩いてレスポンスを取得できる。

  const { loading, error, data } = useQuery(QUERY_GQL);

そのほかにも様々なコールバック関数が用意されておりかなり使い勝手が良い。 useEffectやssr等で呼び出す必要すらない。

ちなみに graphql code generator というライブラリを使用すると hooksをラッピングするカスタムhookを作成してくれて、さらに便利。 https://www.graphql-code-generator.com/

cache

useQueryが発火した時fetchされた結果は自動的にキャッシュされる。

Whenever Apollo Client fetches query results from your server, it automatically caches those results locally. This makes subsequent executions of the same query extremely fast.

つまり何もする必要はない。

Polling

指定された時間でuseQueryを定期実行してくれる。

  const { loading, error, data } = useQuery(TASKS_QUERY, {
    pollInterval: 500,
  });

試しにDBを直接更新してみた。 f:id:shikatech:20210703171401p:plain

clientではリロードせずにデータがfetchされた。 f:id:shikatech:20210703171521p:plain

fetch policy

デフォルトでは cache-first が適用されている。

no-cacheにした場合

  const { loading, error, data } = useQuery(TASKS_QUERY, {
    fetchPolicy: "no-cache"
  });

f:id:shikatech:20210703172812p:plain

cacheには保存されていない。 その他のポリシー https://www.apollographql.com/docs/react/data/queries/#supported-fetch-policies

その他

https://www.apollographql.com/docs/react/data/queries/#usequery-api

その他にもたくさんのAPIが用意されている。

onCompleted

Queryが成功した時のコールバック

  const {data, refetch, networkStatus, loading, error} = useTasksQuery({
    onCompleted: (data) => {return console.log("result",data)}
  })

f:id:shikatech:20210703221751p:plain

これはかなり使えそう、コールバックにはfetch後のデータが入ってくるが、 それ以外のdom操作でformの中身をリセットしたり、成功時のポップアップを出す場合など

skip

Queryを実行しないようにする。

  const {data, refetch, networkStatus, loading, error} = useTasksQuery({
    skip: true
  })

ある条件下では発火させたくない時はこれを使うのが良さそう。

refetch

hooksのresult

名前の通りQueryを再発火させる。 ボタンなどに仕込めばレスポンスを容易に再取得できる。

const {refetch} = useTasksQuery()

refetch()

以上が構築〜Query編 次回はmutationを見ていく。

GraphQL ID設計

きっかけ

本業で初めてGraphQLに触れた。 GraphQLでapiを叩く時、IDの仕様に癖があったので調べてみた。

参考

booth.pm

本題

そもそもGraphQlのスキーマ設計は何を基準に行うべきなのか?

GraphQLスキーマ設計ガイド 第2版 - わかめの自動売り場 - BOOTH では

GitHub v4 API に倣え!

とあるように大手APIの仕様を参考にする。

GitHubのGraphQLAPIは、 Reactのクライアントライブラリである Relay 向けの仕様へ準拠されている。

https://github.com/facebook/relay

つまり フロントエンド側で使うライブラリは ApolloやRelayが有名でそれらに合わせて設計するのが良さそう。

Apollo、RelayにあせてIDを設計すると IDはユニークでグローバルな必要がある。

たとえばUser型のidを123と返すのではなく、User:123や、これを base64 encode したVXNlcjoxMjM=とする。

理由は、ApolloやRelayはキャッシュ機構を持っておりこのidをキーとしているため、 idが重複してしまうとキャッシュが上書きされバグの原因となる。

query FindUser{
  user(id: "users: 1"){
    id
    name
  }

"users: 1" テーブル名 : ID

このようにテーブル間を跨いでユニークな値にする事で Node の id の値のみから、任意のデータを見つけられるようになる。

UserのIDから検索をしたい時に単にID"1"のみを指定した場合どのテーブルかを判別できない。

以上

Go 埋め込み

きっかけ

こちらの記事でクリーンアーキテクチャを勉強していたときに埋め込みについてさらっと使用されていたので自分なりに深堀ってみた。 https://qiita.com/hirotakan/items/698c1f5773a3cca6193e

type UserRepository struct {
    SqlHandler
}

func (repo *UserRepository) Store(u domain.User) (id int, err error) {
    result, err := repo.Execute(
        "INSERT INTO users (first_name, last_name) VALUES (?,?)", u.FirstName, u.LastName,
    )
    if err != nil {
        return
    }
    id64, err := result.LastInsertId()
    if err != nil {
        return
    }
    id = int(id64)
    return
}
type SqlHandler interface {
    Execute(string, ...interface{}) (Result, error)
    Query(string, ...interface{}) (Row, error)
}

UserRepository というstructに構造体にSqlHandler というinterfaceを埋め込んでいる。

UserRepositoryではSqlHandlerのメソッドを使えるようになる。

Goでの埋め込み

参考: https://qiita.com/momotaro98/items/4f6e2facc40a3f37c3c3

interfaceにはstructを埋め込めない

structにはstructを埋め込める interfaceにはinterfaceを埋め込める structにはinterfaceを埋め込める。

埋め込みのメリット

実装を省略できる為 DRY である。

埋め込みの注意点

参考: https://qiita.com/shibukawa/items/16acb36e94cfe3b02aa1 Goの埋め込みで継承はできない。

「名前なしでアクセスできるメンバー変数」というシンタックスシュガーと考えるべきです。

あくまでもDRYの為

埋め込み元と埋め込み先がのメソッドが重複した場合

埋め込み先のメソッドが先に優先される。

あるメソッドは埋め込み元と同じ実装で あるメソッドは独自のメソッドを実装したいときに使用する。

以上。

Go migrateion goose setup

Goのマイグレーションツール

goose

bitbucket.org

Go製のmigration ツールでは人気のある方?

業務でも採用されていたので使ってみました。

情報にバラつきがありセットアップまでに時間がかかりました。

goose

bitbucket版 と github版があります。

githubhttps://github.com/pressly/goose

同じかと思いきや、微妙に挙動が違うので要注意です。

今回はbitbucket版を使用します。

install

bitbucketを使用します。 githubと間違わないよう注意です。

go get bitbucket.org/liamstask/goose/cmd/goose

configure

db/dbconf.yml デフォルトでこのパスを見にいくみたいです。

development:
  driver: mysql
  open: $DB_URL

production:
  driver: mysql
  open: $DB_URL
% echo $DB_URL

root:password@tcp(db:3306)/sample_pj

{username}:{password}@tcp({host}:3306/{db_name}

上記の形式で指定します。 私の場合はdocker-composeを使用している為 hostがdb(コンテナ名)を指定しています。

docker-compose

app:
    environment:
      DB_URL: root:password@tcp(db:3306)/sample_pj

db:
    environment:
      MYSQL_DATABASE: sample_pj
      MYSQL_ROOT_PASSWORD: password

バージョンによって書き方が違うみたいです。

commands

% goose


goose is a database migration management system for Go projects.

Usage:
    goose [options] <subcommand> [subcommand options]

Options:
  -env string
        which DB environment to use (default "development")
  -path string
        folder containing db info (default "db")
  -pgschema string
        which postgres-schema to migrate (default = none)

Commands:
    up         Migrate the DB to the most recent version available
    down       Roll back the version by 1
    redo       Re-run the latest migration
    status     dump the migration status for the current DB
    create     Create the scaffolding for a new migration
    dbversion  Print the current version of the database

create

マイグレーションファイルの生成 db/migrations/ 配下にmigrationファイルが生成されます。

github版だとなぜかルートにマイグレーションファイルが生成されます。

goose create AddSomeColumns sql

sqlを指定しない場合はgoファイルで生成されます。

migrationfile

db/migrations/20210526144142_AddSomeColumns.sql 生成されたファイルに以下を追加

-- +goose Up
-- SQL in section 'Up' is executed when this migration is applied
CREATE TABLE samples (
  id int UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  first_name varchar(255) NOT NULL,
  last_name varchar(255) NOT NULL,
  super_user int(8) UNSIGNED,
  created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- +goose Down
-- SQL section 'Down' is executed when this migration is rolled back
DROP TABLE IF EXISTS samples;

migration

% goose up

これでマイグレーションされます。

逆にdownすると

% goose down

テーブルは削除されます。

またup or down 時に goose_db_versionというテーブルが生成され 変更のたびにversionが記録されていきます。

f:id:shikatech:20210526210120p:plain

以上です。

bitbucket製とgithub製、configファイルの書き方に苦戦しました。

他にもコマンドがあるのでもう少し触ってみようと思います。

Go API開発の環境構築 テンプレート アーキテクチャ、ホットリロード、ロギング、Docker

GoでAPIを作るときのテンプレです。 ホットリロード、ロギング、コンテナ、DB、まとめて構築していきます。

以下の技術を使用します。

ディレクトリ構成です。

├── Dockerfile
├── air.toml //airの設定
├── config
│   ├── config.go
│   └── development.yml
├── db
│   └── my.cnf
├── docker-compose.yml
├── entry.sh
├── go.mod
├── go.sum
├── main.go
├── src
│   └── infrastructure
│       ├── db
│       │   └── sqlhandler.go
│       ├── logging
│       │   └── logging.go
│       └── router
│           └── router.go
└── tmp
    └── main

クリーンアーキテクチャでの実装を前提としています。

Dockerfile

Dockerfile

FROM golang:latest as development

WORKDIR /app
VOLUME /app
ADD . /app

ENV GO_ENV=development

RUN apt-get update && apt-get -y install default-mysql-client
RUN go get github.com/cosmtrek/air

CMD ["./entry.sh"]

docker-compose側でマルチビルドしたいので as developmentとします。 開発環境用なので環境変数に developmentを渡します。 buildに必要な mysql clientと airをインストールします。

docker-compose

version: '3.7'
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: development
    ports:
      - "8080:8080"
    volumes:
      - "./:/app"
    security_opt:
      - seccomp:unconfined
    depends_on:
      - db
    environment:
      DB_PORT: 3306
      DB_NAME: sample_pj
      DB_USER: root
      DB_PASSWORD: password
  db:
    platform: linux/x86_64 #for m1Tip
    image: mysql:8.0.25
    environment:
      MYSQL_DATABASE: sample_pj
      MYSQL_ROOT_PASSWORD: password
    ports:
      - "3306:3306"
    volumes:
      - ./data/mysql:/var/lib/mysql:cached
      - ./db/my.cnf:/etc/mysql/conf.d/my.cnf

m1チップのMac Bookではplatform: linux/x86_64を指定する必要があります。

entry.sh

#!/bin/sh
set -e

$GOPATH/bin/air -c air.toml

set -e https://qiita.com/youcune/items/fcfb4ad3d7c1edf9dc96#set--e -cオプション はコンフィグファイルの指定https://github.com/cosmtrek/air#usage

air.toml

root = "."
tmp_dir = "tmp"

[build]
cmd = "go build -o ./tmp/main ./main.go"
bin = "tmp/main"

次にGoのコードを書いていきます。

config/config.go

package config

import (
    "fmt"
    "io/ioutil"
    "log"
    "os"
    "sync"

    "github.com/caarlos0/env"
    "gopkg.in/yaml.v2"
)

type Config struct{
    Port string
    Db Db
}

type Db struct {
    Port string `env:"DB_PORT"`
    Name string `env:"DB_NAME"`
    User string `env:"DB_USER"`
    Password string `env:"DB_PASSWORD"`
}

var config *Config
var once sync.Once

func Get() *Config {
    return config
}

func init() {
    once.Do(func(){
        config = &Config{}
        goenv := os.Getenv("GO_ENV")
        filepath := fmt.Sprintf("./config/%v.yml", goenv)
        buf, err := ioutil.ReadFile(filepath)
        if err != nil {
            log.Fatalln("failed to load config yaml err:", err)
        }

        fmt.Println("config:", config)
        err = yaml.Unmarshal(buf, config)
        if err != nil {
            log.Fatalln("failed to Unmarshal yaml err: ", err)
        }

        if err := env.Parse(&config.Db); err != nil {
            fmt.Printf("failed to load Db error: %s", err)
        }
    })
}

ここでは環境変数の設定を行います。 環境変数はDockerfileとdocker-composeの両方から渡しています。 os.Getenvで読み込む GO_ENVはDockerfile env アノテーションで読み込むDB_ はdocker-composeからです。

ioutil.ReadFileでconfig配下のymlファイルを読み込みます。

development.yml

port: 8080

これでGO_ENV環境変数毎にymlを切り替えます。

次にsrc配下のGoファイルを作っていきます。 今回はクリーンアーキテクチャを前提にFrameworks & Drivers に当たるinfrastructureの部分を実装していきます。

f:id:shikatech:20210526095810j:plain

src/infrastructure/db/sqlhandler.go

package db

import (
    "fmt"
    "log"

    "github.com/username/sample_pj/config"
    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/mysql"
)

type SQLHandler struct {
    Conn *gorm.DB
}

func New() *SQLHandler {
    connectionString := genConnectString()
    fmt.Println("connectionString: ", connectionString)
    conn, err := gorm.Open("mysql", connectionString)
    if err != nil {
        log.Fatal("db connection error: ", err)
    }
    fmt.Println("Connection OK")
    conn.LogMode(true) // debug mode
    sqlHandler := &SQLHandler{
        Conn: conn,
    }

    return sqlHandler
}

func genConnectString() string {
    conf := config.Get()
    USER := conf.Db.User
    PASSWORD := conf.Db.Password
    PROTOCOL := fmt.Sprintf("tcp(db:%s)", conf.Db.Port)
    DBNAME := conf.Db.Name
    QUERY := "?charset=utf8mb4&parseTime=True&loc=Local"

    return USER + ":" + PASSWORD + "@" + PROTOCOL + "/" + DBNAME + QUERY
}

*gorm.DBを返します。

src/infrastructure/logging/logging.go

package logging

import (
    "fmt"
    "io"
    "log"
    "os"
)


type Logger struct {
    F *log.Logger
    I *log.Logger
    D *log.Logger
    E *log.Logger
}

type NullWriter struct{}

func (n NullWriter) Write(p []byte) (int, error) {
    return 0, nil
}

func NewLogger(env string) *Logger {
    var fatalWriter io.Writer = os.Stderr
    var infoWriter io.Writer = os.Stdout
    var errorWriter io.Writer = os.Stderr
    var debugWriter io.Writer = os.Stdout

    if env != "development" {
        debugWriter = NullWriter{}
    }

    return &Logger{
        F: log.New(fatalWriter, "[FATAL] ", log.Llongfile|log.Ldate|log.Lmicroseconds),
        E: log.New(errorWriter, "[ERROR] ", log.Llongfile|log.Ldate|log.Lmicroseconds),
        I: log.New(infoWriter, "[INFO]  ", log.Llongfile|log.Ldate|log.Lmicroseconds),
        D: log.New(debugWriter, "[DEBUG] ", log.Llongfile|log.Ldate|log.Lmicroseconds),
        }
}

func (l *Logger) Info(v ...interface{}) {
    l.I.Output(2, fmt.Sprint(v...))
}

func (l *Logger) Infof(format string, v ...interface{}) {
    l.I.Output(2, fmt.Sprintf(format, v...))
}

func (l *Logger) Debug(v ...interface{}) {
    l.D.Output(2, fmt.Sprint(v...))
}

func (l *Logger) Debugf(format string, v ...interface{}) {
    l.D.Output(2, fmt.Sprintf(format, v...))
}

func (l *Logger) Error(v ...interface{}) {
    l.E.Output(2, fmt.Sprint(v...))
}

func (l *Logger) Errorf(format string, v ...interface{}) {
    l.E.Output(2, fmt.Sprintf(format, v...))
}

func (l *Logger) Fatal(v ...interface{}) {
    l.F.Output(2, fmt.Sprint(v...))
    os.Exit(1)
}

func (l *Logger) Fatalf(format string, v ...interface{}) {
    l.F.Output(2, fmt.Sprintf(format, v...))
    os.Exit(1)
}

標準logライブラリのマッパーです。 外部パッケージでlogger.Debug("debug")のように使用できます。 可変引数についてhttps://golang.org/ref/spec#Passing_arguments_to_..._parameters logについて https://waman.hatenablog.com/entry/2017/09/29/011614

src/infrastructure/router/router.go

package router

import (
    "net/http"

    "github.com/username/sample_pj/config"
    "github.com/username/sample_pj/src/infrastructure/db"
    "github.com/gin-gonic/gin"
)

func Dispatch(sqlhandler *db.SQLHandler) {
    conf := config.Get()

    r := gin.Default()
    r.GET("/hello", func(ctx *gin.Context) {
        ctx.JSON(http.StatusOK, gin.H{
            "message": "hello",
        })
    })

    r.Run(":" + conf.Port)
}

/hello にアクセスした時, {"message": "hello"} を返します。

main.go

package main

import (
    "github.com/username/sample_pj/src/infrastructure/db"
    "github.com/username/sample_pj/src/infrastructure/logging"
    "github.com/username/sample_pj/src/infrastructure/router"
)

func main() {
    logger := logging.NewLogger("development")
    logger.Debug("debug log")

    sqlhandler := db.New()
    defer sqlhandler.Conn.Close()

    router.Dispatch(sqlhandler)
}
docker-compose up

以上。