iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 3
1
Modern Web

Framework Surfing 30天系列 第 3

Day 3 - API server (Go, GraphQL) - part3

  • 分享至 

  • xImage
  •  

前言:

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

今天會增加一個 Post model,也就是文章,一個 User 可以擁有很多篇文章。

明天會設計前端畫面,設計完畫面之後會再回來修改 schema。

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

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

先從 ORM 開始下手

  1. 新增一個 post model

    touch /internal/orm/models/post.go

     // internal/orm/models/post.go
     package models
    
     import "github.com/gofrs/uuid"
    
     // Post defines a post for the app
     type Post struct {
     	BaseModelSoftDelete 
     	Title               string
     	Content             *string
     	UserID              uuid.UUID // User has many Posts
     }
    
  2. 修改 user model

     // internal/orm/models/user.go
     package models
    
     // User defines a user for the app
     type User struct {
     	BaseModelSoftDelete
     	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"`
    
     	// 修改 user model
     	Posts       []*Post // User has many Posts
     }
    
  3. 修改 migration

     // internal/orm/migration/main.go
     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{},
    
     		// 修改 migration 
     		&models.Post{},
     	).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()
     }
    
  4. 修改 seed

    除了新增一個使用者以外,也幫這個使用者新增一篇文章

     // internal/orm/migration/jobs/seed_users.go
     package jobs
    
     import (
     	"github.com/jinzhu/gorm"
     	"github.com/wtlin1228/go-gql-server/internal/orm/models"
     	"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"
     	pcontent                 = "Test post Content"
     	firstUser   *models.User = &models.User{
     		Email:       "test@test.com",
     		Name:        &uname,
     		FirstName:   &fname,
     		LastName:    &lname,
     		NickName:    &nname,
     		Description: &description,
     		Location:    &location,
    
     		// 修改 seed
     		Posts: []*models.Post{
     			&models.Post{
     				Title:   "Test post title",
     				Content: &pcontent,
     			},
     		},
     	}
     )
    
     // 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
     	},
     }
    
  5. 重新 migration

    $ scripts/run.sh

    如此一來,資料庫裡面就會多一張 Post 的表

