PR

Go言語(Golang)でGraphQLのBFFを開発する方法まとめ

3. 応用

こんにちは。Tomoyuki(@tomoyuki65)です。

マイクロサービスなどでバックエンドAPIが複数あり、エンドポイントが多くなりすぎてフロントエンド側(Webとアプリなど)で使いづらくなった場合、GraphQLのBFF(Backend For Frontend)に集約するケースがあったりします。

その際にBFFはフロントエンドに合わせてNest.jsなどのJavaScript(TypeScript)系のフレームワークで作ることが多いと思いますが、パフォーマンスを最重視するのであればGo言語(Golang)のGraphQLで作る方が最適です。

そこでこの記事では、Go言語(Golang)でGraphQLのBFFを開発する方法についてまとめます。

 

Go言語(Golang)でGraphQLのBFFを開発する方法まとめ

まずは以下のコマンド実行して各種ファイルを作成します。

$ mkdir go-graphql && cd go-graphql
$ mkdir -p deploy/docker/local && touch deploy/docker/local/Dockerfile
$ mkdir src
$ touch compose.yml

※いつものようにDockerを使って環境構築するため、事前にDockerが使える環境を準備して下さい。

 

次に作成したファイルをそれぞれ以下のように記述します。

・「deploy/docker/local/Dockerfile」

FROM golang:1.25.4-alpine3.21

# タイムゾーン設定
ENV TZ=Asia/Tokyo

WORKDIR /go/src

COPY ./src .

# go.modがあれば依存関係をインストール
RUN if [ -f ./go.mod ]; then \
      go install; \
    fi

# gqlgenをインストール
RUN go install github.com/99designs/gqlgen@v0.17.83

# 開発用のライブラリをインストール
RUN go install github.com/air-verse/air@v1.63.1
RUN go install honnef.co/go/tools/cmd/staticcheck@latest
RUN go install go.uber.org/mock/mockgen@latest

EXPOSE 8080

※Goのバージョン「1.25.4」に合わせて、GraphQL用の「gqlgen」のバージョンは「0.17.83」、ホットリロード用の「air」のバージョンは「1.63.1」に固定しています。また静的コード解析に「staticcheck」、テスト用のモック化に「mockgen」を利用します。

 

・「compose.yml」

services:
  graphql:
    container_name: go-graphql
    build:
      context: .
      dockerfile: ./deploy/docker/local/Dockerfile
    command: air -c .air.toml
    volumes:
      - ./src:/go/src
    ports:
      - "8080:8080"
    tty: true
    stdin_open: true

 

次に以下のコマンドを実行し、Dockerコンテナをビルドします。

$ docker compose build --no-cache

 

次に以下のコマンドを実行し、Goとgqlgenとairの初期化処理を行います。

$ docker compose run --rm graphql go mod init go-graphql
$ docker compose run --rm graphql go get github.com/99designs/gqlgen
$ docker compose run --rm graphql gqlgen init
$ docker compose run --rm graphql air init

 

コマンド実行後、下図のように各種ファイルが作成されればOKです。

 

次に以下のコマンドを実行し、コンテナを起動します。

$ docker compose up -d

 

次にブラウザで「http://localhost:8080」にアクセスし、下図のようなGraphQLのPlayground画面が表示されればOKです。

 

デフォルトのresolver(リゾルバー)を修正してテスト

上記で作成されたデフォルトのresolver(リゾルバー)では実行時にエラーになるように設定されているため、ファイル「src/graph/schema.resolvers.go」にあるTodosの処理を以下のように修正した後にテストしてみます。

・「src/graph/schema.resolvers.go」

package graph

// 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.83

import (
    "context"
    "fmt"
    "go-graphql/graph/model"
)

// CreateTodo is the resolver for the createTodo field.
func (r *mutationResolver) CreateTodo(ctx context.Context, input model.NewTodo) (*model.Todo, error) {
    panic(fmt.Errorf("not implemented: CreateTodo - createTodo"))
}

// Todos is the resolver for the todos field.
func (r *queryResolver) Todos(ctx context.Context) ([]*model.Todo, error) {
    // Todoを作成
    todo := &model.Todo{
        ID: "T00001",
        Text: "タスク1",
        Done: false,
        User: &model.User{
            ID: "xxxx-xxxx-0001",
            Name: "田中太郎",
        },
    }

    // 戻り値の設定
    todos := []*model.Todo{todo}

    return todos, nil
}

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

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

