LoginSignup
7
6

More than 1 year has passed since last update.

GraphQL と gRPC で通信する Go 製のマイクロサービスをセットアップする

Last updated at Posted at 2023-01-07

背景

年末年始に Go を勉強したので、 GraphQL と gRPC のサンプル実装を作ってみた。

作りたいもの

  • フロントエンドから gateway 間を GraphQL で通信する
  • gateway とバックエンドサービス間を gRPC で通信する

前提知識

GraphQL

  • 独自のクエリ言語を用いて、フロントエンドで必要なリソース・プロパティだけを柔軟に取得できる
  • スキーマ定義をフロントエンドとバックエンドで共有できるので変更にも強い
  • https://graphql.org

gRPC

  • 独自のプロトコルを用いてバックエンドサービス間の高速な通信を実現できる
  • 同じランタイム内の関数を呼ぶ感覚でバックエンドの処理を呼ぶことができ、様々な言語・フレームワークで実装されたサービスをシームレスに接続できる
  • スキーマ定義をクライアントとバックエンドで共有できるので変更にも強い
  • https://grpc.io

環境

  • Go 1.19.3

GraphQL 開発環境の構築

下記のようなディレクトリ構成で進める。

golang-web-server-sample/
├── docker-compose.yml
├── graphql-gateway/ # フロントエンドから直接リクエストを受け取る GraphQL 製サービス
│   └── Dockerfile
├── grpc-user-service/ # gRPC 製バックエンドサービス
│   └── Dockerfile
└── proto/ # gRPC 用のスキーマ定義(protocol buffers)からスタブを生成するためのコンテナ
    └── Dockerfile

今回は複数サービスを手元で立ち上げられるように docker-compose を用いて開発環境を構成していく。
まずは graphql-gateway をビルドする環境を作る。

docker-compose.yml
version: "3"
services:
  graphql-gateway:
    build: ./graphql-gateway
    volumes:
      - .:/app
    ports:
      - "8080:8080"
    depends_on:
      - grpc-user-service

ポイントは、他のサービスのディレクトリを含む全てのファイルを Docker 内の作業用ディレクトリ /app にマウントしているところ。
これは gRPC の設定時に詳しく説明するが、 gRPC 用のスキーマ定義(protocol buffers)を各サービスから symbolic link で参照できるようにするためにこうしている。

次に graphql-gateway 用の Dockerfile を用意していく。

graphql-gateway/Dockerfile
FROM golang:1.19.3

WORKDIR /app/graphql-gateway

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

RUN go install github.com/cosmtrek/air@v1.40.4
RUN go install github.com/99designs/gqlgen@v0.17.22

go.mod と go.sum が無いとビルド時にエラーが発生するので、先に作成しておく。

graphql-gateway/go.mod
module github.com/midwhite/golang-web-server-sample/graphql-gateway

go 1.19

module 宣言のレポジトリ部分は適宜自分のレポジトリ名に読み替えること。
go.sum は空ファイルで良いので touch コマンドで作っておく。

$ touch graphql-gateway/go.sum

ここまで来ると docker-compose でコンテナをビルドできるので、以降はコンテナ内で作業していく。
下記コマンドでコンテナ内に入ることができる。

$ docker-compose build
$ docker-compose run --rm graphql-gateway bash

go はデフォルトの状態だと live reload してくれなくて不便なので air を用いてコードの変更が即反映されるようにしていく。

$ air init

  __    _   ___
 / /\  | | | |_)
/_/--\ |_| |_| \_ , built with Go

.air.toml file created to the current directory with the default settings

air init コマンドで air の設定ファイル .air.toml を生成する。
生成したファイルの cmd 部分を、今回のエントリーポイントを置く予定の場所に書き換える。

graphql-gateway/.air.toml
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  args_bin = []
  bin = "./tmp/main"
-  cmd = "go build -o ./tmp/main ."
+  cmd = "go build -o ./tmp/main ./server.go"
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html"]
  kill_delay = "0s"
  log = "build-errors.log"
  send_interrupt = false
  stop_on_error = true

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  time = false

[misc]
  clean_on_exit = false

[screen]
  clear_on_rebuild = false