接著是 GraphQL 的部分

  1. 因為我們加了 post,所以要先修改 schema

     # 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
       posts: [Post!]!
     }
    
     type Post {
       id: ID!
       title: String!
       content: String
       createdAt: Time!
       updatedAt: Time
       user: User
     }
    
     # Input Types
     input UserInput {
       email: String
       userId: String
       displayName: String
       name: String
       firstName: String
       lastName: String
       nickName: String
       description: String
       location: String
     }
    
     input PostInput {
       title: String
       content: String
       userId: 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
     }
    
     type Posts {
       count: Int
       list: [Post!]!
     }
    
     # Define mutations here
     type Mutation {
       createUser(input: UserInput!): User!
       updateUser(id: ID!, input: UserInput!): User!
       deleteUser(id: ID!): Boolean!
       createPost(input: PostInput!): Post!
       updatePost(id: ID!, input: PostInput!): Post!
       deletePost(id: ID!): Boolean!
     }
    
     # Define queries here
     type Query {
       users(id: ID): Users!
       posts(id: ID): Posts!
     }
    
  2. 用 gqlgen 產生新的 graphql 檔案,並將這些檔案複製出來

    $ scripts/gqlgen.sh

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

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

    models 我們將 user 和 post 分開兩個檔案來放他們。

  3. 修改 resolvers

    1. 先把檔案複製出來並且分成 main.go、post.go、user.go

      $ touch internal/gql/resolvers/post.go

       // internal/gql/resolvers/main.go
       package resolvers
      
       import (
       	"github.com/wtlin1228/go-gql-server/internal/gql"
       	"github.com/wtlin1228/go-gql-server/internal/orm"
       )
      
       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 }
      
      
      
       -----
      
      
       // internal/gql/resolvers/post.go
       func (r *mutationResolver) CreatePost(ctx context.Context, input models.PostInput) (*models.Post, error) {
       	panic("not implemented")
       }
       func (r *mutationResolver) UpdatePost(ctx context.Context, id string, input models.PostInput) (*models.Post, error) {
       	panic("not implemented")
       }
       func (r *mutationResolver) DeletePost(ctx context.Context, id string) (bool, error) {
       	panic("not implemented")
       }
       func (r *queryResolver) Posts(ctx context.Context, id *string) (*models.Posts, error) {
       	panic("not implemented")
       }
      
      
       -----
      
      
       // internal/gql/resolvers/user.go
       func (r *mutationResolver) CreateUser(ctx context.Context, input models.UserInput) (*models.User, error) {
       	panic("not implemented")
       }
       func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input models.UserInput) (*models.User, error) {
       	panic("not implemented")
       }
       func (r *mutationResolver) DeleteUser(ctx context.Context, id string) (bool, error) {
       	panic("not implemented")
       }
       func (r *queryResolver) Users(ctx context.Context, id *string) (*models.Users, error) {
       	panic("not implemented")
       }
      
    2. 像昨天一樣,我們需要寫一個橋接器,將 orm 和 graphql 的 model 接起來,讓他們可以互相轉換

      $ touch internal/gql/resolvers/transformations/post.go

       // internal/gql/resolvers/transformations/post.go 
       package transformations
      
       import (
       	"github.com/gofrs/uuid"
       	gql "github.com/wtlin1228/go-gql-server/internal/gql/models"
       	dbm "github.com/wtlin1228/go-gql-server/internal/orm/models"
       )
      
       // DBPostToGQLPost transforms [post] db input to gql type
       func DBPostToGQLPost(i *dbm.Post) (o *gql.Post, err error) {
       	o = &gql.Post{
       		ID:        i.ID.String(),
       		Title:     i.Title,
       		Content:   i.Content,
       		CreatedAt: i.CreatedAt,
       		UpdatedAt: i.UpdatedAt,
       	}
       	return o, err
       }
      
       // GQLInputPostToDBPost transforms [post] gql input to db model
       func GQLInputPostToDBPost(i *gql.PostInput, update bool, ids ...string) (o *dbm.Post, err error) {
       	o = &dbm.Post{
       		Title:   *i.Title,
       		Content: i.Content,
       	}
       	updUserID, err := uuid.FromString(*i.UserID)
       	if err != nil {
       		return nil, err
       	}
       	o.UserID = updUserID
      
       	if len(ids) > 0 {
       		updID, err := uuid.FromString(ids[0])
       		if err != nil {
       			return nil, err
       		}
       		o.ID = updID
       	}
       	return o, err
       }
      
    3. 將 post 和 user 的 resolvers 完成

       // internal/gql/resolvers/post.go
       package resolvers
      
       import (
       	"context"
       	"log"
      
       	"github.com/gofrs/uuid"
       	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"
       )
      
       // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.
      
       func (r *mutationResolver) CreatePost(ctx context.Context, input models.PostInput) (*models.Post, error) {
       	return postCreateUpdate(r, input, false)
       }
       func (r *mutationResolver) UpdatePost(ctx context.Context, id string, input models.PostInput) (*models.Post, error) {
       	return postCreateUpdate(r, input, true, id)
       }
       func (r *mutationResolver) DeletePost(ctx context.Context, id string) (bool, error) {
       	return postDelete(r, id)
       }
      
       func (r *queryResolver) Posts(ctx context.Context, id *string) (*models.Posts, error) {
       	return postList(r, id)
       }
      
       // ## Helper functions
      
       func postCreateUpdate(r *mutationResolver, input models.PostInput, update bool, ids ...string) (*models.Post, error) {
       	dbo, err := tf.GQLInputPostToDBPost(&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 post
       	} else {
       		db = db.Model(&dbo).Update(dbo).First(dbo) // Or update it
       	}
       	gql, err := tf.DBPostToGQLPost(dbo)
       	if err != nil {
       		db.RollbackUnlessCommitted()
       		return nil, err
       	}
       	db = db.Commit()
       	return gql, db.Error
       }
      
       func postDelete(r *mutationResolver, id string) (bool, error) {
       	whereID := "id = ?"
       	// Convert id to uuid.UUID from string
       	convertedID, err := uuid.FromString(id)
       	if err != nil {
       		return false, err
       	}
       	// Create scoped clean db interface
       	db := r.ORM.DB.New().Begin()
       	// Find the post
       	dbPost := &dbm.Post{}
       	err = db.Where(whereID, convertedID).First(dbPost).Error
       	if err != nil {
       		return false, err
       	}
       	// Delete the post
       	if err := db.Delete(dbPost).Error; err != nil {
       		db.RollbackUnlessCommitted()
       		return false, err
       	}
       	db = db.Commit()
       	return true, nil
       }
      
       func postList(r *queryResolver, id *string) (*models.Posts, error) {
       	entity := "posts"
       	whereID := "id = ?"
       	record := &models.Posts{}
       	dbRecords := []*dbm.Post{}
       	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.DBPostToGQLPost(dbRec); err != nil {
       			log.Println(entity, err)
       		} else {
       			// get the post's owner
       			dbUser := &dbm.User{}
       			db.Where(whereID, dbRec.UserID).First(dbUser)
       			if gqlUser, err := tf.DBUserToGQLUser(dbUser); err != nil {
       				log.Println(entity, err)
       			} else {
       				rec.User = gqlUser
       			}
      
       			record.List = append(record.List, rec)
       		}
       	}
       	return record, db.Error
       }
      
       // internal/gql/resolvers/user.go
       package resolvers
      
       import (
       	"context"
       	"log"
      
       	"github.com/gofrs/uuid"
       	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"
       )
      
       // THIS CODE IS A STARTING POINT ONLY. IT WILL NOT BE UPDATED WITH SCHEMA CHANGES.
      
       // 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) {
       	whereID := "id = ?"
       	// Convert id to uuid.UUID from string
       	convertedID, err := uuid.FromString(id)
       	if err != nil {
       		return false, err
       	}
       	// Create scoped clean db interface
       	db := r.ORM.DB.New().Begin()
       	// Find the user
       	dbUser := &dbm.User{}
       	err = db.Where(whereID, convertedID).First(dbUser).Error
       	if err != nil {
       		return false, err
       	}
       	// Find the user's posts
       	dbPosts := []*dbm.Post{}
       	db.Model(&dbUser).Related(&dbPosts, "Posts")
       	// Delete posts
       	for _, dbPost := range dbPosts {
       		if err := db.Delete(dbPost).Error; err != nil {
       			db.RollbackUnlessCommitted()
       			return false, err
       		}
       	}
       	// Delete the user
       	if err := db.Delete(dbUser).Error; err != nil {
       		db.RollbackUnlessCommitted()
       		return false, err
       	}
       	db = db.Commit()
       	return true, 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 {
       			// get user's posts
       			dbPosts := []*dbm.Post{}
       			db.Model(&dbRec).Related(&dbPosts, "Posts")
       			for _, dbPost := range dbPosts {
       				if gqlPost, err := tf.DBPostToGQLPost(dbPost); err != nil {
       					log.Println("posts", err)
       				} else {
       					rec.Posts = append(rec.Posts, gqlPost)
       				}
       			}
       			record.List = append(record.List, rec)
       		}
       	}
       	return record, db.Error
       }
      

