搭建使用 Go 编写的微服务,通过 GraphQL 和 gRPC 进行通信
背景 – :
因为在年末年初学习了 Go 语言,所以尝试着实现了 GraphQL 和 gRPC 的示例项目。
想做的东西 zuò de
-
- フロントエンドから 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 的模式定义(协议缓冲区)。
接下来准备为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
请根据需要将模块声明部分的仓库名称更换为您自己的仓库名称。
由于 go.sum 文件可以为空文件,您可以使用 touch 命令先创建一个。
$ touch graphql-gateway/go.sum
当我们到达这一步,可以使用docker-compose来构建容器,然后我们将在容器内进行工作。
您可以使用以下命令进入容器内。
$ docker-compose build
$ docker-compose run --rm graphql-gateway bash
由于默认情况下 Go 不提供实时重新加载功能,这使得编码变得很不方便。因此,我们将使用 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
使用 gqlgen 作为 Go 的 GraphQL 库。
首先,使用以下命令生成初始文件。
$ 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

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!
}
首先,自动生成模型和解析器,但由于意外地删除了最初生成的文件中的定义,所以需要先删除文件,然后再次生成。
$ rm graph/schema.resolvers.go
$ gqlgen # このコマンドで model, resolver を自動生成する
然后,graph/目录下的文件会自动编辑,并添加所需的类型等。如果查看graph/schema.resolvers.go,会发现生成了以下的query/mutation,例如Users、CreateUser、UpdateUser。
// 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
}

我们决定在这一阶段结束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
由于将 protoc-gen-go 添加到 go.mod/go.sum 文件中,因此需要将以下内容添加到 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;
}
关于语法,请参考官方文档。
为了生成与此架构定义文件对应的 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中生成的文件创建符号链接。
$ 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文件,并按照先前在协议缓冲区中定义的模式实现其中的处理。
※ 只要能够通过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客户端。
最终,我们将实现一个 gRPC 客户端,用于连接 graphql-gateway 到 grpc-user-service。虽然在 graphql-gateway 中,我们只需要引用 user-service.pb.go 文件,并将参数传递给客户端实现即可,所以很简单。
首先,创建一个象征性的链接如下。
$ 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
}
在server.go中调用此Setup函数作为入口点。
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 }

我們可以明白上杉謙信無事地被長尾景虎所接班。
到目前為止,我們成功使用GraphQL與前端進行通訊,並使用gRPC與後端進行通訊,並建立了一個由Go語言製作的微服務開發環境。
作者留言
本来需要进行与数据库的连接设置,操作时的监视设置以及测试设置等等很多事情。但是这次只是简单地解释了服务之间的通信。由于我刚开始学习Go语言,对于例如从协议缓冲区生成Go以外的存根时的目录结构是否正确,Go的gRPC客户端的初始化处理是否正确等等,我对整个实现有很多疑问,但暂时先让它能运行起来吧!

如果您有任何发现或意见,请在评论中告诉我,我会非常高兴。