これで air コマンドで起動すればソースコードの変更が即反映される状態になったので、開発環境の構築はここまでにして次に進める。

gqlgen の導入

Go 用の GraphQL ライブラリとして gqlgen を利用する。
まずは下記コマンドで初期ファイルを生成する。

$ go get github.com/99designs/gqlgen
$ go run github.com/99designs/gqlgen init

下記のようなファイルが生成される。

graphql-gateway/
├── gqlgen.yml
├── graph
│   ├── generated.go
│   ├── model
│   │   └── models_gen.go
│   ├── resolver.go
│   ├── schema.graphqls
│   └── schema.resolvers.go
└── server.go

ここまでで docker-compose を使ってサーバーが立ち上がる状態になったので、コンテナから抜けて docker-compose up を実行してみよう。

$ docker-compose up -d
[+] Running 2/2
 ⠿ Network golang-web-server-sample_default              Created
 ⠿ Container golang-web-server-sample-graphql-gateway-1  Started
$ docker-compose logs
golang-web-server-sample-graphql-gateway-1  |
golang-web-server-sample-graphql-gateway-1  |   __    _   ___
golang-web-server-sample-graphql-gateway-1  |  / /\  | | | |_)
golang-web-server-sample-graphql-gateway-1  | /_/--\ |_| |_| \_ , built with Go
golang-web-server-sample-graphql-gateway-1  |
golang-web-server-sample-graphql-gateway-1  | watching .
golang-web-server-sample-graphql-gateway-1  | watching graph
golang-web-server-sample-graphql-gateway-1  | watching graph/model
golang-web-server-sample-graphql-gateway-1  | !exclude tmp
golang-web-server-sample-graphql-gateway-1  | building...
golang-web-server-sample-graphql-gateway-1  | running...
golang-web-server-sample-graphql-gateway-1  | 2023/01/06 23:23:26 connect to http://localhost:8080/ for GraphQL playground

またブラウザで http://localhost:8080/graphql にアクセスしてみると、手元でクエリを作って実行できる GraphiQL の画面が表示されるはずだ。
スクリーンショット 2023-01-07 8.26.17.png

GraphQL の実装

gqlgen を用いた GraphQL の実装は下記のように進める。

  • graph/schema.graphqls にスキーマ定義を書き、それを元にモデルとリゾルバを自動生成する
  • 自動生成されたリゾルバに必要な処理を実装する

まずスキーマ定義を下記のように書いてみる。

graphql-gateway/graph/schema.graphqls
type User {
  id: ID!
  name: String!
  age: Int!
}

type Query {
  users: [User!]!
}

input NewUser {
  name: String!
  age: Int!
}

input UserAttributes {
  id: ID!
  name: String!
  age: Int!
}

type Mutation {
  createUser(input: NewUser!): User!
  updateUser(input: UserAttributes!): User!
}

次に model と resolver を自動生成するが、最初に生成されたファイルにあった定義を消してしまったので、ファイルを1回削除してから再生成する。

$ rm graph/schema.resolvers.go
$ gqlgen # このコマンドで model, resolver を自動生成する

すると graph/ ディレクトリ以下のファイルが自動で編集され、必要な型などが追加される。
ここで graph/schema.resolvers.go を見ると Users CreateUser UpdateUser といった query/mutation が下記のように生成されている。

graphql:graphql-gateway/graph/schema.resolvers.go
// CreateUser is the resolver for the createUser field.
func (r *mutationResolver) CreateUser(ctx context.Context, input model.NewUser) (*model.User, error) {
	panic(fmt.Errorf("not implemented: CreateUser - createUser"))
}

// UpdateUser is the resolver for the updateUser field.
func (r *mutationResolver) UpdateUser(ctx context.Context, input model.UserAttributes) (*model.User, error) {
	panic(fmt.Errorf("not implemented: UpdateUser - updateUser"))
}

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

この中身を手で実装していくことになるが、現時点ではまだバックエンドサービスを作っていないので、仮で下記のように実装する。

graphql:graphql-gateway/graph/schema.resolvers.go
// CreateUser is the resolver for the createUser field.
func (r *mutationResolver) CreateUser(ctx context.Context, input model.NewUser) (*model.User, error) {
	return &model.User{ID: "uuid", Name: input.Name, Age: input.Age}, nil
}

