因為之後預計要寫的主題有 React, Vue, Angular, Electron ... 等等的前端框架,所以就先用 Go 架一個 GraphQL API Server 吧。
$ go get -u github.com/jinzhu/gorm
$ go get gopkg.in/gormigrate.v1
$ go get github.com/gofrs/uuid
因為我們選用的 DB 是 postgreSQL,Model 的 ID 不用想用預設的自動加一,改存 UUID,如此一來就要自己做一個 BaseModel,而不用原本的 gorm.Model,然後實作軟刪除與硬刪除兩種版本。
實作 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)
}
實作 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"`
}
為我們的 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
},
}
Migration 中樞
$ touch internal/orm/migration/main.go
ServiceAutoMigration 包含兩個部分:
第一部分是自動建表,如果之後有新的 model 要重新 migration,記得要先在 db.AutoMigrate 裡面加上新的 model。
第二部分是使用我們寫好的 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()
}
因為我們的 User schema 有更動,而且昨天也還沒實作 User 的 CUD (create, update, delete 部分),因此會需要做一些更動。
先把 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!
}
使用 GQLGen 為新的 schema 產生基本檔案
$ scripts/gqlgen.sh
完成後,就像昨天一樣,把產生的檔案複製到對應的資料夾後 (也就是外面一層,因為我們把 gqlgen 產出的檔案都放在 generated/ 底下,只要拉出來就可以了)。
models 和 gql-server 的檔案不需要改太多,只要把 import path 後面的 generated 刪掉即可。
修改 resolvers,因為這邊改比較多東西,所以把它獨立出來講,我們需要改寫一個檔案,新增兩個檔案
改寫 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 }
寫轉換器,讓 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
}
將 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
}
在執行 $ scripts/run.sh
之前,我們還需要改寫一些東西,將我們寫好的 ORM 接上 server
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)
}
}
將 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))
}
別忘了新增資料庫需要用的環境變數
# 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
跑起來
終於要跑起來了嗎,沒錯,指令給他下去 $ scripts/run.sh
對了,記得要開 postgreSQL,提供一些基本的指令做基本設定:
$ psql
進到 postgreSQL$ postgre=# CREATE DATABASE yourdbname;
$ postgre=# CREATE USER youruser WITH ENCRYPTED PASSWORD 'yourpass';
$ postgre=# GRANT ALL PRIVILEGES ON DATABASE yourdbname TO youruser;
終於可以來爽爽的在 http://localhost:7000/ 上面測試 graphql 了,我們目前有的 query 以及 mutation 有這些,順便把 query 給大家:
List Users:
query {
allUsers: users {
count,
list {
id
email
userId
name
firstName
lastName
nickName
description
location
createdAt
updatedAt
}
}
}
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
}
}
}
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
}
}
Update User
mutation {
updateUser(
id: "41d9820e-6d2a-4902-9542-7c4c591434f6"
input: {
name: "國家機器"
}
) {
id
email
userId
name
firstName
lastName
nickName
description
location
createdAt
updatedAt
}
}
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