iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 2
2
Modern Web

Framework Surfing 30天系列 第 2

Day 2 - API server (Go, GraphQL) - part2

前言

因為之後預計要寫的主題有 React, Vue, Angular, Electron ... 等等的前端框架,所以就先用 Go 架一個 GraphQL API Server 吧。

今天會用到的有:

  1. GORM
  2. gormigrate
  3. uuid

今天專案的結構會長這樣:

  • build
  • cmd
    • gql-server
      • main.go:執行 api server ( pkg/server 底下的那個 )
  • internal
    • gql:會把 gqlgen 產生的 graphql server code 複製到這層
      • generated:gqlgen 產生的 graphql server code
      • transformations:DB to GQL, GQL to DB
      • models:會把 gqlgen 產生的 models 複製到這層
        • generated:gqlgen 產生的 models
      • resolvers:會把 gqlgen 產生的 resolvers 複製到這層
        • generated:gqlgen 產生的 resolvers
    • gql-server
    • handlers:API handler function 都放在這邊
    • orm:跟 gorm 相關的
      • migration
        • jobs:放 seeds
        • main.go:
      • models
        • base.go:最基本的 model,所有其他的 models 都會用到它
        • user.go:定義 User model for grom
      • main.go:gorm 的控制中樞
  • pkg
    • server
      • main.go:我們的 api server (Gin)
  • scripts
    • build.sh:產生執行檔
    • run.sh:讀取 .env 黨並且執行 api server

下載需要的套件 GORM, gormigrate, UUID

$ go get -u github.com/jinzhu/gorm

$ go get gopkg.in/gormigrate.v1

$ go get github.com/gofrs/uuid

建立 GORM 用的 Model

因為我們選用的 DB 是 postgreSQL,Model 的 ID 不用想用預設的自動加一,改存 UUID,如此一來就要自己做一個 BaseModel,而不用原本的 gorm.Model,然後實作軟刪除與硬刪除兩種版本。

  1. 實作 BaseModel

    $ mkdir -p internal/orm/models

    $ touch internal/orm/models/base.go

     package models
    
     import (
     	"time"
    
     	"github.com/gofrs/uuid"
     	"github.com/jinzhu/gorm"
     )
    
     // BaseModel defines the common columns that all db structs should hold, usually
     // db structs based on this have no soft delete
     type BaseModel struct {
     	// ID should use uuid_generate_v4() for the pk's
     	ID        uuid.UUID  `gorm:"type:uuid;primary_key"`
     	CreatedAt time.Time  `gorm:"index;not null;default:CURRENT_TIMESTAMP"` // (My|Postgre)SQL
     	UpdatedAt *time.Time `gorm:"index"`
     }
    
     // BaseModelSoftDelete defines the common columns that all db structs should
     // hold, usually. This struct also defines the fields for GORM triggers to
     // detect the entity should soft delete
     type BaseModelSoftDelete struct {
     	BaseModel
     	DeletedAt *time.Time `sql:"index"`
     }
    
     // BeforeCreate will set a UUID rather than numeric ID.
     func (base *BaseModel) BeforeCreate(scope *gorm.Scope) error {
     	id, err := uuid.NewV4()
     	if err != nil {
     		return err
     	}
     	return scope.SetColumn("ID", id)
     }
    
  2. 實作 UserModel

    $ touch internal/gorm/models/user.go

    注意這邊 User struct 的第一行不是 gorm.Model 而是用我們自己寫的 BaseModelSoftDelete

     package models
    
     // User defines a user for the app
     type User struct {
     	BaseModelSoftDelete         // We don't to actually delete the users, maybe audit if we want to hard delete them? or wait x days to purge from the table, also
     	Email               string  `gorm:"not null;unique_index:idx_email"`
     	UserID              *string // External user ID
     	Name                *string
     	NickName            *string
     	FirstName           *string
     	LastName            *string
     	Location            *string `gorm:"size:1048"`
     	Description         *string `gorm:"size:1048"`
     }
    