type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

※リゾルバーとはGraphQLにおけるデータの取り方を実装する部分です。ファイル「src/graph/schema.graphqls」の定義より自動生成されたリゾルバーファイルが「src/graph/schema.resolvers.go」になります。今回使用しているライブラリ「gqlgen」を使うことで、.graphqlsファイルを定義してから.resolvers.goファイルを自動生成することができ、リゾルバーファイル内のQueryやMutationの中身を記述することで実行時の処理を作れます。

 

次にGraphQLのPlayground画面から以下のクエリを実行します。

query {
  todos {
    text
    done
    user {
      name
    }
  }
}

 

クエリ実行後、下図のように指定したプロパティのデータのみが取得できていればOKです。

 

尚、Postmanを使った実行を試したい場合は、エンドポイントとして「http://localhost:8080/query」を使います。

 

次に以下のコマンドを実行し、一度サーバーを停止しておきます。

$ docker compose down

 

スポンサーリンク

userスキーマの追加を例にGraphQLのAPIを作成する

次にuserスキーマの追加を例にGraphQLのAPIを作成してみます。

まずは上記でも使用したデフォルトのファイル「src/graph/schema.graphqls」にある既存の定義が邪魔なので、以下のように修正します。

・「src/graph/schema.graphqls」

# GraphQL schema example
#
# https://gqlgen.com/getting-started/

type Query {
  getSample: String!
}

input SampleInput {
  text: String!
}

type Mutation {
  updateSample(input: SampleInput!): String!
}

 

次に以下のコマンドを実行し、日付項目のカスタムスカラー用ファイルを作成します。

$ mkdir -p src/graph/scalars && touch src/graph/scalars/datetime.go

 

次に作成したファイル「src/graph/scalars/datetime.go」を以下のように記述します。

・「src/graph/scalars/datetime.go」

package scalars

import (
    "fmt"
    "time"

    "github.com/99designs/gqlgen/graphql"
)

// Goのtime.Time型をGraphQLのモデル用にフォーマット変換
func MarshalDateTime(t time.Time) graphql.Marshaler {
    return graphql.MarshalString(t.Format(time.RFC3339))
}

// Goのtime.Timeがnilの場合(*time.Time)
func MarshalNullableDateTime(t *time.Time) graphql.Marshaler {
    if t == nil {
        return graphql.Null
    }
    return MarshalDateTime(*t)
}

// Goのtime.Time型へのフォーマット変換
func UnmarshalDateTime(v interface{}) (time.Time, error) {
    s, ok := v.(string)
    if !ok {
        return time.Time{}, fmt.Errorf("DateTime must be a string")
    }
    return time.Parse(time.RFC3339, s)
}

 

次にスキーマ定義でカスタムスカラーを使えるようにするため、gqlgenの設定ファイル「src/gqlgen.yml」を以下のように修正します。

・・・

models:
  ID:
    model:
      - github.com/99designs/gqlgen/graphql.ID
      - github.com/99designs/gqlgen/graphql.Int
      - github.com/99designs/gqlgen/graphql.Int64
      - github.com/99designs/gqlgen/graphql.Int32
  # gqlgen provides a default GraphQL UUID convenience wrapper for github.com/google/uuid
  # but you can override this to provide your own GraphQL UUID implementation
  UUID:
    model:
      - github.com/99designs/gqlgen/graphql.UUID

  # The GraphQL spec explicitly states that the Int type is a signed 32-bit
  # integer. Using Go int or int64 to represent it can lead to unexpected
  # behavior, and some GraphQL tools like Apollo Router will fail when
  # communicating numbers that overflow 32-bits.
  #
  # You may choose to use the custom, built-in Int64 scalar to represent 64-bit
  # integers, or ignore the spec and bind Int to graphql.Int / graphql.Int64
  # (the default behavior of gqlgen). This is fine in simple use cases when you
  # do not need to worry about interoperability and only expect small numbers.
  Int:
    model:
      - github.com/99designs/gqlgen/graphql.Int32
  Int64:
    model:
      # graphql.Intは不要なのでコメントアウト
      # - github.com/99designs/gqlgen/graphql.Int
      - github.com/99designs/gqlgen/graphql.Int64
  # 日付項目用のカスタムスカラーを定義
  DateTime:
    model: go-graphql/graph/scalars.DateTime

※modelsのInt64の修正と、DateTimeの追加を行う。

 

