GraphQL + gRPC + Go で作るバックエンド環境

この記事は個人ブログと同じ内容です

www.ritolab.com


GraphQL サーバと gRPC サーバを Go で実装し、それらを連携させた簡単なローカル環境を構築しました。この記事では、その手順と体験をシェアしていきます。

全体アーキテクチャの概要

全体のアーキテクチャは以下のような構成になります。

  1. フロントエンド(クライアント)からのリクエストは、GraphQL サーバのエンドポイントに送信されます。
  2. GraphQL サーバは受け取ったクエリを解析し、必要なデータを取得するために内部で gRPC クライアントとして動作します。
  3. gRPC サーバは、GraphQL サーバからのリクエストを受け取り、実際のビジネスロジックを実行してレスポンスを返します。
  4. GraphQL サーバは、gRPC サーバから受け取ったレスポンスを、GraphQL のスキーマに従った形式に変換してクライアントに返します。

この構成を採用する利点としては以下が挙げられます。

  • フロントエンドは柔軟なデータ取得が可能なGraphQLを利用できる
  • バックエンド間の通信には、型安全で高速な gRPC を使用できる
  • Go の強力な型システムとコード生成ツールにより、安全で保守性の高いコードを実現できる

この構成を図にすると以下のイメージです。

+------------------------+
|                        |
|  Frontend (Client)     |
|                        |
+------------------------+
           |
        GraphQL
     (HTTP/HTTP2)
           |
           v
+------------------------+
|                        |
|   GraphQL Server (Go)  |
|    - Schema           |
|    - Resolver         |
|    [gRPC Client]      |
|                        |
+------------------------+
           |
         gRPC
       (HTTP/2)
           |
           v
+------------------------+
|                        |
|    gRPC Server (Go)    |
|    - Proto            |
|    - Service          |
|    - Business Logic   |
|                        |
+------------------------+
           |
           v
+------------------------+
|                        |
|      Database          |
|                        |
+------------------------+
Frontend (Client)     GraphQL Server (Go)     gRPC Server (Go)
     |                      |                      |
     |                      |                      |
     |  GraphQL Request     |                      |
     |--------------------->|                      |
     |                      |                      |
     |                      |    gRPC Request      |
     |                      |--------------------->|
     |                      |                      |
     |                      |    gRPC Response     |
     |                      |<---------------------|
     |                      |                      |
     |  GraphQL Response    |                      |
     |<---------------------|                      |
     |                      |                      |

なお、まずは基本的な機能実装に焦点を当てるため、ディレクトリ構成やファイル分割、ネーミングなどは簡易的です。

また、実験的な環境であり、本番用には適切なエラーハンドリングやセキュリティ対策の追加が必要です。

開発環境

端末は Mac(M2)で、Go がインストール済みの状態です。

%go version
go version: go1.23.2 darwin/arm64

プロジェクトの作成

まずは、開発を進めていくプロジェクトを作成します。新しいディレクトリを作成し、以下のコマンドで Go モジュールとして初期化します。

mkdir <新しいディレクトリ>

cd <新しいディレクトリ>

go mod init <プロジェクト名>

GraphQL サーバ構築

インストールとセットアップ

GraphQL サーバ構築には 99designs/gqlgen を利用します。このパッケージは、GraphQL スキーマから Go のコードを自動生成してくれる便利なツールです。

まず、必要なパッケージをインストールします。

go get -u github.com/99designs/gqlgen

次に、gqlgen の初期設定を行います。プロジェクトのルートディレクトリで以下コマンドを実行します。

go run github.com/99designs/gqlgen init

ルート配下に graph ディレクトリが作成され、配下に以下のファイルが作成されます。

graph
├── model
│   └── models_gen.go
├── generated.go
├── resolver.go
├── schema.graphqls
└── schema.resolver.go
gqlgen.yml
  • gqlgen.yml: gqlgen の設定ファイル
  • graph/schema.graphqls: GraphQLスキーマ定義ファイル
  • graph/generated/generated.go: 自動生成されたコード
  • graph/model/models_gen.go: スキーマから生成されたモデル
  • graph/resolver.go: リゾルバーの実装ファイル
  • server.go: GraphQLサーバのエントリーポイント

デフォルトの設定が既に生成されているため、この時点で GraphQL サーバを起動できます。以下のコマンドを実行して GraphQL サーバを起動してみます。

go run server.go

http://localhost:8080/ にアクセスすると、GraphQL Playground が表示されたことが確認できました。