Migration 的部分

  1. 為我們的 UserModel 寫第一個 Seed

    $ mkdir -p internal/orm/migration

    $ touch internal/orm/migration/jobs/seed_users.go

    在這邊馬上就會用到剛剛寫好的 internal/gorm/models/user.go

     package jobs
    
     import (
         "github.com/wtlin1228/go-gql-server/internal/orm/models"
         "github.com/jinzhu/gorm"
         "gopkg.in/gormigrate.v1"
     )
    
     var (
         uname                    = "Test User"
         fname                    = "Test"
         lname                    = "User"
         nname                    = "Foo Bar"
         description              = "This is the first user ever!"
         location                 = "His house, maybe? Wouldn't know"
         firstUser   *models.User = &models.User{
             Email:       "test@test.com",
             Name:        &uname,
             FirstName:   &fname,
             LastName:    &lname,
             NickName:    &nname,
             Description: &description,
             Location:    &location,
         }
     )
    
     // SeedUsers inserts the first users
     var SeedUsers *gormigrate.Migration = &gormigrate.Migration{
         ID: "SEED_USERS",
         Migrate: func(db *gorm.DB) error {
             return db.Create(&firstUser).Error
         },
         Rollback: func(db *gorm.DB) error {
             return db.Delete(&firstUser).Error
         },
     }
    
  2. Migration 中樞

    $ touch internal/orm/migration/main.go

    ServiceAutoMigration 包含兩個部分:

    1. 第一部分是自動建表,如果之後有新的 model 要重新 migration,記得要先在 db.AutoMigrate 裡面加上新的 model。

    2. 第二部分是使用我們寫好的 Seed,一樣如果有新的 Seed 也要加在最下面的 gormigrate.New 裡面喔。

      package migration
      
      import (
          "fmt"
      
          log "log"
      
          "github.com/jinzhu/gorm"
          "github.com/wtlin1228/go-gql-server/internal/orm/migration/jobs"
          "github.com/wtlin1228/go-gql-server/internal/orm/models"
          "gopkg.in/gormigrate.v1"
      )
      
      func updateMigration(db *gorm.DB) error {
          return db.AutoMigrate(
              &models.User{},
          ).Error
      }
      
      // ServiceAutoMigration migrates all the tables and modifications to the connected source
      func ServiceAutoMigration(db *gorm.DB) error {
          // Keep a list of migrations here
          m := gormigrate.New(db, gormigrate.DefaultOptions, nil)
          m.InitSchema(func(db *gorm.DB) error {
              log.Println("[Migration.InitSchema] Initializing database schema")
              switch db.Dialect().GetName() {
              case "postgres":
                  // Let's create the UUID extension, the user has to have superuser
                  // permission for now
                  db.Exec("create extension \"uuid-ossp\";")
              }
              if err := updateMigration(db); err != nil {
                  return fmt.Errorf("[Migration.InitSchema]: %v", err)
              }
              // Add more jobs, etc here
              return nil
          })
          m.Migrate()
      
          if err := updateMigration(db); err != nil {
              return err
          }
          m = gormigrate.New(db, gormigrate.DefaultOptions, []*gormigrate.Migration{
              jobs.SeedUsers,
          })
          return m.Migrate()
      }
      

GraphQL Server 的部分