次に以下のコマンドを実行し、userスキーマ用のファイルを作成します。

$ touch src/graph/user.graphqls

 

次に作成したファイル「src/graph/user.graphqls」を以下のように記述します。

・「src/graph/user.graphqls」

# カスタムスカラーを利用
scalar Int64
scalar DateTime

# ユーザー
type User {
  id: Int64!
  uid: String!
  last_name: String!
  first_name: String!
  email: String!
  created_at: DateTime!
  updated_at: DateTime!
  deleted_at: DateTime
}

# Queryにフィールドを追加
extend type Query {
  user(uid: String!): User
}

※既にファイル「src/graph/schema.graphqls」にQueryが定義済みのため、extendを使ってQueryにフィールドを追加します。尚、GraphQLにおいてデータ取得系はQueryを使い、それ以外の更新処理系などはMutationを使います。

 

次に以下のコマンドを実行し、userスキーマ等から各種ファイルを更新します。

$ docker compose run --rm graphql gqlgen generate

※リゾルバーファイル「src/graph/user.resolvers.go」が自動生成します。

 

カスタムロガーの追加

次に以下のコマンドを実行し、カスタムロガー用のファイルを作成します。

$ mkdir -p src/internal/logger && touch src/internal/logger/logger.go

 

次に作成したファイル「src/internal/logger/logger.go」を以下のように記述します。

・「src/internal/logger/logger.go」

package logger

import (
    "context"
    "log/slog"
    "os"
    "runtime"
)

// カスタムロガー用のハンドラー設定
type SlogHandler struct {
    slog.Handler
}

func (h *SlogHandler) Handle(ctx context.Context, r slog.Record) error {
    // rをコピー
    newRecord := r.Clone()

    // runtimeからプログラムカウンターを取得して上書き
    pc, _, _, ok := runtime.Caller(4)
    if ok {
        newRecord.PC = pc
    }

    // コンテキストに設定した値からAddAttrsを追加
    requestID, ok := ctx.Value("requestID").(string)
    if ok {
        newRecord.AddAttrs(slog.Attr{Key: "requestID", Value: slog.String("requestID", requestID).Value})
    }

    return h.Handler.Handle(ctx, newRecord)
}

var slogHandler = &SlogHandler{
    slog.NewJSONHandler(os.Stdout, nil),
}

var slogHandlerAddSource = &SlogHandler{
    slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        AddSource: true,
    }),
}

// slogの定義
var logger = slog.New(slogHandler)
var loggerAddSource = slog.New(slogHandlerAddSource)

// カスタムロガー用のインターフェース設定
type Logger interface {
    Info(addSource bool, ctx context.Context, message string)
    Warn(addSource bool, ctx context.Context, message string)
    Error(addSource bool, ctx context.Context, message string)
}

// カスタムロガーの定義
type customLogger struct{}

func NewLogger() Logger {
    return &customLogger{}
}

func (cl *customLogger) Info(addSource bool, ctx context.Context, message string) {
    if addSource {
        loggerAddSource.InfoContext(ctx, message)
    } else {
        logger.InfoContext(ctx, message)
    }
}

func (cl *customLogger) Warn(addSource bool, ctx context.Context, message string) {
    if addSource {
        loggerAddSource.WarnContext(ctx, message)
    } else {
        logger.WarnContext(ctx, message)
    }
}

func (cl *customLogger) Error(addSource bool, ctx context.Context, message string) {
    if addSource {
        loggerAddSource.ErrorContext(ctx, message)
    } else {
        logger.ErrorContext(ctx, message)
    }
}

 

次に以下のコマンドを実行し、テストコードのモック化用ファイルも作成しておきます。

$ docker compose run --rm graphql mockgen -source=./internal/logger/logger.go -destination=./internal/logger/mock_logger/mock_logger.go

 

userドメインモデルの追加

次に以下のコマンドを実行し、userドメインモデル用のファイルを作成します。

$ mkdir -p src/internal/domain/user && touch src/internal/domain/user/user.go

 

次に作成したファイル「src/internal/domain/user/user.go」を以下のように記述します。

・「src/internal/domain/user/user.go」

package user

import (
    "time"
)

type User struct {
    ID        int64      `json:"-"`
    UID       string     `json:"uid"`
    LastName  string     `json:"last_name"`
    FirstName string     `json:"first_name"`
    Email     string     `json:"email"`
    CreatedAt time.Time  `json:"created_at"`
    UpdatedAt time.Time  `json:"updated_at"`
    DeletedAt *time.Time `json:"deleted_at"`
}

 