GraphQL Playground は、GraphQLクエリのテストや、スキーマの確認ができる Web UI ツールです。クエリの入力、実行、レスポンスの確認が簡単にでき、開発時に便利です。また、スキーマのドキュメントも自動生成されるため、API の仕様確認にも活用できます。

スキーマの定義

次に、スキーマを定義します。スキーマは、GraphQL の API で利用可能な型、クエリ、ミューテーションを定義するものです。graph/schema.graphqls ファイルを編集します。

今回は例として、よくあるブログシステムを想定し、User(ユーザー)と、そこにひも付く Post(記事)で定義します。また、ユーザーの一覧を取得するリゾルバ(GraphQL のクエリを受け取って実際のデータを返す関数)users を定義します。

type Query {
  users: [User!]!
}

type User {
  id: ID!
  name: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
}

スキーマの定義ができたら、gqlgen というツールを使用しこのスキーマに基づいて Go のコードを自動生成します。この自動生成では、スキーマで定義した型の構造体や、GraphQL のクエリを処理するためのリゾルバの雛形が作成されます。手作業でこれらのコードを書く必要がなく、開発効率を高めることができます。

以下のようなコードが自動的に生成されます。

  • スキーマで定義した型(UserやPost)の Go 構造体
  • GraphQL のクエリを適切な Go 関数呼び出しに変換するためのコード
  • ゾルバ関数の雛形

コード生成を行う前に、まず gqlgen ツールの設定として tools.go を作成し、以下を記述しておきます。

//go:build tools
// +build tools

package tools

import (
  _ "github.com/99designs/gqlgen"
  _ "github.com/99designs/gqlgen/graphql/introspection"
)

この tools.go は、プロジェクトで使用するツールの依存関係を管理するためのものです。後ほど go mod tidy を実行する際に、これらの開発ツールの依存関係が go.mod ファイルに適切に含まれるようになります。

ここまでできたら、以下のコマンドを実行してコードを自動生成します。

go run github.com/99designs/gqlgen generate

graph/model/models_gen.go が生成されます。ファイルを見てみると、先程定義したスキーマが出力されていることが確認できます。

type Post struct {
  ID    string `json:"id"`
  Title string `json:"title"`
}

type Query struct {
}

type User struct {
  ID    string  `json:"id"`
  Name  string  `json:"name"`
  Posts []*Post `json:"posts"`
}

また、graph/schema.resolvers.go にリゾルバの雛形が生成されます。

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
  panic(fmt.Errorf("not implemented: Users - users"))
}

これに仮の実装を行い、結果を返すようにしてみましょう。

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
  return []*model.User{
    {
      ID:   "1",
      Name: "たろう",
      Posts: []*model.Post{
        {ID: "101", Title: "サンタさんは本当にいるかもしれない"},
      },
    },
    {
      ID:   "2",
      Name: "はなこ",
      Posts: []*model.Post{
        {ID: "102", Title: "近所に KFC があってよかった"},
      },
    },
  }, nil
}

ここまでできたら、GraphQL Playground に以下を入力してリクエストを送信してみます。

query {
  users {
    id
    name
    posts {
      id
      title
    }
  }
}

レスポンスが返ってきたことが確認できました。スキーマ定義通りにユーザー情報と、各ユーザーの投稿が取得できていることが分かります。GraphQL サーバの構築はここまでで完了です。

gRPC サーバの構築

次に、gRPC サーバを構築していきます。

インストールとセットアップ

Protocol Buffers Compiler と、gRPC と Protocol Buffers 用の Go プラグインをインストールします。

Protocol Buffers Compiler インストール

Protocol Buffers Compiler のインストールは OS によって変わりますので下記の公式ドキュメントを参考にしてください。

Protocol Buffers Compiler インストール

Mac は homebrew でいけます。

brew install protobuf

gRPC と Protocol Buffers 用の Go プラグインをインストール

gRPC と Protocol Buffers 用の Go プラグインをインストールします。

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

以下をプロジェクトにインストールします。

go get -u google.golang.org/grpc
go get -u google.golang.org/protobuf

proto ファイル作成

proto ファイルは、Protocol Buffers(プロトコルバッファ)という Google が開発したデータ記述言語で使用されるスキーマ定義ファイルです。このファイルをもとに、さまざまなプログラミング言語でデータのシリアライズとデシリアライズを効率的に行うコードを自動生成できます。

また、gRPC では proto ファイルを使用して、RPC(リモートプロシージャコール)サービスやメッセージ構造を定義します。