因為我們的 User schema 有更動,而且昨天也還沒實作 User 的 CUD (create, update, delete 部分),因此會需要做一些更動。

  1. 先把 Schema 改成跟 GORM 的 model 能夠對應起來的樣子

    $ vim internal/gql/schemas/schema.graphql

     scalar Time
     # Types
     type User {
       id: ID!
       email: String!
       userId: String
       name: String
       firstName: String
       lastName: String
       nickName: String
       description: String
       location: String
       createdAt: Time!
       updatedAt: Time
     }
    
     # Input Types
     input UserInput {
       email: String
       userId: String
       displayName: String
       name: String
       firstName: String
       lastName: String
       nickName: String
       description: String
       location: String
     }
    
     # List Types
     type Users {
       count: Int # You want to return count for a grid for example
       list: [User!]! # that is why we need to specify the users object this way
     }
    
     # Define mutations here
     type Mutation {
       createUser(input: UserInput!): User!
       updateUser(id: ID!, input: UserInput!): User!
       deleteUser(id: ID!): Boolean!
     }
    
     # Define queries here
     type Query {
       users(id: ID): Users!
     }
    
  2. 使用 GQLGen 為新的 schema 產生基本檔案

    $ scripts/gqlgen.sh

    完成後,就像昨天一樣,把產生的檔案複製到對應的資料夾後 (也就是外面一層,因為我們把 gqlgen 產出的檔案都放在 generated/ 底下,只要拉出來就可以了)。

    models 和 gql-server 的檔案不需要改太多,只要把 import path 後面的 generated 刪掉即可。

  3. 修改 resolvers,因為這邊改比較多東西,所以把它獨立出來講,我們需要改寫一個檔案,新增兩個檔案

    1. 改寫 internal/gql/resolvers/main.go,在 Resolver 裡面加入 ORM,然後只留下 Mutation 以及 Query 的 Resolver struct。

       package resolvers
      
       import (
       	"github.com/wtlin1228/go-gql-server/internal/gql"
       	"github.com/wtlin1228/go-gql-server/internal/orm"
       )
      
       // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.
      
       type Resolver struct {
       	ORM *orm.ORM
       }
      
       func (r *Resolver) Mutation() gql.MutationResolver {
       	return &mutationResolver{r}
       }
       func (r *Resolver) Query() gql.QueryResolver {
       	return &queryResolver{r}
       }
      
       type mutationResolver struct{ *Resolver }
      
       type queryResolver struct{ *Resolver }
      
    2. 寫轉換器,讓 gql 和 orm 可以互相轉換

      $ mkdir -p internal/gql/resolvers/transformations

      $ touch -p internal/gql/resolvers/transformations/users.go

       package transformations
      
       import (
       	"errors"
      
       	"github.com/gofrs/uuid"
       	gql "github.com/wtlin1228/go-gql-server/internal/gql/models"
       	dbm "github.com/wtlin1228/go-gql-server/internal/orm/models"
       )
      
       // DBUserToGQLUser transforms [user] db input to gql type
       func DBUserToGQLUser(i *dbm.User) (o *gql.User, err error) {
       	o = &gql.User{
       		ID:          i.ID.String(),
       		Email:       i.Email,
       		UserID:      i.UserID,
       		Name:        i.Name,
       		FirstName:   i.FirstName,
       		LastName:    i.LastName,
       		NickName:    i.NickName,
       		Description: i.Description,
       		Location:    i.Location,
       		CreatedAt:   i.CreatedAt,
       		UpdatedAt:   i.UpdatedAt,
       	}
       	return o, err
       }
      
       // GQLInputUserToDBUser transforms [user] gql input to db model
       func GQLInputUserToDBUser(i *gql.UserInput, update bool, ids ...string) (o *dbm.User, err error) {
       	o = &dbm.User{
       		UserID:      i.UserID,
       		Name:        i.Name,
       		FirstName:   i.FirstName,
       		LastName:    i.LastName,
       		NickName:    i.NickName,
       		Description: i.Description,
       		Location:    i.Location,
       	}
       	if i.Email == nil && !update {
       		return nil, errors.New("Field [email] is required")
       	}
       	if i.Email != nil {
       		o.Email = *i.Email
       	}
       	if len(ids) > 0 {
       		updID, err := uuid.FromString(ids[0])
       		if err != nil {
       			return nil, err
       		}
       		o.ID = updID
       	}
       	return o, err
       }
      
    3. 將 User 的 Resolvers 都放在同一個檔案裡面

       package resolvers
      
       import (
       	"context"
       	"log"
      
       	models "github.com/wtlin1228/go-gql-server/internal/gql/models"
       	tf "github.com/wtlin1228/go-gql-server/internal/gql/resolvers/transformations"
       	dbm "github.com/wtlin1228/go-gql-server/internal/orm/models"
       )
      
       // CreateUser creates a record
       func (r *mutationResolver) CreateUser(ctx context.Context, input models.UserInput) (*models.User, error) {
       	return userCreateUpdate(r, input, false)
       }
      
       // UpdateUser updates a record
       func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input models.UserInput) (*models.User, error) {
       	return userCreateUpdate(r, input, true, id)
       }
      
       // DeleteUser deletes a record
       func (r *mutationResolver) DeleteUser(ctx context.Context, id string) (bool, error) {
       	return userDelete(r, id)
       }
      
       // Users lists records
       func (r *queryResolver) Users(ctx context.Context, id *string) (*models.Users, error) {
       	return userList(r, id)
       }
      
       // ## Helper functions
      
       func userCreateUpdate(r *mutationResolver, input models.UserInput, update bool, ids ...string) (*models.User, error) {
       	dbo, err := tf.GQLInputUserToDBUser(&input, update, ids...)
       	if err != nil {
       		return nil, err
       	}
       	// Create scoped clean db interface
       	db := r.ORM.DB.New().Begin()
       	if !update {
       		db = db.Create(dbo).First(dbo) // Create the user
       	} else {
       		db = db.Model(&dbo).Update(dbo).First(dbo) // Or update it
       	}
       	gql, err := tf.DBUserToGQLUser(dbo)
       	if err != nil {
       		db.RollbackUnlessCommitted()
       		return nil, err
       	}
       	db = db.Commit()
       	return gql, db.Error
       }
      
       func userDelete(r *mutationResolver, id string) (bool, error) {
       	return false, nil
       }
      
       func userList(r *queryResolver, id *string) (*models.Users, error) {
       	entity := "users"
       	whereID := "id = ?"
       	record := &models.Users{}
       	dbRecords := []*dbm.User{}
       	db := r.ORM.DB.New()
       	if id != nil {
       		db = db.Where(whereID, *id)
       	}
       	db = db.Find(&dbRecords).Count(&record.Count)
       	for _, dbRec := range dbRecords {
       		if rec, err := tf.DBUserToGQLUser(dbRec); err != nil {
       			log.Println(entity, err)
       		} else {
       			record.List = append(record.List, rec)
       		}
       	}
       	return record, db.Error
       }
      