userクライアントの追加

次に以下のコマンドを実行し、userクライアント用の各種ファイルを作成します。

$ mkdir -p src/internal/client/user
$ touch src/internal/client/user/user.go src/internal/client/user/http_client.go

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/internal/client/user/user.go」

package user

import (
    "context"

    model "go-graphql/internal/domain/user"
)

type UserClient interface {
    FindByUID(ctx context.Context, uid string) (*model.User, error)
}

 

・「src/internal/client/user/http_client.go」

package user

import (
    "context"
    "net/http"
    "time"

    model "go-graphql/internal/domain/user"
)

type userHttpClient struct {
    httpClient *http.Client
    baseURL string
}

func NewUserHttpClient(httpClient *http.Client, baseURL string) UserClient {
    return &userHttpClient{
        httpClient: httpClient,
        baseURL: baseURL,
    }
}

func (c *userHttpClient) FindByUID(_ctx context.Context, uid string) (*model.User, error) {
    // データを返す例(実際にはc.httpClientを使って外部APIを実行する想定)
    var user *model.User

    if uid == "xxxx-xxxx-xxxx-0001" {
        user = &model.User{
            ID: 1,
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Now(),
            UpdatedAt: time.Now(),
            DeletedAt: nil,
        }
    } else {
        // 空のオブジェクトが帰ってきた場合を想定
        user = &model.User{
            ID: 0,
            UID: "",
            LastName: "",
            FirstName: "",
            Email: "",
            CreatedAt: time.Time{},
            UpdatedAt: time.Time{},
            DeletedAt: nil,
        }
    }

    // データ存在チェック
    if user.UID == "" {
        // データが存在しない場合はnilを返す
        return nil, nil
    }

    return user, nil
}

※今回は固定値を返すような例としていますが、実際には外部APIを実行してデータ取得するような想定です。

 

次に以下のコマンドを実行し、テストコードのモック化用ファイルも作成しておきます。

$ docker compose run --rm graphql mockgen -source=./internal/client/user/user.go -destination=./internal/client/user/mock_user/mock_user.go

 

共通のリゾルバーにカスタムロガーとクライアントのインスタンスを追加する

次に共通のリゾルバーファイル「src/graph/resolver.go」で、上記で作成したカスタムロガーとクライアントのインスタンスを渡せるように修正します。

・「src/graph/resolver.go」

package graph

// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.

import (
    "go-graphql/internal/client/user"
    "go-graphql/internal/logger"
)

type Resolver struct {
    Logger logger.Logger
    UserClient user.UserClient
}

 

userリゾルバーファイルの修正

次に事前に自動生成したリゾルバーファイル「src/graph/user.resolvers.go」を以下のように修正します。

・「src/graph/user.resolvers.go」

package graph

// 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.83

import (
    "context"
    "errors"
    "fmt"
    "go-graphql/graph/model"

    "github.com/99designs/gqlgen/graphql"
)

// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, uid string) (*model.User, error) {
    // コンテキストからクエリのフィールド情報取得
    field := graphql.GetFieldContext(ctx)
    fieldInfo := fmt.Sprintf("[Object=%s, FieldName=%s, Alias=%s]", field.Object, field.Field.Name, field.Field.Alias)

    // リゾルバーの開始ログ出力
    msg := fmt.Sprintf("start resolver User %s", fieldInfo)
    r.Logger.Info(false, ctx, msg)

    // バリデーションチェック
    if len(uid) > 20 {
        msg := fmt.Sprintf("uid is too long %s", fieldInfo)
        r.Logger.Warn(true, ctx, msg)
        return nil, errors.New(msg)
    }

    // 外部APIを実行してデータ取得
    res, err := r.UserClient.FindByUID(ctx, uid)
    if err != nil {
        return nil, err
    }

    // 対象データが存在しない場合はnilを返す
    if res == nil {
        return nil, nil
    }

    // 外部APIのレスポンス結果をGraphQLのモデルにマッピング
    gqlUser := &model.User{
        ID: res.ID,
        UID: res.UID,
        LastName: res.LastName,
        FirstName: res.FirstName,
        Email: res.Email,
        CreatedAt: res.CreatedAt,
        UpdatedAt: res.UpdatedAt,
        DeletedAt: res.DeletedAt,
    }

    return gqlUser, nil
}

