はじめに
■ご案内■
本連載の背景/作成できるアプリケーション/進め方をご理解頂く上でも【環境構築編】 をご一読頂けると幸いです。
- 【環境構築編】
- 【Next.js編】
- 【Go編】 👈いまここです
- 【AWS編】
これからも頑張ってハンズオン系の記事を書いていきたいと思っているので、いいねっと思って頂けたら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
から必要最低限のファイルのみを指定して軽量化しています。
# ビルドステージ
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
というライブラリを利用しています。
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"
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
で用意しています。
# マイグレーション実行
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ファイルは以下の用に記述します。
-- +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
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で利用しています。
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検証を行うようにしています。
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
}
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)
}
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データベースの操作を抽象化、操作し易くします。
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
}
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内でタグを付与することで、環境変数をわかりやすく取り扱えます。
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スキーマファイルを読み取り、ファイルを自動生成します。
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
で指定することができるため、採用しているアーキテクチャや設計思想に合わせて出力先を変更する事ができます。
# 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では、Query
とMutation
に該当するGoのデータ型と実装用の雛形を自動で生成してくれます。
スキーマファイル
input NewMessage {
text: String!
userId: String!
}
type Mutation {
createMessage(input: NewMessage!): Message!
updateMessage(userId: String!): Message!
}
type Query {
getMessages: [Message!]!
}
Query/Mutation resolver
gqlgenが出力する、resolverはよくあるcontrollerやhandlerと役割は同じで、詳細の実装はresolverに処理をベタ書きでも、MVCでもDDDでも実装者の好きに実装できます。
type Query
とtype Mutation
は以下の通りに出力されます。
本アプリケーションでは、このメソッド内でUseCase
層を呼び出し、ビジネスロジックをまとめています。
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 }
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 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
に自動生成します。
// 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
内に渡す事ができます。
// 開発者側で作成するカスタムモデル
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()
メソッドが実行される形です。
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
様の記事に代えさせていただきます。
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
}
// ... 省略
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
}
}
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
}
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
に登録する必要があるので注意してください。
# 登録時に出力される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
}
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
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
}
{
"query": "query { getMessages { id text created_at user { name } } }"
}
{
"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を使ったデバッグ
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
を利用している場合は以下の手順を試してください。
$ make dlv
この後は
■ご案内■
- 【環境構築編】
- 【Next.js編】
- 【Go編】
- 【AWS編】 👈次はこちらへ
これからも頑張ってハンズオン系の記事を書いていきたいと思っているので、いいねっと思って頂けたらLGTM押していただけると励みになります!