測試 API Server

$ scripts/run.sh

  1. 新增 user

     mutation {
       createUser (
         input: {
         	name: "Leo"
           email: "wtlin1228@gmail.com"
           userId: "user-007"
           displayName: "唐唐"
         }
       ) {
         id
     		email
     		createdAt
       }
     }
    
  2. 新增 post

     mutation {
       createPost (input: {
         title: "今天是鐵人賽第三天"
         content: "覺得開心"
         userId: "3220bb83-59ec-44f4-9568-7b1d240bc783"
       }) {
         title
         content
         createdAt
       }
     }
    
  3. 列出 post

     query {
       allPosts: posts {
         count,
         list {
           id
           title
           content
         }
       }
     }
    
  4. 列出 post 以及這篇 post 的擁有者

     query {
       allPosts: posts {
         count,
         list {
           id
           title
           content
           user {
             id
             email
             description
           }
         }
       }
     }
    
  5. 列出 user 和 user 的 post

     query {
     	allUsers: users {
         count,
         list {
           id
           email
     	  userId
     	  name
     	  firstName
     	  lastName
     	  nickName
     	  description
     	  location
     	  createdAt
     	  updatedAt
           posts {
             id
             title
             content
           }
         }
       }
     }
    
  6. 刪除 post

     mutation {
       deletePost(
         id: "c0a66568-ea81-4f56-ac8e-dc2cba285539"
       )
     }
    
  7. 刪除 user 和屬於這個 user 的 post

    只需要刪除 user,屬於他的 posts 就會一起被刪除

     mutation {
       deleteUser(
         id: "b557d7af-c65e-48bd-9e4d-f82f6ea54d80"
       )
     }
    

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

尚未有邦友留言

立即登入留言