LoginSignup
72
55

【Go編】Next.js × Go × AWSでJWT認証付きGraphQLアプリとCI/CDを構築してみよう

Last updated at Posted at 2023-05-26

はじめに

■ご案内■
本連載の背景/作成できるアプリケーション/進め方をご理解頂く上でも【環境構築編】 をご一読頂けると幸いです。

これからも頑張ってハンズオン系の記事を書いていきたいと思っているので、いいねっと思って頂けたらLGTM押していただけると励みになります!

環境構築

本サンプルアプリの環境構築方法は【環境構築編】に記載しているので、そちらをご参照ください。

クリーンアーキテクチャ風なディレクトリ設計

以下の記事を参考にしつつクリーンアーキテクチャ風なディレクトリ設計をしてみました。
各階層間をインターフェースを利用して、システムの各部分を疎結合化しております。

# 簡単のため一部ファイルは割愛しています
go-graphql-jwt-api/
├── build/
│    ├── db/
│    └── docker/
│
├── cmd/
│    └── main.go
│
├── pkg/
│    ├── adapter/
│    │    └── http/
│    │        ├── handler/
│    │        │      ├── graph_handler.go
│    │        │   └── login_handler.go
│    │        │
│    │        ├── middleware/
│    │        │     └── auht_middleware.go
│    │        │
│    │        ├── resolver/
│    │        │      ├── mutation.resolvers.go
│    │        │   └── query.resolvers.go
│    │        │
│    │        └── route/
│    │            └── route.go
│    │
│    ├── domain/
│    │    ├── model/
│    │    │     └── graph/
│    │    │         └── message.go
│    │    │
│    │    └── repository
│    │        └── message_repository.go
│    │
│    ├── infra/
│    │    ├── user.go
│    │    └── message_infra.go
│    │
│    ├── lib/
│    │    ├── config/
│    │    ├── graph
│    │    │      ├── generated
│    │    │      ├── loader
│    │    │   └── schema
│    │    │
│    │    ├── mock/
│    │    ├── sentry/
│    │    └── validator/
│    │
│    └── usecase/
│    │    ├── auth_usecase.go
│    │    └── message_usecase.go
│    │

マルチステージビルド

Next.js編と同様にマルチステージビルドを利用しています。AWSへのデプロイ用にrunnerステージではbuilderから必要最低限のファイルのみを指定して軽量化しています。

build/docker/go/Dockerfile
# ビルドステージ
FROM golang:1.19-alpine as builder

RUN apk --no-cache add gcc musl-dev

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .

RUN go build -trimpath -ldflags "-w -s" -o ./main cmd/main.go

# デプロイ用ステージ
FROM alpine:3.17 as runner