※このファイルは自動生成前提のため、作成済みの関数外にグローバル定義を書いても更新次に消えちゃうので注意して下さい。関数内に全て記述するか、別のファイルにまとめておいて呼び出して使うなどが必要です。また、GraphQLの場合は一度のリクエストで複数のリゾルバーを実行できるため、ログ出力の仕方は工夫が必要になります。(リクエスト単位のログとリゾルバー単位のログをちゃんと出して判別できるようにする。)

 

ミドルウェア用のファイルを作成

次に以下のコマンドを実行し、ミドルウェア用の各種ファイルを作成します。

$ mkdir -p src/internal/middleware && touch src/internal/middleware/middleware.go
$ mkdir -p src/graph/middleware && touch src/graph/middleware/middleware.go

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/internal/middleware/middleware.go」

package middleware

import (
    "context"
    "net/http"

    "github.com/google/uuid"
)

// リクエスト単位用のミドルウェア
func Request(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // リクエスト単位で一意のIDを設定
        uuid := uuid.New().String()

        // コンテキストにrequestIDを追加
        ctx := context.WithValue(r.Context(), "requestID", uuid)

        // 処理実行
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

※リクエスト時はHTTP用のミドルウェアが先に実行されます。

 

・「src/graph/middleware/middleware.go」

package middleware

import (
    "context"
    "fmt"

    "go-graphql/internal/logger"

    "github.com/99designs/gqlgen/graphql"
)

// リクエスト単位のログ出力用のAroundResponses
func LoggingAroundResponses(logger logger.Logger) func(ctx context.Context, next graphql.ResponseHandler) *graphql.Response {
    return func(ctx context.Context, next graphql.ResponseHandler) *graphql.Response {
        // リクエストのクエリ関連情報を取得
        opCtx := graphql.GetOperationContext(ctx)

        // リクエストの開始ログ
        msg := fmt.Sprintf("go-graphql request start [Operation=%s, Query=%s, Variables=%s]", opCtx.OperationName, opCtx.RawQuery, opCtx.Variables)
        logger.Info(false, ctx, msg)

        resp := next(ctx)

        // リクエストの完了ログ
        msg = "go-graphql request finished"
        logger.Info(false, ctx, msg)

        return resp
    }
}

※このファイルはGraphQLのミドルウェア用ファイルです。

 

環境変数用のファイルを作成

次に以下のコマンドを実行し、環境変数用のファイル「.env」を作成します。

$ touch src/.env

 

次に作成したファイル「src/.env」を以下のように記述します。

・「src/.env」

ENV=local
PORT=8080
ALLOWED_ORIGIN_URL="http://localhost:3000"
USER_BASE_URL="http://localhost:8081"

※ALLOWED_ORIGIN_URLとUSER_BASE_URLの値は例として設定しています。

 

mainファイル(src/server.go)の修正

次にmainファイル「src/server.go」を以下のように修正します。

・「src/server.go」

package main

import (
    "fmt"
    "log/slog"
    "net/http"
    "os"

    "go-graphql/graph"
    graphMW "go-graphql/graph/middleware"
    "go-graphql/internal/logger"
    httpMW "go-graphql/internal/middleware"
    // クライアント層
    clientUser "go-graphql/internal/client/user"

    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/99designs/gqlgen/graphql/handler/extension"
    "github.com/99designs/gqlgen/graphql/handler/lru"
    "github.com/99designs/gqlgen/graphql/handler/transport"
    "github.com/99designs/gqlgen/graphql/playground"
    "github.com/joho/godotenv"
    "github.com/rs/cors"
    "github.com/vektah/gqlparser/v2/ast"
)

const defaultPort = "8080"

func main() {
    /**********************
    * .env ファイルの読み込み
    ***********************/
    err := godotenv.Load()
    if err != nil {
        slog.Error(".envファイルの読み込みに失敗しました。")
    }

    // ENV
    env := os.Getenv("ENV")

    // ポート番号
    port := os.Getenv("PORT")
    if port == "" {
        port = defaultPort
    }

    /***************
    * ロガー設定
    ****************/
    logger := logger.NewLogger()

    /****************************
    * クライアント層のインスタンス化
    *****************************/
    // httpClientの設定
    httpClient := &http.Client{}

    // userクライアントの設定
    userBaseURL := os.Getenv("USER_BASE_URL")
    userClient := clientUser.NewUserHttpClient(httpClient, userBaseURL)

    /****************
    * GraphQL用の設定
    *****************/
    // ハンドラー設定
    srv := handler.New(graph.NewExecutableSchema(graph.Config{
        Resolvers: &graph.Resolver{
            // インスタンス設定の追加
            Logger: logger,
            UserClient: userClient,
        },
    }))

    // ミドルウェア設定
    srv.AroundResponses(graphMW.LoggingAroundResponses(logger))

    // その他
    srv.AddTransport(transport.Options{})
    srv.AddTransport(transport.GET{})
    srv.AddTransport(transport.POST{})

    srv.SetQueryCache(lru.New[*ast.QueryDocument](1000))

    srv.Use(extension.Introspection{})
    srv.Use(extension.AutomaticPersistedQuery{
        Cache: lru.New[string](100),
    })

    /***************
    * CORS設定
    ****************/
    allowedOriginURL := os.Getenv("ALLOWED_ORIGIN_URL")
    c := cors.New(cors.Options{
        AllowedOrigins: []string{allowedOriginURL},
        AllowCredentials: true,
        AllowedMethods: []string{"GET", "POST", "OPTIONS"},
        AllowedHeaders: []string{"Authorization", "Content-Type"},
    })

    /*********************
    * http用のハンドラー設定
    **********************/
    // 本番環境以外の場合はPlaygroundを設定
    if env != "production" {
        http.Handle("/", playground.Handler("GraphQL playground", "/query"))
    }
    finalHandler := c.Handler(httpMW.Request(srv))
    http.Handle("/query", finalHandler)

    /*********************
    * サーバー起動
    **********************/
    slog.Info(fmt.Sprintf("[ENV=%s] Start go-graphql Server Port: %s", env, port))
    if err := http.ListenAndServe(fmt.Sprintf(":%s", port), nil); err != nil {
        slog.Error("server stopped", "err", err)
        os.Exit(1)
    }
}

 

次に以下のコマンドを実行し、フォーマット修正および静的コード解析を行い、警告が出ないことを確認します。

$ docker compose run --rm graphql go mod tidy
$ docker compose run --rm graphql go fmt ./...
$ docker compose run --rm graphql sh -c "staticcheck ./graph/... 2>&1 | grep -v 'ec.Errorf is deprecated'"

※staticcheckによる静的コード解析では自動生成されるファイル「src/graph/generated.go」も対象になってしまって警告が出るため、それを除外するようなコマンドにしています。

 

コンテナの再ビルドと起動

次に以下のコマンドを実行し、コンテナを再びルドします。

$ docker compose down
$ docker compose build --no-cache

 

次に以下のコマンドを実行し、コンテナを起動します。

$ docker compose up -d

 

次に以下のコマンドを実行し、ログ出力を確認します。

$ docker compose logs

 

ログ出力を確認し、エラーがなければOKです。

 

スポンサーリンク

userスキーマのGraphQL APIを試す

次に上記で作成したuserスキーマのGraphQL APIをPostmanを使って試します。

まずはGraphQL用のリクエストを追加し、URLに「http://localhost:8080/query」を入力し、スキーマからuserにチェックを付けます。

 

次にuidに「”xxxx-xxxx-xxxx-0001″」を入力後、「Query」ボタンをクリックして実行し、想定通りのデータが取得できればOKです。

 

次にuidに「”xxxx-xxxx-xxxx-0002″」を入力後、「Query」ボタンをクリックして実行し、データが取得できずにnullが返ってこればOKです。

 

次にuidに「”xxxx-xxxx-xxxx-0001-x”」を入力後、「Query」ボタンをクリックして実行し、uidのバリデーションチェックでエラーになってerrorsが返ってこればOKです。

※尚、GraphQLでは予期せぬエラーが発生しない限りはステータスコード200が返り、バリデーションエラーなどのエラーかどうかについてはレスポンス結果から判定する必要があります。(例えばuserがnullじゃないかや、errors項目に値が存在するかなどをチェックして判定する。)

 

テストコードを追加して試す

次にテストコードを追加して試すため、以下のコマンドを実行し、テストコード用のファイルを追加します。

$ touch src/graph/user.resolvers_test.go

 

次に作成したファイル「src/graph/user.resolvers_test.go」を以下のように記述します。

・「src/graph/user.resolvers_test.go」

package graph_test

import (
    "testing"
    "time"

    "go-graphql/graph"
    mockUserClient "go-graphql/internal/client/user/mock_user"
    domainUser "go-graphql/internal/domain/user"
    mockLogger "go-graphql/internal/logger/mock_logger"
    httpMW "go-graphql/internal/middleware"

    "github.com/99designs/gqlgen/client"
    "github.com/99designs/gqlgen/graphql/handler"
    "github.com/stretchr/testify/assert"
    "github.com/vektah/gqlparser/v2/gqlerror"
    "go.uber.org/mock/gomock"
)

func TestResolversUser(t *testing.T) {
    /*********
    * モック化
    **********/
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    // ロガーのモック化
    mockLogger := mockLogger.NewMockLogger(ctrl)

    // クライアント層のモック化
    mockUserClient := mockUserClient.NewMockUserClient(ctrl)

    /**************************
    * gqlgenのテスト用クライアント
    ***************************/
    srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
        Resolvers: &graph.Resolver{
            Logger: mockLogger,
            UserClient: mockUserClient,
        },
    }))
    handler := httpMW.Request(srv)
    c := client.New(handler)

    t.Run("正常終了すること", func(t *testing.T) {
        // ロガーのモック設定
        mockLogger.EXPECT().Info(gomock.Any(), gomock.Any(), gomock.Any()).Return()

        // ユーザークライアントのモック設定
        expectedUser := &domainUser.User{
            ID: int64(1),
            UID: "xxxx-xxxx-xxxx-0001",
            LastName: "田中",
            FirstName: "太郎",
            Email: "t.tanaka@example.com",
            CreatedAt: time.Now(),
            UpdatedAt: time.Now(),
            DeletedAt: nil,
        }
        mockUserClient.EXPECT().FindByUID(gomock.Any(), gomock.Any()).Return(expectedUser, nil)

        // レスポンス結果用の変数定義
        var res struct {
            User struct {
                ID int64 `json:"id"`
                UID string `json:"uid"`
                LastName string `json:"last_name"`
                FirstName string `json:"first_name"`
                Email string `json:"email"`
                CreatedAt string `json:"created_at"`
                UpdatedAt string `json:"updated_at"`
                DeletedAt *string `json:"deleted_at"`
            } `json:"user"`
        }

        // クエリの設定
        query := `
            query {
                user(uid: "xxxx-xxxx-xxxx-0001") {
                    id
                    uid
                    last_name
                    first_name
                    email
                    created_at
                    updated_at
                    deleted_at
                }
            }
        `

        // テストの実行
        err := c.Post(query, &res)

        // 検証
        assert.NoError(t, err)
        assert.Equal(t, int64(1), res.User.ID)
        assert.Equal(t, "xxxx-xxxx-xxxx-0001", res.User.UID)
        assert.Equal(t, "田中", res.User.LastName)
        assert.Equal(t, "太郎", res.User.FirstName)
        assert.Equal(t, "t.tanaka@example.com", res.User.Email)
        assert.NotNil(t, res.User.CreatedAt)
        assert.NotNil(t, res.User.UpdatedAt)
        assert.Nil(t, res.User.DeletedAt)
    })

    t.Run("uidのバリデーションエラー", func(t *testing.T) {
        // ロガーのモック設定
        mockLogger.EXPECT().Info(gomock.Any(), gomock.Any(), gomock.Any()).Return()
        mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any(), gomock.Any()).Return()

        // レスポンス結果用の変数定義
        var res struct {
            User *struct {
                ID int64 `json:"id"`
                UID string `json:"uid"`
                LastName string `json:"last_name"`
                FirstName string `json:"first_name"`
                Email string `json:"email"`
                CreatedAt string `json:"created_at"`
                UpdatedAt string `json:"updated_at"`
                DeletedAt *string `json:"deleted_at"`
            } `json:"user"`
        }

        // クエリの設定
        query := `
            query {
                user(uid: "xxxx-xxxx-xxxx-0001-x") {
                    id
                    uid
                    last_name
                    first_name
                    email
                    created_at
                    updated_at
                    deleted_at
                }
            }
        `

        // テストの実行
        err := c.Post(query, &res)

        // 検証
        assert.Error(t, err)
        assert.Nil(t, res.User)
        assert.Contains(t, err.Error(), "uid is too long")

        if errList, ok := err.(gqlerror.List); ok {
            assert.Equal(t, 1, len(errList))
            assert.Equal(t, "uid is too long [Object=Query, FieldName=user, Alias=user]", errList[0].Message)
            assert.Equal(t, "user", errList[0].Path)
        }
    })
}