proto/user/service.proto を作成して、以下を定義していきます。

  • package: プロトコルバッファ定義の名前空間
  • option go_package: Go コード生成時のパッケージ名
  • service: gRPC サービスの定義
  • message: リクエストやレスポンスのデータ構造
syntax = "proto3";

package user;

option go_package = "proto/user";

// Query サービスの定義
service QueryService {
  rpc Users (UsersInput) returns (UsersResponse);
}

// ユーザー情報の定義
message User {
  string id = 1;
  string name = 2;
  repeated Post posts = 3;
}

// 投稿情報の定義
message Post {
  string id = 1;
  string title = 2;
}

// Users メソッドのリクエスト(今回は何も指定しない)
message UsersInput {}

// Users メソッドのレスポンス
message UsersResponse {
  repeated User users = 1;
}

コード生成

以下の protoc コマンドで .proto ファイルから Go コードを生成します。

protoc --go_out=<出力ディレクトリ> --go-grpc_out=<出力ディレクトリ> <protoファイルのパス>

今回の例だとコマンドは以下になります。

protoc --proto_path=proto --go_out=. --go-grpc_out=. proto/**/*.proto
  1. --proto_path=proto:
    • .proto ファイルを検索する基準ディレクトリを指定。
    • 今回は proto/ 配下を基準にして .proto ファイルを検索します。
    • これにより、proto/ 配下でインポートされる他の .proto ファイルも自動的に解決されます。
  2. --go_out=.:
  3. --go-grpc_out=.:
    • gRPC 用のコード (_grpc.pb.go ファイル) を生成する出力先ディレクトリ。
    • これも今回は . で現在のディレクトリを指定。
  4. proto/**/*.proto:
  5. コンパイル対象の .proto ファイルのパス。
    • ワイルドカード (**/*.proto) を使用して proto/ 配下のすべての .proto ファイルを対象とします。

protoc コマンドを実行すると、proto ディレクトリに service.pd.goservice_grpc.pd.go ファイルが生成されます。

proto/
└── user/
   ├── service.pd.go
   ├── service.proto
   └── service_grpc.pd.go

gRPC メソッドの実装

次に、ユーザー一覧を返却する gRPC メソッド Users を実装します。

grpcserver/server.go を作成し、Users を実装します。次工程で gRPC サーバを起動するため、StartGRPCServer 関数も一緒に定義しています。

package grpcserver

import (
  "context"
  "graphql-dataloader-demo/proto/user"
  "log"
  "net"

  "google.golang.org/grpc"
)

// gRPC サーバの実装
type userServiceServer struct {
  user.UnimplementedQueryServiceServer
}

// Users ユーザーの一覧を返却します
func (s *userServiceServer) Users(ctx context.Context, req *user.UsersInput) (*user.UsersResponse, error) {
  // 仮のユーザーリストを返す
  users := []*user.User{
    {Id: "1", Name: "ミギ"},
    {Id: "2", Name: "ダリ"},
  }

  return &user.UsersResponse{
    Users: users,
  }, nil
}

// StartGRPCServer gRPC サーバを起動します
func StartGRPCServer(port string) error {
  listener, err := net.Listen("tcp", ":"+port)
  if err != nil {
    return err
  }

  grpcServer := grpc.NewServer()
  user.RegisterQueryServiceServer(grpcServer, &userServiceServer{})

  log.Printf("gRPC server is running on port %s", port)
  return grpcServer.Serve(listener)
}

gRPC サーバの起動

ここで一度、gRPC サーバを起動してみます。

server.go に gRPC サーバを起動させるための記述を行っていきます。これまでは GraphQL サーバの起動のみ定義していましたが、ここに gRPC サーバの起動も追記していきます。

package main

import (
  "graphql-dataloader-demo/graph"
  "graphql-dataloader-demo/grpcserver"
  "log"
  "net/http"
  "os"

  "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/vektah/gqlparser/v2/ast"
)

const (
  defaultPort     = "8080"
  defaultGRPCPort = "50051" // 追加
)

func main() {
  // HTTP サーバのポート設定
  port := os.Getenv("PORT")
  if port == "" {
    port = defaultPort
  }

  // [追加] gRPC サーバのポート設定
  grpcPort := os.Getenv("GRPC_PORT")
  if grpcPort == "" {
    grpcPort = defaultGRPCPort
  }

  // [追加] GraphQL リゾルバを gRPC クライアントとともに初期化
  grpcAddr := "localhost:" + grpcPort
  resolver := graph.NewResolver(grpcAddr)

  // GraphQL サーバの設定
  srv := handler.New(graph.NewExecutableSchema(graph.Config{Resolvers: resolver}))
  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),
  })

  http.Handle("/", playground.Handler("GraphQL playground", "/query"))
  http.Handle("/query", srv)

  // [追加] gRPC サーバを起動
  go func() {
    log.Printf("Starting gRPC server on port %s", grpcPort)
    if err := grpcserver.StartGRPCServer(grpcPort); err != nil {
      log.Fatalf("failed to start gRPC server: %v", err)
    }
  }()

  log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
  log.Fatal(http.ListenAndServe(":"+port, nil))
}

以下のコマンドを実行して GraphQL サーバに加えて gRPC サーバを起動します。

go run server.go
% go run server.go
connect to http://localhost:8080/ for GraphQL playground
Starting gRPC server on port 50051
gRPC server is running on port 50051

gRPC サーバも無事に起動しました。

gRPC クライアントのセットアップ

最後に、GraphQL リゾルバで gRPC メソッドを使えるようにするために、gRPC クライアントのセットアップを行っていきます。

gRPC クライアントの追加

graph/resolver.go にて、リゾルバに gRPC クライアントを追加します。

package graph

import (
  "google.golang.org/grpc"
  "google.golang.org/grpc/credentials/insecure"
  "graphql-dataloader-demo/proto/user"
  "log"
)

type Resolver struct {
  GRPCClient user.QueryServiceClient // gRPC クライアントを格納するフィールド
}

// NewResolver は gRPC クライアントを初期化して Resolver を返します
func NewResolver(grpcAddr string) *Resolver {
  // gRPC の接続設定
  // 非セキュア接続(plaintext)を使用しています。
  // 本番環境では適切な証明書を使用してセキュア接続に切り替えてください。
  opts := []grpc.DialOption{
    grpc.WithTransportCredentials(insecure.NewCredentials()), // 非セキュア接続(開発用)
  }

  // gRPC サーバに接続
  conn, err := grpc.NewClient(grpcAddr, opts...)
  if err != nil {
    log.Fatalf("gRPC サーバへの接続に失敗しました: %v", err)
  }

  // Resolver に gRPC クライアントを設定して返却
  return &Resolver{
    GRPCClient: user.NewQueryServiceClient(conn),
  }
}

GraphQL リゾルバで gRPC を呼び出す

最後に、GraphQL の Usersゾルバで gRPC メソッドを呼び出します。

graph/schema.resolvers.go

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {

  // gRPC メソッドへリクエスト
  res, err := r.Resolver.GRPCClient.Users(ctx, &user.UsersInput{})
  if err != nil {
    return nil, err
  }

  // gRPC の User を GraphQL の User に変換
  users := make([]*model.User, len(res.Users))
  for i, u := range res.Users {
    users[i] = &model.User{
      ID:   u.Id,
      Name: u.Name,
    }
  }

  return users, nil
}

go run server.go でサーバを起動後、GraphQL Playground に以下を入力してリクエストを送信します。

query {
  users {
    id
    name
  }
}

すると、gRPC サーバからのレスポンスを受け取って返したことが確認できました。

{
  "data": {
    "users": [
      {
        "id": "1",
        "name": "ミギ"
      },
      {
        "id": "2",
        "name": "ダリ"
      }
    ]
  }
}

ここまでで、GraphQL サーバと gRPC サーバの連携は完了です。

まとめ

本記事では、GraphQL サーバと gRPC サーバを連携させたローカル開発環境を構築しました。

  1. GraphQLサーバ(99designs/gqlgen)
    • スキーマファイルの作成とコード生成
    • ゾルバの実装
    • GraphQL Playground の活用
  2. gRPCサーバ
    • Protocol Buffers によるスキーマ定義
    • サービス実装とサーバの起動
    • 型安全なコード生成の活用
  3. GraphQLサーバとgRPCサーバの連携
    • gRPC クライアントの実装
    • GraphQL リゾルバからの gRPC 呼び出し
    • データ変換処理の実装

実験的な構成ではありますが、GraphQL と gRPC を組み合わせることで、フロントエンドに柔軟な GraphQL インターフェースを提供しながらも、バックエンド間では効率的な gRPC 通信を実現し、Go の強力な型システムで開発するというそれぞれの強みを活かしたシステムが構築できる可能性を感じました。

特に、コードの自動生成は大きな強みです。品質が一定に保たれるだけでなく、実装コストの削減にもつながります。

これからこれらのツールを色々試していこうと思います。