// UpdateUser is the resolver for the updateUser field.
func (r *mutationResolver) UpdateUser(ctx context.Context, input model.UserAttributes) (*model.User, error) {
	return &model.User{ID: input.ID, Name: input.Name, Age: input.Age}, nil
}

// Users is the resolver for the users field.
func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
	users := []*model.User{
		{ID: "1", Name: "徳川家康", Age: 20},
		{ID: "2", Name: "豊臣秀吉", Age: 25},
		{ID: "3", Name: "織田信長", Age: 30},
	}

	return users, nil
}

試しに http://localhost:8080/graphql でクエリを投げてみると、下記の通り期待した結果が得られる。
スクリーンショット 2023-01-07 13.10.52.png
スクリーンショット 2023-01-07 13.15.17.png
スクリーンショット 2023-01-07 13.16.05.png

ここまでで GraphQL の実装は終わりにして、バックエンドサービスを作る段階に進むことにする。

Protocol Buffers 開発環境の構築

gRPC で開発を始めるために、 Protocol Buffers で書かれたスキーマ定義から gRPC の実装を自動生成する土台を作っていく。
まずは docker-compose に必要なサービスを追加していく。

docker-compose.yml
version: "3"
services:
  graphql-gateway:
    build: ./graphql-gateway
    volumes:
      - .:/app
    ports:
      - "8080:8080"
    command: air
    depends_on:
      - grpc-user-service
+  proto:
+    build: ./proto
+    volumes:
+      - .:/app

これはスキーマ定義からサービス間通信に用いる実装を自動生成するためのコンテナで、開発時にのみ実行する。
これをビルドするための Dockerfile を下記のように書く。

proto/Dockerfile
FROM golang:1.19.3

WORKDIR /app/proto

RUN apt-get update && apt-get install -y protobuf-compiler

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

次に前回と同様 go.mod と go.sum を先に作る。

proto/go.mod
module github.com/midwhite/golang-web-server-sample/proto

go 1.19
$ touch proto/go.sum

docker コンテナを立ち上げ、中で作業する。

$ docker-compose build
$ docker-compose run --rm proto bash

必要な go のライブラリをインストールする。

$ go get github.com/golang/protobuf/protoc-gen-go@v1.5.2

これで go.mod/go.sum に protoc-gen-go が追加されたので、 Dockerfile に下記を追加してイメージをビルドし直す。

proto/Dockerfile
FROM golang:1.19.3

WORKDIR /app/proto

RUN apt-get update && apt-get install -y protobuf-compiler

COPY go.mod go.sum ./
RUN go mod download
+
+ RUN go install github.com/golang/protobuf/protoc-gen-go@v1.5.2
+ 
+ CMD ["make", "generate"]
$ docker-compose build

スキーマ定義ファイルを下記のように追加する。
※レポジトリ部分は適宜自分のレポジトリ名に読み替えること。

proto/user-service/user-service.proto
syntax = "proto3";

option go_package = "github.com/midwhite/golang-web-server-sample/grpc-user-service/pb";

package pb;

service UserService {
  rpc GetUserDetail(GetUserDetailParams) returns (UserDetail) {}
}

message GetUserDetailParams { string id = 1; }

message UserDetail {
  string id = 1;
  string name = 2;
  int64 age = 3;
}

文法については公式docsを参照のこと。
このスキーマ定義ファイルから gRPC の実装を生成するために下記の通り Makefile を追加する。

proto/Makefile
generate:
	protoc -I . ./user-service/user-service.proto --go_out=plugins=grpc:./user-service

これを実行する。

$ make generate
protoc -I . ./user-service/user-service.proto --go_out=plugins=grpc:./user-service

すると user-service/github.com/midwhite/golang-web-server-sample/grpc-user-service/pb/ という深いディレクトリに user-service.pb.go というファイルが生成される。
このファイル内に gRPC 通信用のクライアント実装とサーバー実装が含まれているので、それを各サービスから参照して利用していく。

gRPC サーバー開発環境の構築

例の通り docker-compose にサービスを追加し、 Dockerfile を追加し、 go.mod/go.sum を作成する。