將我們的 API Server 跑起來

在執行 $ scripts/run.sh 之前,我們還需要改寫一些東西,將我們寫好的 ORM 接上 server

  1. GraphqlHandler 需要接收 ORM

     // internal/handlers/gql.go
     package handlers
    
     import (
     	"github.com/99designs/gqlgen/handler"
     	"github.com/gin-gonic/gin"
     	"github.com/wtlin1228/go-gql-server/internal/gql"
     	"github.com/wtlin1228/go-gql-server/internal/gql/resolvers"
     	"github.com/wtlin1228/go-gql-server/internal/orm"
     )
    
     // GraphqlHandler defines the GQLGen GraphQL server handler
     func GraphqlHandler(orm *orm.ORM) gin.HandlerFunc {
     	// NewExecutableSchema and Config are in the generated.go file
     	c := gql.Config{
     		Resolvers: &resolvers.Resolver{
     			ORM: orm, // pass in the ORM instance in the resolvers to be used
     		},
     	}
    
     	h := handler.GraphQL(gql.NewExecutableSchema(c))
    
     	return func(c *gin.Context) {
     		h.ServeHTTP(c.Writer, c.Request)
     	}
     }
    
     // PlaygroundHandler Defines the Playground handler to expose our playground
     func PlaygroundHandler(path string) gin.HandlerFunc {
     	h := handler.Playground("Go GraphQL Server", path)
     	return func(c *gin.Context) {
     		h.ServeHTTP(c.Writer, c.Request)
     	}
     }
    
  2. 將 ORM 傳給GraphqlHandler

     // pkg/server/main.go
     package server
    
     import (
     	log "log"
    
     	"github.com/wtlin1228/go-gql-server/internal/orm"
    
     	"github.com/gin-gonic/gin"
     	"github.com/wtlin1228/go-gql-server/internal/handlers"
     	"github.com/wtlin1228/go-gql-server/pkg/utils"
     )
    
     var host, port, gqlPath, gqlPgPath string
     var isPgEnabled bool
    
     func init() {
     	host = utils.MustGet("GQL_SERVER_HOST")
     	port = utils.MustGet("GQL_SERVER_PORT")
     	gqlPath = utils.MustGet("GQL_SERVER_GRAPHQL_PATH")
     	gqlPgPath = utils.MustGet("GQL_SERVER_GRAPHQL_PLAYGROUND_PATH")
     	isPgEnabled = utils.MustGetBool("GQL_SERVER_GRAPHQL_PLAYGROUND_ENABLED")
     }
    
     // Run spins up the server
     func Run(orm *orm.ORM) {
     	log.Println("GORM_CONNECTION_DSN: ", utils.MustGet("GORM_CONNECTION_DSN"))
    
     	endpoint := "http://" + host + ":" + port
    
     	r := gin.Default()
     	// Handlers
     	// Simple keep-alive/ping handler
     	r.GET("/heartbeat", handlers.Heartbeat())
    
     	// GraphQL handlers
     	// Playground handler
     	if isPgEnabled {
     		r.GET(gqlPgPath, handlers.PlaygroundHandler(gqlPath))
     		log.Println("GraphQL Playground @ " + endpoint + gqlPgPath)
     	}
     	// Pass in the ORM instance to the GraphqlHandler
     	r.POST(gqlPath, handlers.GraphqlHandler(orm))
     	log.Println("GraphQL @ " + endpoint + gqlPath)
    
     	// Run the server
     	// Inform the user where the server is listening
     	log.Println("Running @ " + endpoint)
     	// Print out and exit(1) to the OS if the server cannot run
     	log.Fatal(r.Run(host + ":" + port))
     }
    
  3. 別忘了新增資料庫需要用的環境變數

     # Web framework config
     GIN_MODE=debug
     GQL_SERVER_HOST=localhost
     GQL_SERVER_PORT=7000
    
     # GQLGen config
     GQL_SERVER_GRAPHQL_PATH=/graphql
     GQL_SERVER_GRAPHQL_PLAYGROUND_ENABLED=true
     GQL_SERVER_GRAPHQL_PLAYGROUND_PATH=/
    
     # GORM config
     GORM_AUTOMIGRATE=true
     GORM_SEED_DB=true
     GORM_LOGMODE=true
     GORM_DIALECT=postgres
     GORM_CONNECTION_DSN=postgres://username:password@localhost:5432/yourdbname?sslmode=disable
    
  4. 跑起來

    終於要跑起來了嗎,沒錯,指令給他下去 $ scripts/run.sh

    對了,記得要開 postgreSQL,提供一些基本的指令做基本設定:

    1. $ psql 進到 postgreSQL
    2. $ postgre=# CREATE DATABASE yourdbname;
    3. $ postgre=# CREATE USER youruser WITH ENCRYPTED PASSWORD 'yourpass';
    4. $ postgre=# GRANT ALL PRIVILEGES ON DATABASE yourdbname TO youruser;