RUN apk update && \
    apk add --no-cache shadow && \
    useradd -m appuser && \
    rm -f /usr/bin/gpasswd /usr/bin/passwd /usr/bin/chfn /sbin/unix_chkpwd /usr/bin/expiry /usr/bin/chage /usr/bin/chsh && \
    rm -rf /var/cache/apk/*

USER appuser

WORKDIR /app
COPY --from=builder /app/main .

CMD ["./main"]

# 開発用ステージ
FROM golang:1.19-alpine3.17 as dev

ENV CGO_ENABLED 0
ENV GO111MODULE auto

RUN apk --no-cache add git

WORKDIR /app
COPY . /app

RUN go install github.com/go-delve/delve/cmd/dlv@v1.20.1 && \
    go install github.com/pressly/goose/v3/cmd/goose@v3.7.0 && \
    go install github.com/golang/mock/mockgen@v1.6.0 && \
    go install github.com/cosmtrek/air@v1.27.3  && \
    go install github.com/99designs/gqlgen@v0.17.24

GitHubActionsを用いたCI環境構築(golangci)

本Repositoryでも同様にGitHubActionsを使って静的解析とテストを実行しています。
静的解析ツールとしてはgolangci-lintを利用しています。

また、ワークフロー上でテストが完了した際にGitHubActions上で以下のようにカバレッジレポートを出力できるoctocovというライブラリを利用しています。

スクリーンショット 2023-05-22 7.39.18.png

.github/workflows/golangci.yml
name: golang-ci
on:
  push:
    branches:
      - main
  pull_request:
    paths:
      - "**.go"
      - .github/workflows/golangci.yml
jobs:
  golangci-lint:
    name: golangci-lint
    runs-on: ubuntu-latest
    steps:
      - name: Check out code into the Go module directory
        uses: actions/checkout@v3
      - name: golangci-lint
        uses: reviewdog/action-golangci-lint@v2
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          golangci_lint_flags: "--config=./.golangci.yml ./..."
          fail_on_error: true
          reporter: "github-pr-review"
.github/workflows/test.yml
name: Test

on:
  push:
    branches:
      - main
  pull_request:
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v3
      - name: setup
        uses: actions/setup-go@v3
        with:
          go-version-file: go.mod
      - name: Get dependencies
        run: go get -v -t -d ./...
      - name: testing
        run: go test ./... -coverprofile=coverage.out

      # レポート出力
      - name: create report
        uses: k1LoW/octocov-action@v0

ここでは割愛していますが、それぞれ以下の設定ファイルをプロジェクトのルートに格納している必要があります。

ツール 設定ファイル 目的
golangci-lint .golangci.yml 静的解析用の設定ファイル
octocov .octocov.yml カバレッジレポート用の設定ファイル

Gooseを使ったマイグレーション管理

Gooseは、Go言語で作られたデータベースのマイグレーションツールです。

SQLスクリプトとGoのコードを使用することで、データベースのスキーマ変更を管理し、バージョンコントロールを可能にします。

ver.3系にアップデートされてから設定方法が以前と異なっているようでした。

本アプリではサンプルとして以下のようなテンプレートコマンドをmakefileで用意しています。

/Makefile
# マイグレーション実行
migrate:
	docker-compose exec app goose -dir ./build/db/migration mysql "root:user@tcp(mysql:3306)/golang" up # mysqlはコンテナ名を指定

# マイグレーションファイル作成
create-migration: 
	docker-compose exec app goose -dir ./build/db/migration create insert_users sql  # ファイル名は適宜変更すること

migrationファイルは以下の用に記述します。

build/db/migration/20221210225619_createusers.sql
-- +goose Up
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY NOT NULL,
    name VARCHAR(255) DEFAULT NULL,
    email VARCHAR(255) DEFAULT NULL,
    password VARCHAR(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE INDEX user_id on users (id);

SET @encryption_key = 'passwordpassword';
INSERT INTO users (name, email, password)
VALUES
    ('user1', HEX(AES_ENCRYPT('test1@example.com', @encryption_key)), HEX(AES_ENCRYPT('password1', @encryption_key))),
    ('user2', HEX(AES_ENCRYPT('test2@example.com', @encryption_key)), HEX(AES_ENCRYPT('password2', @encryption_key))),
    ('user3', HEX(AES_ENCRYPT('test3@example.com', @encryption_key)), HEX(AES_ENCRYPT('password3', @encryption_key)));

-- +goose Down
DROP INDEX user_id;
DROP TABLE users;

Echo

Echoは、Go言語での高速なHTTPルーティングと中間処理を提供する高性能なフルスタックWebフレームワークです。

効率的なルーティング、各種HTTPメソッドのサポート、ミドルウェアなどをシンプルに記述できます。

本アプリでもルーティングやミドルウェアのハンドリングをメインに利用しています。

Routing

pkg/adapter/http/route/route.go

package route

import (
// ...省略
)

type Route interface {
	InitRouting(*config.Config) (*echo.Echo, error)
}

type InitRoute struct {
	Ch handler.Csrf
	Lh handler.Login
	Gh handler.Graph
	Ph http.HandlerFunc
	Am authMiddleware.Auth
}

// DI
func NewInitRoute(ch handler.Csrf, lh handler.Login, gh handler.Graph, ph http.HandlerFunc, am authMiddleware.Auth) Route {
	InitRoute := InitRoute{ch, lh, gh, ph, am}
	return &InitRoute
}

// Rooterメソッド
func (i *InitRoute) InitRouting(cfg *config.Config) (*echo.Echo, error) {
	e := echo.New()

    // ... 省略

	// Route
	e.GET("/healthcheck", func(c echo.Context) error {
		return c.String(http.StatusOK, "New deployment test")
	})
	e.GET("/csrf-cookie", i.Ch.CsrfHandler())
	e.POST("/login", i.Lh.LoginHandler())
	e.GET("/logout", i.Lh.LogoutHandler())
	e.POST("/query", i.Gh.QueryHandler())
	e.GET("/playground", func(c echo.Context) error {
		i.Ph.ServeHTTP(c.Response(), c.Request())
		return nil
	})

	// Start Server
	if err := e.Start(fmt.Sprintf(":%s", cfg.Port)); err != nil {
		return nil, xerrors.Errorf("fail to start port:%s %w", cfg.Port, err)
	}

	return e, nil
}

Middleware

他のフレームワーク同様にEchoでもリクエストとレスポンスの間に追加の処理を挟むための機能としてMiddlewareが存在しています。

本アプリケーションでもCSRF, CORS, JWTをMiddlewareで利用しています。

pkg/adapter/http/route/route.go

package route

// ... 省略

func (i *InitRoute) InitRouting(cfg *config.Config) (*echo.Echo, error) {
	e := echo.New()

	cookieDomain := ""
	if cfg.Env == "prd" {
		cookieDomain = "." + cfg.AppDomain
	}

	// middleware
	e.Use(
		middleware.Logger(),
		middleware.Recover(),

        // CORS
		middleware.CORSWithConfig(middleware.CORSConfig{
			AllowOrigins:     []string{cfg.FrontURL},
			AllowCredentials: true,
		}),

        // CSRF
		middleware.CSRFWithConfig(middleware.CSRFConfig{
			CookiePath:     "/",
			CookieSecure:   true,
			CookieDomain:   cookieDomain,
			CookieSameSite: http.SameSiteNoneMode,

        // 特定のPathのみMiddlewareの対象外とする
			Skipper: func(c echo.Context) bool {
				if strings.Contains(c.Request().URL.Path, "/healthcheck") {
					return true
				}
				if strings.Contains(c.Request().URL.Path, "/playground") {
					return true
				}
				if strings.Contains(c.Request().URL.Path, "/query") {
					return true
				}
				return false
			},
		}),

        // HandlerFunc型を返す独自ミドルウェア
		i.Am.AuthMiddleware,
	)

// ... 省略
}

JWT認証

本アプリでは認証方法にJWTを利用しています。

ログイン成功した場合にクッキーにトークンをセット、以後はミドルウェアでリクエスト毎にJWT検証を行うようにしています。

pkg/adapter/http/middleware/auht_middleware.go

package middleware

import (
// ... 省略
)

type Auth interface {
	AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc
}

type AuthMiddleware struct {
	AuthUseCase usecase.Auth
}

func NewAuthMiddleware(ju usecase.Auth) Auth {
	AuthMiddleware := AuthMiddleware{
		AuthUseCase: ju,
	}
	return &AuthMiddleware
}

func (j *AuthMiddleware) AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) (err error) {
		// 特定のパスのみJWT処理をスキップ
		if j.isSkippedPath(c.Request().URL.Path, c.Request().Referer()) {
			if err := next(c); err != nil {
				return xerrors.Errorf("AuthMiddleware error path: %s: %w", c.Request().URL.Path, err)
			}
			return nil
		}

		// クッキーからトークン取得
		cookie, err := c.Cookie("token")
		if err != nil {
			return xerrors.Errorf("AuthMiddleware not extract cookie: %w", err)
		}

		// JWT解析・検証
		claims, err := j.AuthUseCase.JwtParser(cookie.Value)
		if err != nil {
			j.AuthUseCase.DeleteCookie(c, cookie)
			return fmt.Errorf("failed to parse jwt claims: %w", err)
		}

		// ユーザーデータと照会
		var cl = *claims
		uId := cl["user_id"].(string)
		if err := j.AuthUseCase.IdentifyJwtUser(uId); err != nil {
			j.AuthUseCase.DeleteCookie(c, cookie)
			return fmt.Errorf("failed to personal authentication: %w", err)
		}

		if err := next(c); err != nil {
			return xerrors.Errorf("failed to AuthMiddleware err: %w", err)
		}

		return nil
	}
}

func (j *AuthMiddleware) isSkippedPath(reqPath, refPath string) bool {
	skippedPaths := []string{"/healthcheck", "/csrf-cookie", "/login", "/logout", "/playground"}
	for _, path := range skippedPaths {
		if strings.Contains(reqPath, path) || strings.Contains(refPath, path) {
			return true
		}
	}

	return false
}

pkg/usecase/auth_usecase.go

package usecase

import (
// ... 省略
)

type Auth interface {
	Login(c echo.Context, fv *FormValue) (string, error)
	JwtParser(auth string) (*jwt.MapClaims, error)
	IdentifyJwtUser(id string) error
	DeleteCookie(c echo.Context, ck *http.Cookie)
}

type AuthUseCase struct {
	config   *config.Config
	userRepo repository.User
}

type FormValue struct {
	Email    string `json:"email" validate:"required,email"`
	Password string `json:"password" validate:"required"`
}

// 独自クレーム型
type jwtCustomClaims struct {
	UserId string `json:"user_id"`
	jwt.RegisteredClaims
}

func NewAuthUseCase(userRepo repository.User, config *config.Config) Auth {
	AuthUseCase := AuthUseCase{
		userRepo: userRepo,
		config:   config,
	}
	return &AuthUseCase
}

// JWTパース
func (a *AuthUseCase) JwtParser(auth string) (*jwt.MapClaims, error) {
	token, err := jwt.Parse(auth, func(token *jwt.Token) (interface{}, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, xerrors.Errorf("unexpected signing method: %v", token.Header["alg"])
		}

		return []byte(a.config.JwtSecret), nil
	})

	if err != nil {
		return nil, xerrors.Errorf("JwtParser failed: %v", err)
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok || !token.Valid {
		return nil, xerrors.Errorf("inValid claims: %v", claims)
	}

	return &claims, nil
}


// ログイン時のフォームデータ検証とトークンセット
func (a *AuthUseCase) Login(c echo.Context, fv *FormValue) (string, error) {
	// Email暗号化
	encEmail, err := a.userRepo.Encrypt(fv.Email)
	if err != nil {
		return "", fmt.Errorf("login failed at Encrypt err %w", err)
	}

	// 暗号化済みEmailで取得
	user, err := a.userRepo.GetUserByEmail(encEmail)
	if err != nil {
		return "", fmt.Errorf("login failed at GetUserByEmail err %w", err)
	}

	// DBから取得したpasswordを復号化
	pass, err := a.userRepo.Decrypt(user.Password)
	if err != nil {
		return "", fmt.Errorf("login failed at Decrypt err %w", err)
	}

	// フォームデータと照会
	if pass != fv.Password {
		return "", fmt.Errorf("login failed at compare pass err %w", err)
	}

	// 独自クレーム作成
	claims := &jwtCustomClaims{
		user.ID,
		jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 1)),
		},
	}

	// トークン作成
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	t, err := token.SignedString([]byte(a.config.JwtSecret))
	if err != nil {
		return "", xerrors.Errorf("login failed at NewWithClaims err %w", err)
	}

	// 日本時間
	time.Local = time.FixedZone("Local", 9*60*60)
	jst, err := time.LoadLocation("Local")
	if err != nil {
		return "", xerrors.Errorf("login failed at LoadLocation err %w", err)
	}
	nowJST := time.Now().In(jst)

	// CookieにJWT格納
	cookie := new(http.Cookie)
	cookie.Name = "token"
	cookie.Value = t
	cookie.Expires = nowJST.Add(1 * time.Hour)
	cookie.Path = "/"
	cookie.HttpOnly = true
	cookie.Secure = true
	cookie.SameSite = http.SameSiteNoneMode
	c.SetCookie(cookie)

	return user.ID, nil
}

// DB照会
func (a *AuthUseCase) IdentifyJwtUser(id string) error {
	_, err := a.userRepo.GetUserById(id)
	if err != nil {
		return fmt.Errorf("IdentifyJwtUser err %w", err)
	}

	return nil
}

// Cookieリセット
func (a *AuthUseCase) DeleteCookie(c echo.Context, ck *http.Cookie) {
	ck.Value = ""
	ck.MaxAge = -1
	c.SetCookie(ck)
}

pkg/adapter/http/handler/login_handler.go
package handler

import (
// ... 省略
)

type Login interface {
	LoginHandler() echo.HandlerFunc
	LogoutHandler() echo.HandlerFunc
}

type LoginHandler struct {
	AuthUseCase usecase.Auth
}

func NewLoginHandler(au usecase.Auth) Login {
	LoginHandler := LoginHandler{
		AuthUseCase: au,
	}
	return &LoginHandler
}

func (l *LoginHandler) LoginHandler() echo.HandlerFunc {
	return func(c echo.Context) (err error) {
		var fv = &usecase.FormValue{
			Email:    c.FormValue("email"),
			Password: c.FormValue("password"),
		}

		if err = c.Validate(fv); err != nil {
			return xerrors.Errorf("login validate err: %w", err)
		}

		userId, err := l.AuthUseCase.Login(c, fv)
		if err != nil {
			return fmt.Errorf("login failed err: %w", err)
		}

		return c.JSON(http.StatusOK, echo.Map{
			"userId": userId,
		})
	}
}

// ... 省略

GORM

GORMはORMライブラリで、MySQL, PostgreSQL, SQLiteなど多様なDBに対応しています。
SQLデータベースの操作を抽象化、操作し易くします。

pkg/infra/db_conn.go
package infra

import (
// ... 省略
)

func NewDBConnector(cfg *config.Config) (*gorm.DB, error) {
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", cfg.DBUser, cfg.DBPass, cfg.DBHost, cfg.DBPort, cfg.DBName)
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

	if err != nil {
		return nil, xerrors.Errorf("db connection failed:%w", err)
	}

	return db, nil
}
pkg/infra/user_infra.go
package infra

// ... 省略

func (r *userRepository) GetUserByEmail(email string) (*graph.User, error) {
	var user *graph.User
	if err := r.db.Where("email = ?", email).First(&user).Error; err != nil {
		return nil, xerrors.Errorf("get users by email failed , %w", err)
	}

	return user, nil
}

// ... 省略

func (r *userRepository) Encrypt(plain string) (string, error) {
	var enc string
	err := r.db.Raw("SELECT HEX(AES_ENCRYPT(?, ?))", plain, r.config.EncryptKey).Scan(&enc).Error
	if err != nil {
		return enc, xerrors.Errorf("encrypt email failed , %w", err)
	}

	return enc, nil
}

caarlos0/envを使った環境変数管理

encoding/jsonのような感覚で書いたstruct内でタグを付与することで、環境変数をわかりやすく取り扱えます。

pkg/lib/config/config.go
package config

import (
	"github.com/caarlos0/env/v6"
	"golang.org/x/xerrors"
)

type Config struct {
	AppDomain  string `env:"API_APP_DOMAIN" envDefault:""`
	Env        string `env:"API_ENV" envDefault:"dev"`
	Port       string `env:"API_PORT" envDefault:"8080"`
	FrontURL   string `env:"API_FRONT_URL" envDefault:"https://localhost:3443"`
    // ... 省略
}

func New() (*Config, error) {
	cfg := &Config{}
	if err := env.Parse(cfg); err != nil {
		return nil, xerrors.Errorf("fail to parse cfg: %w", err)
	}
	return cfg, nil
}

gqlgenを利用したGraphQLのスキーマ駆動開発

GraphQLの主なメリットはアンダーフェッチ(必要なデータを一度に柔軟に取得できない)とオーバーフェッチ(不必要なデータまで取得してしまう)問題の解決です。

本アプリでは、gqlgenを用いてGraphQLサーバーを構築しています。

同ライブラリを用いることで、スキーマファーストな開発を進める事ができ、かつ自動生成されるGoのコードとの間に不整合が生じることがなく管理も容易になります。

以下のLayerXさんの技術ブログが非常にわかりやすかったので掲載させて頂きます。

ディレクトリ階層

ディレクトリ設計
# gqlgenに該当する箇所のみ抜粋
go-graphql-jwt-server/
│
├── pkg/
│    ├── adapter/
│    │    └── http/
│    │        ├── resolver/ # schema.graphqlから生成されたresolverの実装ファイル
│    │              ├── mutation.resolvers.go
│    │              ├── query.resolvers.go
│    │              ├── resolver.go
│    │            └── type.resolvers.go
│    │
│    ├── domain/
│    │    ├── model/
│    │    │     ├──  graph/
│    │    │     │    └── models_gen.go # スキーマを元に自動生成されるモデル/構造体
│    │    │     └──  message.go # 個別で定義したカスタムモデル/構造体
│    │
│    ├── lib/
│    │    ├── graph
│    │    │      ├── generated/  # 自動生成されたパッケージ(基本的に触らない)
│    │    │   │   └── generated.go
│    │    │      ├── loader/  
│    │    │   │   └── loaders.go
│    │    │   └── schema/  # GraphQLのスキーマ定義ファイル
│    │    │       ├── input.graphqls
│    │    │       ├── mutation.graphqls
│    │    │       ├── query.graphqls
│    │    │       └── type.graphqls
│    
├── gqlgen.yml # gqlgenの設定ファイル

Makefileに記載されているコマンドを実行するとで、GraphQLのスキーマ定義ファイルが格納されている、/schemaディレクトリ配下のGraphQLスキーマファイルを読み取り、ファイルを自動生成します。

 Makefile

gqlgen:
	docker-compose exec app go run github.com/99designs/gqlgen generate

ディレクトリ ファイル名 内容
pkg/domain/model/graph/ models_gen.go スキーマを元に自動生成されるモデル/構造体
pkg/adapter/http/resolver/ type.resolvers.go, resolver.go, query.resolvers.go, mutation.resolvers.go schema.graphqlから生成されたresolverの実装ファイルを記述するための雛形。Handlerのようなもの。基本ここに実装していく。
pkg/lib/graph/generated/ generated.go スキーマを元に自動生成されるパッケージ。変更しない。

それぞれの出力先はgqlgen.ymlで指定することができるため、採用しているアーキテクチャや設計思想に合わせて出力先を変更する事ができます。

gqlgen.yml
# Where are all the schema files located? globs are supported eg  src/**/*.graphqls
schema:
  - pkg/lib/graph/schema/*.graphqls

# Where should the generated server code go?
exec:
  filename: pkg/lib/graph/generated/generated.go
  package: generated

# ...省略

# Where should any generated models go?
model:
  filename: pkg/domain/model/graph/models_gen.go
  package: graph

# Where should the resolver implementations go?
resolver:
  layout: follow-schema
  dir: pkg/adapter/http/resolver
  package: resolver

# ...省略

# gqlgen will search for any type names in the schema in these go packages
# if they match it will use them, otherwise it will generate them.
autobind:
  - "github.com/WebEngrChild/go-graphql-server/pkg/domain/model"

# ...省略

基本的に上記コマンドを実行することで、スキーマファイルをベースに自動生成を行い開発を進めていく形になります。

Query/Mutation

GraphQLでは、データの更新をQuery, データの取得をMutationを使って行います。

gqlgenでは、QueryMutationに該当するGoのデータ型と実装用の雛形を自動で生成してくれます。

スキーマファイル

input.graphqls
input NewMessage {
    text: String!
    userId: String!
}
mutation.graphqls
type Mutation {
    createMessage(input: NewMessage!): Message!
    updateMessage(userId: String!): Message!
}
query.graphqls
type Query {
    getMessages: [Message!]!
}

Query/Mutation resolver

gqlgenが出力する、resolverはよくあるcontrollerやhandlerと役割は同じで、詳細の実装はresolverに処理をベタ書きでも、MVCでもDDDでも実装者の好きに実装できます。

type Querytype Mutationは以下の通りに出力されます。
本アプリケーションでは、このメソッド内でUseCase層を呼び出し、ビジネスロジックをまとめています。

pkg/adapter/http/resolver/mutation.resolvers.go
package resolver

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.24

import (
// ... 省略
)

// CreateMessage is the resolver for the createMessage field.
func (r *mutationResolver) CreateMessage(ctx context.Context, input graph.NewMessage) (*model.Message, error) {
	// 入力値のエスケープ処理
	escapedText := html.EscapeString(input.Text)
	created, err := r.MsgUseCase.CreateMessage(&escapedText, &input.UserID)
	if err != nil {
		err = fmt.Errorf("resolver CreateMessage() err %w", err)
		sentry.CaptureException(err)
		return nil, err
	}

	return created, nil
}

// Mutation returns generated.MutationResolver implementation.
func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} }

type mutationResolver struct{ *Resolver }

pkg/adapter/http/resolver/query.resolvers.go
package resolver

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.24

import (
// ... 省略
)

// GetMessages is the resolver for the getMessages field.
func (r *queryResolver) GetMessages(ctx context.Context) ([]*model.Message, error) {
	todos, err := r.MsgUseCase.GetMessages()
	if err != nil {
		err = fmt.Errorf("resolver Todos() err %w", err)
		sentry.CaptureException(err)
		return nil, err
	}

	return todos, nil
}

// Query returns generated.QueryResolver implementation.
func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} }

type queryResolver struct{ *Resolver }

フィールド解決としてのリゾルバ機能(カスタムモデル)

GraphQLクエリはネストされたフィールドを持つことができます。GraphQLにおいて、リゾルバはこれらのネストされたフィールドのデータを取得を管理するための役割を担います。

gqlgenはこのフィールド解決機能をカスタムモデルという機能を使って実現します。

このアプリでは、messagesテーブルとusersテーブルにリレーションがあり、外部キーuser_idでリレーションが貼られています。

スキーマファイル

type.graphqls
type Message {
  id: ID!
  text: String!
  user: User!
  created_at: String!
  updated_at: String!
}
type User {
  id: ID!
  name: String!
  email: String!
  password: String!
}

自動生成される構造体

gqlgenは、スキーマファイルを元にモデル/構造体をmodel_gen.goに自動生成します。

pkg/domain/model/graph/models_gen.go
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.

package graph

type NewMessage struct {
	Text   string `json:"text"`
	UserID string `json:"userId"`
}

type User struct {
	ID       string `json:"id"`
	Name     string `json:"name"`
	Email    string `json:"email"`
	Password string `json:"password"`
}

カスタムモデル

上述の通り、GraphQLでは内部にリゾルバという機能があり、この機能によってクライアントからリクエストされるデータにリレーションがある際でも、データを柔軟に取得することができます。

gqlgenでは、カスタムモデルという機能を使って、リレーション構造がある場合のリクエストに対応します。

カスタムモデルを使うことで、GraphQLのインターフェイスであるスキーマファイルpkg/lib/graph/schema/type.graphqlsを修正することなく、リレーションに必要なID等をresolver内に渡す事ができます。

pkg/domain/model/message.go
// 開発者側で作成するカスタムモデル
package model

type Message struct {
	ID        string `json:"id"`
	Text      string `json:"text"`
	UserID    string `json:"user_id"` // User()内で利用されるリレーションキー
	CreatedAt string `json:"created_at"`
	UpdatedAt string `json:"updated_at"`
}

model resolver

スキーマファイルで指定したtypeとカスタムモデルで独自定義したstructに差分がある場合(ここではtypeのuser: User!)、対応するmodel用のリゾルバ(ここではUser())が作成され、 queryで指定された時のみ当該メソッドが実行されます。

ここでは、getMessages内でuserフィールドが指定されるとこのUser()メソッドが実行される形です。

pkg/adapter/http/resolver/type.resolvers.go
package resolver

// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.24

import (
// ... 省略
)

// メッセージ内で呼び出された時に実行されるメソッド
// User is the resolver for the user field.
func (r *messageResolver) User(ctx context.Context, obj *model.Message) (*graph.User, error) {
    // ここで外部キーを*model.Messageから取得できる
	if obj.UserID == "" {
		return nil, nil
	}
	user, err := r.UserUseCase.LoadUser(ctx, obj.UserID)
	if err != nil {
		err = fmt.Errorf("resolver User() err %w", err)
		sentry.CaptureException(err)
		return nil, err
	}
	return user, nil
}

// Message returns generated.MessageResolver implementation.
func (r *Resolver) Message() generated.MessageResolver { return &messageResolver{r} }

type messageResolver struct{ *Resolver }

N+1対策のDataLoader

本実装を行わなくてもGraphQLサーバーとしての利用をすることは可能ですが、N+1問題を抱えています。

そこでGraphQLでは、DataLoaderという仕組みを用いて解決を行っています。

具体的には、クライアントからのDBへのリクエストをある一定期間プールして、まとめて実行することでクエリ回数を減少させます。

この、DataLoaderのリクエストを待ち合わせて読み込む仕組みを遅延読み込み(Lazy Loading)と言います。

本アプリでは、gqlgenでの公式リファレンスでも紹介されているgraph-gophers/dataloader用いて実装します。

ここでは、本アプリで利用しているコードを掲載するにとどめ、詳細は以下のlayerx様の記事に代えさせていただきます。

pkg/adapter/http/resolver/type.resolvers.go
package resolver

// ... 省略

// User is the resolver for the user field.
func (r *messageResolver) User(ctx context.Context, obj *model.Message) (*graph.User, error) {
	if obj.UserID == "" {
		return nil, nil
	}

	// データローダー機能を使ってユーザーを取得
	user, err := r.UserUseCase.LoadUser(ctx, obj.UserID)

    // ... 省略

	return user, nil
}

// ... 省略
pkg/adapter/http/handler/graph_handler.go
package handler

// ... 省略

func (g *GraphHandler) QueryHandler() echo.HandlerFunc {
	// 各DataLoaderを取りまとめるstructを初期化
	ldr := &loader.Loaders{

		// User取得用のLoaderを初期化
		UserLoader: dataloader.NewBatchedLoader(

			// データ取得用のdataloader.BatchFuncを実装したメソッド
			g.UserUseCase.BatchGetUsers,

			// キャッシュオプション
			dataloader.WithCache(&dataloader.NoCache{}),
		),
	}

	rslvr := resolver.Resolver{
		MsgUseCase:  g.MsgUseCase,
		UserUseCase: g.UserUseCase,
	}

	srv := handler.NewDefaultServer(
		generated.NewExecutableSchema(generated.Config{Resolvers: &rslvr}),
	)

	return func(c echo.Context) error {

		// Loadersをcontextにインジェクト
		loader.Middleware(ldr, srv).ServeHTTP(c.Response(), c.Request())
		return nil
	}
}

pkg/usecase/user_usecase.go
package usecase

// ... 省略

// BatchGetUsers dataloader.BatchFuncを実装したユーザーの一覧を取得するメソッド
func (u *UserUseCase) BatchGetUsers(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
	// dataloader.Keysの型を[]stringに変換する
	userIDs := make([]string, len(keys))

    // ... 省略

	// リポジトリー層からデータをまとめて取得
	log.Printf("BatchGetUsers(id = %s)\n", strings.Join(userIDs, ","))
	userByID, err := u.userRepo.GetMapInIDs(ctx, userIDs)

    // ... 省略

	// []*model.User[]*dataloader.Resultに変換する
	output := make([]*dataloader.Result, len(keys))
    
    // ... 省略

	return output
}

// LoadUser dataloader.Loadをwrapして型づけした実装
func (u *UserUseCase) LoadUser(ctx context.Context, userID string) (*graph.User, error) {
	log.Printf("LoadUser(id = %s)\n", userID)

	// GetLoaders ContextからLoadersを取得する
	loaders := loader.GetLoaders(ctx)

	// 遅延読み込み(キャッシュ取得・生成管理)
	thunk := loaders.UserLoader.Load(ctx, dataloader.StringKey(userID))
	result, err := thunk()
	if err != nil {
		return nil, xerrors.Errorf("LoadUser err %w", err)
	}
	user := result.(*graph.User)
	log.Printf("return LoadUser(id = %s, name = %s)\n", user.ID, user.Name)
	return user, nil
}

pkg/lib/graph/loader/loaders.go
package loader

// ... 省略

type ctxKey string

const (
	loadersKey = ctxKey("dataloaders")
)

// Loaders 各DataLoaderを取りまとめるstruct
type Loaders struct {
	UserLoader *dataloader.Loader
}

// BatchFuncMap 外部から渡されるBatchFunc型を束ねたもの
type BatchFuncMap map[string]*dataloader.BatchFunc

// Middleware LoadersをcontextにインジェクトするHTTPミドルウェア
func Middleware(loaders *Loaders, next http.Handler) http.Handler {
	loaders.UserLoader.ClearAll()
	// return a middleware that injects the loader to the request context
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		nextCtx := context.WithValue(r.Context(), loadersKey, loaders)
		r = r.WithContext(nextCtx)
		next.ServeHTTP(w, r)
	})
}

// GetLoaders ContextからLoadersを取得する
func GetLoaders(ctx context.Context) *Loaders {
	return ctx.Value(loadersKey).(*Loaders)
}

Sentryを使ったエラー通知

本アプリケーションではSentryを使ったエラー通知を行っています。

以下の記事を参考に実装してみました。

事前にSentryに登録する必要があるので注意してください。

.env
# 登録時に出力されるDSNを入力
API_SENTRY_DSN=https://xxxxxxxx@yyyyyy.ingest.sentry.io/999999999999

package sentry

import (
// ...省略
)

func SetUp(c *config.Config) error {
	if err := sentry.Init(sentry.ClientOptions{
		Dsn:              c.SentryDsn,
		Environment:      c.Env,
		Debug:            true,
		AttachStacktrace: true,
		TracesSampleRate: 1.0,
		// BeforeSend のフックで Event を書き換え
		BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
			for i := range event.Exception {
				exception := &event.Exception[i]
				// fmt.wrapError, xerrors.wrapError 以外は何もしない
				if !strings.Contains(exception.Type, "wrapError") {
					continue
				}
				// 最初の : で分割(正しく Wrapされていないものは無視)
				sp := strings.SplitN(exception.Value, ":", 2)
				if len(sp) != 2 {
					continue
				}
				// : の前を Typeに、 : より後ろを Value に
				exception.Type, exception.Value = sp[0], sp[1]
			}
			return event
		},
	}); err != nil {
		return xerrors.Errorf("fail to init sentry: %w", err)
	}

	return nil
}
cmd/main.go
package main

import (
// ... 省略
)

func main() {
	// Config
	c, cErr := config.New()

	// Sentryの初期化
	if err := initSentry.SetUp(c); err != nil {
		sentry.CaptureException(fmt.Errorf("initSentry err: %w", err))
	}

	// DB
	db, err := infra.NewDBConnector(c)
	if err != nil {
		sentry.CaptureException(fmt.Errorf("initDb err: %w", err))
	}
    
    // ... 省略

	defer func() {
		// .envが存在しない場合
		if cErr != nil {
			sentry.CaptureException(fmt.Errorf("config err: %w", cErr))
		}
		// panic の場合も Sentry に通知する場合は Recover() を呼ぶ
		sentry.Recover()
		// サーバーへは非同期でバッファしつつ送信するため、未送信のものを忘れずに送る(引数はタイムアウト時間)
		sentry.Flush(2 * time.Second)
	}()

Go Mockを使ったテスト

Go MockはGoのコードに対するモックオブジェクトを生成するためのツールです。

インターフェースに対してモックを生成し、テスト中にこれらのモックを使用して関数の振る舞いを制御することができます。

create-mock: # ファイル名は適宜変更すること
	docker-compose exec app mockgen -source=pkg/domain/repository/user_repository.go -destination pkg/lib/mock/mock_user.go

上記コマンドを実行することで自動的にモックファイルを生成することができます。

ディレクトリ設計
# gqlgenに該当する箇所のみ抜粋
go-graphql-jwt-server/
│
├── pkg/
│    │
│    ├── route/
│    │    ├── testdata/ # テスト用データ
│    │    │   ├── ok_res.json.golden
│    │    │   └── ok_req.json.golden
│    │    ├── route.go
│    │    └── route_test.go
│    │
│    ├── lib/
│    │    ├── mock/ # 自動生成されるモックファイル
│    │    │      ├── mock_message.go
│    │    │   └── mock_user.go
pkg/adapter/http/route/route_test.go
package route

import (
 // ... 省略
)

func TestQueryRoute(t *testing.T) {
	// Generate Mock Repository
	ctrlMessage := gomock.NewController(t)
	defer ctrlMessage.Finish()
	mmr := repository.NewMockMessage(ctrlMessage)
	mRes := []*model.Message{
		{ID: "1", Text: "testMessage1", UserID: "1", CreatedAt: "", UpdatedAt: ""},
	}
	mmr.EXPECT().GetMessages().Return(mRes, nil)

	ctrlUser := gomock.NewController(t)
	defer ctrlUser.Finish()
	mur := repository.NewMockUser(ctrlUser)
	ids := []string{"1"}
	uRes := map[string]*graph.User{
		"1": {ID: "1", Name: "testUser1"},
	}
	mur.EXPECT().GetMapInIDs(gomock.Any(), ids).Return(uRes, nil)

	// DI
	mu := usecase.NewMsgUseCase(mmr)
	uu := usecase.NewUserUseCase(mur)
	gh := handler.NewGraphHandler(mu, uu)

	// Routing
	e := echo.New()
	e.POST("/query", gh.QueryHandler())

	// Create a new http request to test the server
	reqFile := "testdata/ok_req.json.golden"
	reqbody := loadFile(t, reqFile)
	body := strings.NewReader(string(reqbody))
	req := httptest.NewRequest(http.MethodPost, "/query", body)
	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	rec := httptest.NewRecorder()

	// Serve the http request
	e.ServeHTTP(rec, req)

	// Assert the response
	assert.Equal(t, http.StatusOK, rec.Code)
	rspFile := "testdata/ok_res.json.golden"
	wnt := loadFile(t, rspFile)
	assert.JSONEq(t, string(wnt), string(rec.Body.Bytes()))
}

func loadFile(t *testing.T, path string) []byte {
	t.Helper()

	bt, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("cannot read from %q: %v", path, err)
	}
	return bt
}

ok_req.json.golden
{
    "query": "query { getMessages { id text created_at user { name } } }"
}
ok_res.json.golden
{
    "data": {
        "getMessages": [
            {
                "id": "1",
                "text": "testMessage1",
                "created_at": "",
                "user": {
                    "name": "testUser1"
                }
            }
        ]
    }
}

appendix

Airを利用したホットリロード開発

golangをDockerコンテナで扱う時、ローカルでのコードの変更をコンテナ側に反映するためにリロードをする必要があります。

つまり更新コードの確認のたびにdocker restart や docker-compose restartを実行する必要があります。Airはこのリロード作業を自動化してくれます。

設定ファイルは.air.tomlになります。

air:
	docker-compose exec app air -c .air.toml

delveを使ったデバッグ

Makefile
app-dlv:  # コンテナ内でdlv直接実行
	docker-compose exec app dlv debug ./cmd/main.go
dlv:  # Golandを使ったdlv
	docker-compose exec app dlv debug ./cmd/main.go --headless --listen=:2345 --api-version=2 --accept-multiclient

私と同様にgolandを利用している場合は以下の手順を試してください。

Terminal
$ make dlv

image.png

image.png

この後は

■ご案内■

これからも頑張ってハンズオン系の記事を書いていきたいと思っているので、いいねっと思って頂けたらLGTM押していただけると励みになります!

72
55
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
72
55