docker-compose.yml
version: "3"
services:
  graphql-gateway:
    build: ./graphql-gateway
    volumes:
      - .:/app
    ports:
      - "8080:8080"
    command: air
    depends_on:
      - grpc-user-service
  proto:
    build: ./proto
    volumes:
      - .:/app
+  grpc-user-service:
+    build: ./grpc-user-service
+    volumes:
+      - .:/app
+    command: air
grpc-user-service/Dockerfile
FROM golang:1.19.3

WORKDIR /app/grpc-user-service

RUN apt-get update && apt-get install -y postgresql-client

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

RUN go install github.com/cosmtrek/air@v1.40.4
grpc-user-service/go.mod
module github.com/midwhite/golang-web-server-sample/grpc-user-service

go 1.19
$ touch grpc-user-service/go.sum

イメージをビルドして docker コンテナの中に入る。

$ docker-compose build
$ docker-compose run --rm grpc-user-service bash

air の設定ファイルを生成する。

$ air init

ここまでで開発環境の構築は済んだので、gRPC サーバーの実装に進む。

gRPC サーバーを実装する

まずは pb ディレクトリを作り、先ほど proto で生成したファイルに symbolic link を貼る。

$ mkdir pb
$ cd pb/
$ ln -s ../../proto/user-service/github.com/midwhite/golang-web-server-sample/grpc-user-service/pb/user-service.pb.go userservice.go

こうすることで grpc-user-service/pb/userservice.go の位置で先ほど生成したファイルを参照することができる。
そこで新しく impl/userservice.go を追加し、下記のように先ほど protocol buffers で定義したスキーマの通りに中の処理を実装していく。
※今回は gRPC を介した通信さえできれば良いので、処理の中身は雑に固定の値を返すだけにする。

grpc-user-service/impl/userservice.go
package impl

import (
	"context"

	"github.com/midwhite/golang-web-server-sample/grpc-user-service/pb"
)

type UserServiceServer struct{}

func (s *UserServiceServer) GetUsers(_ context.Context, _ *pb.GetUsersParams) (*pb.UserList, error) {
	users := []*pb.User{
		{Id: "1", Name: "北条氏康", Age: 20},
		{Id: "2", Name: "武田信玄", Age: 30},
		{Id: "3", Name: "今川義元", Age: 40},
	}
	response := pb.UserList{
		Users: users,
	}
	return &response, nil
}

func (s *UserServiceServer) CreateUser(_ context.Context, _ *pb.CreateUserParams) (*pb.User, error) {
	user := pb.User{
		Id:   "uuid",
		Name: "長尾景虎",
		Age:  25,
	}
	return &user, nil
}

func (s *UserServiceServer) UpdateUser(_ context.Context, params *pb.UpdateUserParams) (*pb.User, error) {
	user := pb.User{
		Id:   params.Id,
		Name: "上杉謙信",
		Age:  35,
	}
	return &user, nil
}

このサーバー実装をエンドポイントとして公開するため下記のようにエントリーポイントを実装する。
公式のサンプル実装に倣っただけ)

grpc-user-service/server.go
package main

import (
	"flag"
	"fmt"
	"log"
	"net"

	"google.golang.org/grpc"

	"github.com/midwhite/golang-web-server-sample/grpc-user-service/impl"
	"github.com/midwhite/golang-web-server-sample/grpc-user-service/pb"
)

var (
	port = flag.Int("port", 50051, "The server port")
)

func main() {
	flag.Parse()
	lis, err := net.Listen("tcp", fmt.Sprintf("grpc-user-service:%d", *port))

	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	var opts []grpc.ServerOption
	grpcServer := grpc.NewServer(opts...)
	pb.RegisterUserServiceServer(grpcServer, &impl.UserServiceServer{})
	grpcServer.Serve(lis)
}

後は必要なライブラリをインストールすれば起動可能な状態になる。

$ go mod tidy

gRPC クライアントを実装する

最後に graphql-gateway から grpc-user-service に接続するため、 gRPC クライアントを実装していく。
と言っても、 graphql-gateway 側でも proto 内の user-service.pb.go を参照して、クライアント実装にパラメータを渡すだけで済むので簡単だ。

まずは下記のように symbolic link を作成する。