※テスト実行にはgqlgenのclientを使う。

 

次に以下のコマンドを実行し、go.modを更新します。

$ docker compose exec graphql go mod tidy

 

次に以下のコマンドを実行し、テストを実行します。

$ docker compose exec graphql go test -v $(docker compose exec graphql go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...)

※対象のファイルだけ実行させるようなコマンドにしています。

 

テスト実行後、以下のように全てのテストがPASSすればOKです。

 

スポンサーリンク

本番環境用のDockerコンテナを作る

次に本番環境にデプロイすることを想定し、一つのDockerコンテナ単体で起動できるようにします。

まずは以下のコマンドを実行し、各種ファイルを作成します。

$ mkdir -p deploy/docker/prod && touch deploy/docker/prod/Dockerfile
$ touch src/.env.production

 

次に作成したファイルをそれぞれ以下のように記述します。

・「src/.env.production」

ENV=production
PORT=8080
ALLOWED_ORIGIN_URL="http://localhost:3000"
USER_BASE_URL="http://localhost:8081"

※本番環境用としてこの環境変数ファイル「.env.production」を使いますが、このファイルには機密情報を含めないようにご注意下さい。

 

・「deploy/docker/prod/Dockerfile」

####################
# ビルドステージ
####################
FROM golang:1.25.4-alpine3.21 AS builder

