使用go+gqlgen构建GraphQL服务器(使用GORM进行数据库连接)
题目
如题所示。在与GraphQL相关的上一次实践中,我们使用了”nuxtjs/apollo”作为前端,”go+gqlgen”作为后端来创建GraphQL服务。
在那次实践中,后端只是简单地返回了固定的JSON数据,这只是为了勉强让服务运行,当然在实际应用中,我们不可能只返回固定的JSON数据。
因此,这一次我们将进行持久化实现。
相关文章索引
-
- 第12回「GraphQLにおけるRelayスタイルによるページング実装再考(Window関数使用版)」
-
- 第11回「Dataloadersを使ったN+1問題への対応」
-
- 第10回「GraphQL(gqlgen)エラーハンドリング」
-
- 第9回「GraphQLにおける認証認可事例(Auth0 RBAC仕立て)」
-
- 第8回「GraphQL/Nuxt.js(TypeScript/Vuetify/Apollo)/Golang(gqlgen)/Google Cloud Storageの組み合わせで動画ファイルアップロード実装例」
-
- 第7回「GraphQLにおけるRelayスタイルによるページング実装(後編:フロントエンド)」
-
- 第6回「GraphQLにおけるRelayスタイルによるページング実装(前編:バックエンド)」
-
- 第5回「DB接続付きGraphQLサーバ(by Golang)をローカルマシン上でDockerコンテナ起動」
-
- 第4回「graphql-codegenでフロントエンドをGraphQLスキーマファースト」
-
- 第3回「go+gqlgenでGraphQLサーバを作る(GORM使ってDB接続)」
-
- 第2回「NuxtJS(with Apollo)のTypeScript対応」
- 第1回「frontendに「nuxtjs/apollo」、backendに「go+gqlgen」の組み合わせでGraphQLサービスを作る」
开发环境
操作系统 – Linux(Ubuntu)
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.2 LTS (Bionic Beaver)"
后端
话说
$ go version
go version go1.13.3 linux/amd64
包管理器 – Go模块
IDE – Goland (集成开发环境 – Goland)
GoLand 2019.2.5
Build #GO-192.7142.48, built on November 8, 2019
# Docker容器
Docker是一个开源的平台,用于自动化部署、管理和运行应用程序。
$ docker -v
Docker version 18.09.2, build 6247962
Docker Compose:船运组合
$ docker-compose -v
docker-compose version 1.23.1, build b02f1306
# 数据库连接工具
DataGrip是一种数据库开发工具。
DataGrip 2019.3.1
Build #DB-193.5662.58, built on December 18, 2019
请你给我提供一个选项的中文的同义句。
GraphQL (图灵规范查询语言)
https://graphql.org/ -> https://graphql.org/ (不需要重新翻译)
https://gqlgen.com/ -> https://gqlgen.com/ (不需要重新翻译)
O-R映射器
实践
本次的全部源代码如下:
https://github.com/sky0621/study-graphql/tree/v0.3.0
设计
以下是一个模仿TODO应用的功能。
-
- ユーザ情報を1件登録
-
- ユーザ情報を1件取得(★そのユーザと紐づくTODO情報も同時に取得可能)
-
- 全ユーザ情報を取得(★同上)
-
- TODO情報を1件登録
-
- TODO情報を1件取得(★そのTODOを登録したユーザ情報も同時に取得)
- 全TODO情報を取得(★同上)
在本地搭建数据库服务器
使用docker-compose,编写Yaml文件。
因为数据库可以使用任何一种,所以我选择了MySQL作为临时方案。
version: '3'
services:
db:
restart: always
image: mysql:5.7.24
command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: localuser
MYSQL_PASSWORD: localpass
MYSQL_DATABASE: localdb
volumes:
- ./persistence/init:/docker-entrypoint-initdb.d
另外,在Docker启动时,我还想在容器内的数据库中创建表格,因此提供以下DDL。
CREATE TABLE IF NOT EXISTS `todo` (
`id` varchar(64) NOT NULL,
`text` varchar(256) NOT NULL,
`done` bool NOT NULL,
`user_id` varchar(64) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_bin;
CREATE TABLE IF NOT EXISTS `user` (
`id` varchar(64) NOT NULL,
`name` varchar(256) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE utf8_bin;
由于准备完毕,可以开始启动了。
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql
$
$ ll docker-compose.yml
-rw-r--r-- 1 sky0621 sky0621 399 Dec 22 20:12 docker-compose.yml
$
$ ls -lR persistence/
persistence/:
total 8
drwxr-xr-x 2 sky0621 sky0621 4096 Dec 22 20:14 init
-rw-r--r-- 1 sky0621 sky0621 99 Dec 22 19:18 README.md
persistence/init:
total 4
-rw-r--r-- 1 sky0621 sky0621 405 Dec 22 20:14 1_create.sql
$
$ sudo docker-compose up
Creating network "study-graphql_default" with the default driver
Creating study-graphql_db_1_3a13f8e517b8 ... done
Attaching to study-graphql_db_1_f411e51b48e6
db_1_f411e51b48e6 | Initializing database
・
・
・
db_1_f411e51b48e6 | 2019-12-27T17:02:30.129056Z 0 [Note] mysqld: ready for connections.
db_1_f411e51b48e6 | Version: '5.7.24' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server (GPL)

GraphQL模式
首先定义“TODO信息”和“用户信息”模型。
“TODO信息”模型中包含“用户信息”模型,并且“用户信息”模型中也包含多个“TODO信息”模型。
通过这种定义,当客户端发出获取该模型的查询时,可以通过一次查询获取整个层次结构。
接下来定义以下四个查询。
– 获取所有待办事项信息。
– 获取第一个待办事项信息。
– 获取所有用户信息。
– 获取第一个用户信息。
下面是定义用于新建的参数。
– 以名称“NewTodo”定义TODO注册时的参数。
– 以名称“NewUser”定义用户注册时的参数。
最后定义了以下两个变异:
– TODO信息注册
– 用户信息注册
# GraphQL schema example
#
# https://gqlgen.com/getting-started/
type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}
type User {
id: ID!
name: String!
todos: [Todo!]!
}
type Query {
todos: [Todo!]!
todo(id: ID!): Todo!
users: [User!]!
user(id: ID!): User!
}
input NewTodo {
text: String!
userId: String!
}
input NewUser {
name: String!
}
type Mutation {
createTodo(input: NewTodo!): ID!
createUser(input: NewUser!): ID!
}
Go的实现
在主函数中添加服务器启动时对数据库连接进行初始化的处理。
package main
import (
"log"
"net/http"
"os"
"github.com/99designs/gqlgen/handler"
"github.com/jinzhu/gorm"
"github.com/sky0621/study-graphql/backend"
_ "github.com/go-sql-driver/mysql"
)
const dataSource = "localuser:localpass@tcp(127.0.0.1:3306)/localdb?charset=utf8&parseTime=True&loc=Local"
const defaultPort = "5050"
func main() {
port := os.Getenv("PORT")
if port == "" {
port = defaultPort
}
// 主にここの処理
db, err := gorm.Open("mysql", dataSource)
if err != nil {
panic(err)
}
if db == nil {
panic(err)
}
defer func() {
if db != nil {
if err := db.Close(); err != nil {
panic(err)
}
}
}()
db.LogMode(true)
http.Handle("/", handler.Playground("GraphQL playground", "/query"))
http.Handle("/query", handler.GraphQL(backend.NewExecutableSchema(backend.Config{Resolvers: &backend.Resolver{DB: db}})))
log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
模型定义
接下来是GraphQL模式中定义的”TODO信息”模型和”用户信息”模型的Go实现。
package models
type Todo struct {
ID string `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
}
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
当与GraphQL模式定义的模型进行比较,就会发现例如”TODO信息”模型。
在GraphQL模式中,可以这样定义。
type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}
Go的实现中缺少与”user: User!”相关的元素。
“用户信息”模型也是如此。
type User {
id: ID!
name: String!
todos: [Todo!]!
}
「TODO情報」模型的数组在Go端的实现中不存在。
这实际上是要点所在。
gqlgen相关
可以从上面的内容中看出,使用 gqlgen 可以自动生成各种资源。
schema:
- ../schema/schema.graphql
exec:
filename: generated.go
model:
filename: models_gen.go
resolver:
filename: resolver.go
type: Resolver
autobind:
- github.com/sky0621/study-graphql/backend/models
models:
User:
model: models.User
Todo:
model: models.Todo
代码生成工具
$ pwd
/home/sky0621/src/github.com/sky0621/study-graphql/backend
$
$ ll gqlgen.yml
-rw-r--r-- 1 sky0621 sky0621 388 Dec 22 23:32 gqlgen.yml
$
$ go run github.com/99designs/gqlgen init
・
・
・
$
$ ll resolver.go
-rw-rw-r-- 1 sky0621 sky0621 3.7K Dec 26 01:07 resolver.go
关于使用gqlgen命令,请参考以下链接:
https://qiita.com/sky0621/items/8abd445edba347e8f6f1#gqlgenコマンドによるスケルトン生成
生成的resolver.go文件内的内容是什么?
package backend
import (
"context"
"github.com/sky0621/study-graphql/backend/models"
)
// THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.
type Resolver struct{}
func (r *Resolver) Mutation() MutationResolver {
return &mutationResolver{r}
}
func (r *Resolver) Query() QueryResolver {
return &queryResolver{r}
}
func (r *Resolver) Todo() TodoResolver {
return &todoResolver{r}
}
func (r *Resolver) User() UserResolver {
return &userResolver{r}
}
type mutationResolver struct{ *Resolver }
func (r *mutationResolver) CreateTodo(ctx context.Context, input NewTodo) (string, error) {
panic("not implemented")
}
func (r *mutationResolver) CreateUser(ctx context.Context, input NewUser) (string, error) {
panic("not implemented")
}
type queryResolver struct{ *Resolver }
func (r *queryResolver) Todos(ctx context.Context) ([]*models.Todo, error) {
panic("not implemented")
}
func (r *queryResolver) Todo(ctx context.Context, id string) (*models.Todo, error) {
panic("not implemented")
}
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
panic("not implemented")
}
func (r *queryResolver) User(ctx context.Context, id string) (*models.User, error) {
panic("not implemented")
}
type todoResolver struct{ *Resolver }
func (r *todoResolver) User(ctx context.Context, obj *models.Todo) (*models.User, error) {
panic("not implemented")
}
type userResolver struct{ *Resolver }
func (r *userResolver) Todos(ctx context.Context, obj *models.User) ([]*models.Todo, error) {
panic("not implemented")
}
嗯,雖然這裡寫了很多東西,但這些都是在執行GraphQL模式所定義的查詢和突變所需的邏輯集合。
在這其中,剛才提到的「在GraphQL模式中有一個『user: User!』,但在Go模型中沒有」涉及到以下兩個:
– todoResolver的User方法
– userResolver的Todos方法
关于它如何相关,最直观的方式就是实施并进行操作确认,所以首先要进行实施。
用户注册(省略TODO注册)
当收到客户的以下要求时,
type Mutation {
createUser(input: NewUser!): ID!
}
input NewUser {
name: String!
}
解决器的以下部分发生了火灾
func (r *mutationResolver) CreateUser(ctx context.Context, input NewUser) (string, error) {
log.Printf("[mutationResolver.CreateUser] input: %#v", input)
id := util.CreateUniqueID()
err := database.NewUserDao(r.DB).InsertOne(&database.User{
ID: id,
Name: input.Name,
})
if err != nil {
return "", err
}
return id, nil
}
type NewUser struct {
Name string `json:"name"`
}
将目标用户信息注册到用户表中。
func (d *userDao) InsertOne(u *User) error {
res := d.db.Create(u)
if err := res.Error; err != nil {
return err
}
return nil
}
type User struct {
ID string `gorm:"column:id;primary_key"`
Name string `gorm:"column:name"`
}
func (u *User) TableName() string {
return "user"
}


1用户获取(忽略TODO获取)
如果客户有以下要求,
type Query {
user(id: ID!): User!
}
type User {
id: ID!
name: String!
todos: [Todo!]!
}
type Todo {
id: ID!
text: String!
done: Boolean!
user: User!
}
解决员的以下部分发生了爆炸,并且
func (r *queryResolver) User(ctx context.Context, id string) (*models.User, error) {
log.Printf("[queryResolver.User] id: %s", id)
user, err := database.NewUserDao(r.DB).FindOne(id)
if err != nil {
return nil, err
}
return &models.User{
ID: user.ID,
Name: user.Name,
}, nil
}
再三強调一下,GraphQl模式中有一个与”多个待办事项信息”相关的元素,而Go模型中没有这个元素。
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
从用户表中获取目标用户的信息。
func (d *userDao) FindOne(id string) (*User, error) {
var users []*User
res := d.db.Where("id = ?", id).Find(&users)
if err := res.Error; err != nil {
return nil, err
}
if len(users) < 1 {
return nil, nil
}
return users[0], nil
}

嗯?
在Go的模型中,User对象并没有包含一个Todo数组,但是响应中却包含了”todos”这个字段…。
这个其实是在queryResolver.User方法调用完毕后,以下的内容也被触发了。
在以下的Todos参数中,obj *models.User包含了从数据库获取的用户信息,所以可以通过该obj获取ID,并获取与该用户相关的TODO列表。
func (r *userResolver) Todos(ctx context.Context, obj *models.User) ([]*models.Todo, error) {
log.Println("[userResolver.Todos]")
todos, err := database.NewTodoDao(r.DB).FindByUserID(obj.ID)
if err != nil {
return nil, err
}
var results []*models.Todo
for _, todo := range todos {
results = append(results, &models.Todo{
ID: todo.ID,
Text: todo.Text,
Done: todo.Done,
})
}
return results, nil
}
整理
如果按照本次实现的方式,如果是获取TODO信息的查询,则会触发TODO信息获取的解析器;如果是获取用户信息的查询,则会触发用户信息获取的解析器。
只要实现了各自的处理,GraphQL库会根据需要适时调用必要的处理,并根据GraphQL模式的响应结构将结果返回给客户端。
这里有一个问题。
在获取用户信息的查询中,”TODO信息”模型中包含”用户信息”模型,每次获取TODO信息时也会同时触发关联的用户信息的获取。
也就是说,如果数据库中有100条TODO信息,如果从客户端发出”获取所有TODO信息”的查询,并请求将”用户信息”作为包含在结果中的元素,那么就会发生以下101次数据库IO操作。
– 从todo表中查询所有TODO信息的SQL查询操作
– 针对每条以上获取的结果,执行查询与之关联的用户信息的SQL查询操作
N + 1问题。
解决这个问题很简单,在获取全部TODO信息的SQL中,通过与用户表(user table)进行JOIN来获取数据,这样就不需要101个SQL,而是只需一个SQL。
然而,这样做的话,即使在执行查询客户端不需要包含用户信息的情况下,服务器端也会发出必须JOIN用户表并选择的SQL语句来获取全部TODO信息。
但是,为了避免这个问题,必须根据对响应的需求来切换SQL语句,这需要实现特定的逻辑。
如果可能的话,都想避免这两种情况。作为解决这个问题的方法,似乎有一种叫做dataloader的库(?)。
下次试试看吧。以下链接可能会有所帮助。
https://qiita.com/yuku_t/items/2c1735cbf45e75c0bfb8