$ mkdir /app/graphql-gateway/pb
$ cd /app/graphql-gateway/pb
$ ln -s ../../proto/user-service/github.com/midwhite/golang-web-server-sample/grpc-user-service/pb/user-service.pb.go userservice.go

次に gRPC クライアントを実装する。
(これも公式のサンプル実装に倣っただけ)

graphql-gateway/userservice/client.go
package userservice

import (
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"

	"github.com/midwhite/golang-web-server-sample/graphql-gateway/pb"
)

var Client pb.UserServiceClient

func Setup() (func(), error) {
	var opts []grpc.DialOption
	opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))

	conn, err := grpc.Dial("grpc-user-service:50051", opts...)
	if err != nil {
		return nil, err
	}
	Client = pb.NewUserServiceClient(conn)

	return func() {
		conn.Close()
	}, nil
}

この Setup 関数をエントリーポイントの server.go 内から呼び出す。

graphql-gateway/server.go
package main

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

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/midwhite/golang-web-server-sample/graphql-gateway/graph"
	"github.com/midwhite/golang-web-server-sample/graphql-gateway/userservice"
)

const defaultPort = "8080"

func main() {
+	closer, err := userservice.Setup()
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer closer()

	port := os.Getenv("PORT")
	if port == "" {
		port = defaultPort
	}

	srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: &graph.Resolver{}}))

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

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

最後に GraphQL のリゾルバから gRPC クライアントを呼び出す。

graphql-gateway/graph/schema.resolvers.go
package graph

import (
	"context"

	"github.com/midwhite/golang-web-server-sample/graphql-gateway/graph/model"
	"github.com/midwhite/golang-web-server-sample/graphql-gateway/pb"
	"github.com/midwhite/golang-web-server-sample/graphql-gateway/userservice"
)

func (r *mutationResolver) CreateUser(ctx context.Context, input model.NewUser) (*model.User, error) {
	params := pb.CreateUserParams{Name: input.Name, Age: int64(input.Age)}
	res, err := userservice.Client.CreateUser(ctx, &params)

	if err != nil {
		return nil, err
	}

	user := model.User{ID: res.Id, Name: res.Name, Age: input.Age}

	return &user, nil
}

func (r *mutationResolver) UpdateUser(ctx context.Context, input model.UserAttributes) (*model.User, error) {
	params := pb.UpdateUserParams{Id: input.ID, Name: input.Name, Age: int64(input.Age)}
	res, err := userservice.Client.UpdateUser(ctx, &params)

	if err != nil {
		return nil, err
	}

	user := model.User{ID: res.Id, Name: res.Name, Age: input.Age}

	return &user, nil
}

func (r *queryResolver) Users(ctx context.Context) ([]*model.User, error) {
	res, err := userservice.Client.GetUsers(context.Background(), &pb.GetUsersParams{})

	if err != nil {
		return nil, err
	}

	users := make([]*model.User, len(res.Users))

	for i, user := range res.Users {
		users[i] = &model.User{ID: user.Id, Name: user.Name, Age: int(user.Age)}
	}

	return users, nil
}

func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }

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

これで実装は完了しているので、 http://localhost:8080/graphql で動作確認してみる。
スクリーンショット 2023-01-07 18.28.59.png
スクリーンショット 2023-01-07 18.29.50.png
スクリーンショット 2023-01-07 18.27.38.png

無事に長尾景虎が上杉謙信へと更新されていることが分かる。
ここまでで、フロントエンドとの通信を GraphQL で行い、バックエンドとの通信を gRPC で行う Go 製のマイクロサービスの開発環境をセットアップすることができた。

あとがき

本来は他にも DB との接続設定や運用時の監視設定やテストの設定など色々やるべきことはあるが、今回はサービス間通信のみを切り取って説明した。
まだ Go を学習して日が浅いため、例えば protocol buffers から Go 以外のスタブを生成する時にこのディレクトリ構成はこれでいいのか、 Go の gRPC クライアントの初期化処理はこれでいいのか等、実装全体で疑問に思っていることは多いものの、とりあえず動いたのでヨシ!

jrRD7Uvn_400x400.png

お気づきの点があればコメントで教えていただけると嬉しいです。 mm

7
6
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
7
6