WORKDIR /go/src

COPY ./src .

# 依存関係をインストール
RUN go install

# ビルド(GOOS:OS指定、GOARCH:CPUアーキテクチャ指定)
RUN GOOS=linux GOARCH=amd64 go build -o main .

####################
# 実行ステージ
####################
FROM alpine:3.21 AS runner

# タイムゾーンを設定
ENV TZ=Asia/Tokyo

# インストール可能なパッケージ一覧の更新
RUN apk update && \
    apk upgrade && \
    # パッケージのインストール(--no-cacheでキャッシュ削除)
    apk add --no-cache \
            tzdata

WORKDIR /app

# コンテナ用ユーザー作成
RUN addgroup --system --gid 1001 appuser && \
    adduser --system --uid 1001 appuser

# ビルドステージで作成したバイナリをコピー
COPY --from=builder --chown=appuser:appuser ./go/src/main .
COPY --from=builder --chown=appuser:appuser ./go/src/.env.production ./.env

# ポートを設定
EXPOSE 8080

# コンテナ起動ユーザー設定
USER appuser

# APIサーバー起動コマンド
CMD ["./main"]

※例えばGoogle CloudのCloud Runなどにデプロイする際は、Goのビルド時にOSやCPUアーキテクチャの指定が必要になります。

 

