背景
年末年始に 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 をビルドする環境を作る。
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 を用意していく。
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 が無いとビルド時にエラーが発生するので、先に作成しておく。
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
部分を、今回のエントリーポイントを置く予定の場所に書き換える。
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 の画面が表示されるはずだ。
GraphQL の実装
gqlgen を用いた GraphQL の実装は下記のように進める。
-
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 が下記のように生成されている。
// 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"))
}
この中身を手で実装していくことになるが、現時点ではまだバックエンドサービスを作っていないので、仮で下記のように実装する。
// 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 でクエリを投げてみると、下記の通り期待した結果が得られる。
ここまでで GraphQL の実装は終わりにして、バックエンドサービスを作る段階に進むことにする。
Protocol Buffers 開発環境の構築
gRPC で開発を始めるために、 Protocol Buffers で書かれたスキーマ定義から gRPC の実装を自動生成する土台を作っていく。
まずは docker-compose に必要なサービスを追加していく。
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 を下記のように書く。
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 を先に作る。
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 に下記を追加してイメージをビルドし直す。
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
スキーマ定義ファイルを下記のように追加する。
※レポジトリ部分は適宜自分のレポジトリ名に読み替えること。
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 を追加する。
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 を作成する。
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
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
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 を介した通信さえできれば良いので、処理の中身は雑に固定の値を返すだけにする。
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
}
このサーバー実装をエンドポイントとして公開するため下記のようにエントリーポイントを実装する。
(公式のサンプル実装に倣っただけ)
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 クライアントを実装する。
(これも公式のサンプル実装に倣っただけ)
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 内から呼び出す。
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 クライアントを呼び出す。
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, ¶ms)
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, ¶ms)
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 で動作確認してみる。
無事に長尾景虎が上杉謙信へと更新されていることが分かる。
ここまでで、フロントエンドとの通信を GraphQL で行い、バックエンドとの通信を gRPC で行う Go 製のマイクロサービスの開発環境をセットアップすることができた。
あとがき
本来は他にも DB との接続設定や運用時の監視設定やテストの設定など色々やるべきことはあるが、今回はサービス間通信のみを切り取って説明した。
まだ Go を学習して日が浅いため、例えば protocol buffers から Go 以外のスタブを生成する時にこのディレクトリ構成はこれでいいのか、 Go の gRPC クライアントの初期化処理はこれでいいのか等、実装全体で疑問に思っていることは多いものの、とりあえず動いたのでヨシ!
お気づきの点があればコメントで教えていただけると嬉しいです。 mm