使用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)
Screenshot from 2019-12-28 02-05-48.png

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"
}
Screenshot from 2019-12-28 03-18-24.png
Screenshot from 2019-12-28 03-19-32.png

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
}
Screenshot from 2019-12-28 03-47-30.png

嗯?
在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

bannerAds