次に以下のコマンドを実行し、Dockerコンテナのビルドおよび起動します。

$ docker compose down
$ docker build --no-cache -f ./deploy/docker/prod/Dockerfile -t go-graphql:v1.0.0 .
$ docker run -d -p 80:8080 go-graphql:v1.0.0 

 

次にDocker Desktopを確認し、Dockerコンテナが起動していればOKです。

 

次にDockerコンテナのログも確認し、想定通り出力されていればOKです。

 

次にPostmanを使ってテストをしてみますが、URLに「http://localhost/query」を入力し、スキーマからuserにチェックを付け、uidに「”xxxx-xxxx-xxxx-0001″」を入力後、「Query」ボタンをクリックして実行し、想定通りのデータが取得できればOKです。

 

GraphQLのAPI仕様書について

GraphQLについては、REST APIのOpenAPIやgRPCのドキュメントなどのような別のAPI仕様書は存在せず、スキーマファイル(例:src/graph/user.graphqls)がAPIの仕様書になってます。

そのため、GraphQLでAPI仕様書を確認したい場合は、各種スキーマファイルを直接確認して下さい。

 

スポンサーリンク

最後に

今回はGo言語(Golang)でGraphQLのBFFを開発する方法について解説しました。

Goの場合はライブラリ「gqlgen」を使うことで比較的簡単にGraphQLサーバーを作れたので、その点は非常に良かったです。

BFFを導入する際に、パフォーマンスを考慮してGo言語のGraphQLを使いたい方は、ぜひ参考にしてみて下さい。

 

コメント

タイトルとURLをコピーしました