一切就緒之後

終於可以來爽爽的在 http://localhost:7000/ 上面測試 graphql 了,我們目前有的 query 以及 mutation 有這些,順便把 query 給大家:

  1. List Users:

     query {
     	allUsers: users {
         count,
         list {
           id
     			email
     			userId
     			name
     			firstName
     			lastName
     			nickName
     			description
     			location
     			createdAt
     			updatedAt
         }
       }
     }
    
  2. Get a User by ID

     query {
       userWithID: users(id: "41d9820e-6d2a-4902-9542-7c4c591434f6") {
         count,
         list {
           id
     			email
     			userId
     			name
     			firstName
     			lastName
     			nickName
     			description
     			location
     			createdAt
     			updatedAt
         }
       }
     }
    
  3. Create User

     mutation {
       createUser (
         input: {
         	name: "Leo"
           email: "wtlin1228@gmail.com"
           userId: "user-007"
           displayName: "唐唐"
         }
       ) {
         id
     		email
     		userId
     		name
     		firstName
     		lastName
     		nickName
     		description
     		location
     		createdAt
     		updatedAt
       }
     }
    
  4. Update User

     mutation {
       updateUser(
         id: "41d9820e-6d2a-4902-9542-7c4c591434f6"
         input: {
           name: "國家機器"
         }
       ) {
         id
     		email
     		userId
     		name
     		firstName
     		lastName
     		nickName
     		description
     		location
     		createdAt
     		updatedAt
       }
     }
    
  5. Delete User

     mutation {
       deleteUser(
         id: "41d9820e-6d2a-4902-9542-7c4c591434f6"
       )
     }
    

參考文章:

https://dev.to/cmelgarejo/creating-an-opinionated-graphql-server-with-go-part-3-3aoi

https://medium.com/@the.hasham.ali/how-to-use-uuid-key-type-with-gorm-cc00d4ec7100


上一篇
Day 1 - API server (Go, GraphQL) - part1
下一篇
Day 3 - API server (Go, GraphQL) - part3
系列文
Framework Surfing 30天6
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

1
ST 進行式
iT邦新手 4 級 ‧ 2019-09-05 16:29:53

感恩樓主寫的詳盡 ???

唐唐 iT邦新手 5 級 ‧ 2019-09-06 00:11:46 檢舉

/images/emoticon/emoticon12.gif

1
ST 進行式
iT邦新手 4 級 ‧ 2019-09-05 16:30:34

感恩樓主寫的詳盡 ???

唐唐 iT邦新手 5 級 ‧ 2019-09-06 00:12:21 檢舉

/images/emoticon/emoticon15.gif

我要留言

立即登入留言