本文記載於 2021/03/31 ,不同的邏輯可能會因為時代的不同而變化,欲要使用 Golang 打造一個具有 GraphQL 和 REST-API 並存的 Server,可以參考本篇文章的做法。
文章摘自現階段工作中已執行中的軟體架構,在工作中, Golang 扮演接管 Legacy 系統部分新功能的角色,簡單的說,這個工作是維護一個沒有文件、說明、 git 的龐大軟體架構,而目前的工作階段總共有 3 種不同的程式語言在接管維護這個龐大的系統,如此段所述, 會使用 Golang 接續 Legacy 舊系統的新功能開發。
然而,本項工作是完全的 GraphQL,所以勢必在團隊溝通上,選擇使用 GraphQL 作為 API 接入口是一個比較好的選擇,以下探討現在是如何實現這樣的架構 Pattern,並且陳列需要考量的點,若有優缺點、效能上的考量,或許未來可以再新的文章中做討論,不在本篇討論範圍。
首先,必須先散列專案中主要的主題以及使用的技術,然後再從目錄結構的方式從旁了解架構設計的本質。
- Simple HTTP Server (go-chi)
- Middleware JWT Authenticate
- CORS
- REST API
- GraphQL
- Database Entity / Model Pattern (E<-M<-G 模式)
- GORM
- Go-Migrations
- Logging
- GQL Resolver Model
- mirror feature func
- nullable pointer helper func
- passed EM Model
* E<-M 模式,是指在這個系統中,沒有外部 API 呼叫,只從 Go 裡面自己測試呼叫這個 Pattern 的資料,是從 Model (M) 開始呼叫,由 M 裡面去執行 Entity(E) 的行為作處理後丟回 M,M 再丟回給呼叫者。
* E<-M<-G 模式,是指上一個模式中,呼叫者可以是 GraphQL 的 Resolver 機制,統稱 (G),也就是作為一個外部的進入點。 (此模式以此類推可以延展,思維是 Entity 一定是作為單一資源訪問的形式處理任何邏輯,但效能不見得好。)
以流程來說, G(GraphQL Resolvers) 是最先被 HTTP 服務流入的服務,然後 G 再去呼叫 M 再去呼叫 E。
EM 模式的實現
Entity 模式
首先是 Entity 模型的實現,對於 Entity 來說,你會有一個最主要的 /entity/entity.go ,會放入通用內容:
package entity type DB struct { Gorm *gorm.DB } func New(config config.Config) *DB func (d *DB) Migrate() error // 統一化 Query 都會幫你把資料映射到 mirror 中 func (d *DB) GenQuery(query string, mirror interface{}) error // 統一化 Execute 都只會做事,不會回傳任何東西 func (d *DB) GenExec(query string) error // 統一化 Insert 都會給出插入後的 LAST_INSERT_ID func (d *DB) GenInsert(query string) (lastInsertID int, err error) // String 是 helper,會將值升級為 pointer func String(v string) *string { return &v } // Integer 是 helper,會將值升級為 pointer func Integer(v int) *int { return &v } // Time 是 helper,會將值升級為 pointer func Time(v time.Time) *time.Time { return &v }
- 定義 gorm 需要的 struct
- struct 的 func 需要定義 TableName() 給 gorm
- struct 的 func 開始寫需要存取 CRUD 純資料存取的功能
package entity // 定義一個 Database Table 也是定義資源類型 type Classroom struct { ID int `db:"id" gorm:"primaryKey;autoIncrement;column:id;type:int;" json:"id"` ClassName string `db:"className" gorm:"column:className;varchar(255)" json:"className"` CreatedAt time.Time `db:"createdAt" gorm:"column:createdAt" json:"createdAt"` DeletedAt *time.Time `db:"deletedAt" gorm:"column:deletedAt" json:"deletedAt,omitempty"` } // Gorm 所需要使用的 TableName() 才能做 auto-migrate func (c *Classroom) TableName() string { return "Classroom" } // 定義自己要的行為,但是是掛在 DB 底下 (這樣才能存取 Passed Model) func (d *DB) CreateClassroom(name string) (id int, err error); // 定義查詢條件,讓結構體不是 pointer,但內容可以是 pointer 令值為空 type ClassroomQueryOption struct { ID *int //可能是空的,表示不查 ClassName *string //可能是空的,表示不查 CreatedAt *time.Time //可能是空的,表示不查 DeletedAt *time.Time //可能是空的,表示不查 } func (d *DB) ListClassroom(opts ClassroomQueryOption) (id int, err error); // 取得單一資源,回傳 entity-struct,不要回傳除了基本資料、 entity-struct 以外的資源 struct func (d *DB) GetClassroom(id int) (c Classroom, err error); // 刪除單一資源 func (d *DB) DeleteClassroom(id int) error;
*null 值問題處理
在上述的例子中,你會發現可以留資料庫 null 欄位的變數,都是使用指標。
在市面上常見的做法有使用 gorm 的 null type (e.g: sql.NullString, sql.NullTime) [1],也有使用第三方的 guregu/null [2] 來當作型別,更常見的是以上使用 pointer 表示可能為空值的變數,各有利弊,使用 pointer 缺點就是帶值很麻煩,必須要新增變數,再賦予指標。
由於 Golang 相容 C++ ,沒有特別讓資料型別預設值為 nil,於是資料庫的 null 值問題將會造成 Golang 一點小麻煩,因為用 nil 強制丟到 Golang 型別,也只會得到該預設值,所以沒有指標的 int a 被資料庫丟一記 nil 之後,可能會產生 a 是 0 的結果,可是你必須知道 a = 0 不等價於 a 為 nil。
所以資料庫是否為空的判斷一定要使用 pointer 來獨立判斷 nil,而不是判斷初始值,像是 if a != nil { ... } 。
即便 DB, Program null 是 40 年前就有的問題,至今也需要使用對自己更有利的做法處理。
對 Golang 而言,也許有部分的人恨透使用 Pointer 變數,會讓專案完全避免 Null 值,此點可以斟酌考量,不一定有誰對誰錯的問題,而是在於這個精神、設計原則、慣例是否可以帶來比弊點更強大的好處。
*資料庫 Migrations
Entity 模式中,因為資料庫整合 gorm,既然使用 ORM ,那麼系統也可以更自動化地處理 Migrations,不過問題似乎在於,gorm 的 auto-migrations 只能加上新 Table 而無法減去 Table、修改 Column,於是你必須手動建置一個完整的 Migrations,這整套方案如下:
- 使用一個可以提供 Migration 機制的服務,像是 Go-Migrations,他會在資料庫做一個表,用於紀錄目前版本,以及在 Migration 前是否有資料 (dirty 欄位) 的紀錄。
- 手動寫一個腳本,有兩個 function: up, down,意思就是 migration 升級此版,up 基本上要手寫所有變更的 SQL Scripts,包含 ALTER TABLE MODIFY COLUMN 至細微項目; down 則是如果 up 後想要回復,必須提供刪除新版退回舊版的機制。
- up 的資料沒有預設值也沒有關係
- 若要使用 auto-migration,要確定該服務是否有對 column 建立 Index, Constraint。
- Upgrade Database
- Rollback
- History
*小提醒: 您需要記得維護舊系統資料庫時,資料的可信度一般來說都是最低的,資料瑕疵造成您判斷的問題時常出現,出問題時應該先懷疑資料的正確性。
Model 模式
首先是 Model 模式的實現, Model 的設計考量點相對於 Enity 來說已經小很多,以下是 /model/model.go:
package model // Model 會處理所有商業邏輯的表中層 type Model struct { // DB 是被新增出來的 DB Entity,它的資料庫連線此時應該是開好的。 DB *entity.DB // Config 是常見被帶入 Config 設定的物件,可以保留給您決定。 Config config.Config } // 使用 New 幫你帶出實體化的 Model 物件,供存取商業邏輯 func New(d *entity.DB, conf config.Config) *Model { return &Model{d, conf} }
然後,任意一個同 model package 底下的 model,會用於存取商業邏輯,做運算、處理、整合其他 DB-CRUD-Functions,例如對 Classroom 的操作: /model/classroom.go:
package model func (m *Model) CreateClassroom(name string) (id int, err error){ // 把呼叫的資源 pass 給 DB-Entity Fuction id, err := m.DB.CreateClassroom(name) if err != nil { return 0, err } //.... 整合其他商務邏輯 // 比方說判斷 // 比方說牽扯好幾種服務的邏輯呼叫,都在這邊 } // 回傳有可能還是 Entity-Struct 的樣子,那麼就直接帶出 func (m *Model) GetClassroom(id int) (entity.Classroom, error){ return m.DB.GetClassroom(id) }
*單元、整合測試的起點
package model import ( "testing" "github.com/stretchr/testify/assert" ) // classroom 是單元測試共享的資料。 var classroom entity.Classroom var m *Model // 初始化本測試需要的環境,每一次針對單 function unit test 都可被載入,所以其他檔案有重複 init 其實不影響。 func init() { // 把設定檔案帶進來 conf := config.New() // 設定資料庫連線, DB-Entity d := entity.New(conf) // 先 migrate d.Migrate() // 先取得一個已經有 db 設定好的商務邏輯 model 物件 m = New(d, conf) // Model 中的 New } func TestCreateClassroom(t *testing.T) { classroom = entity.Classroom{ ClassName: "TEST CLASS", CreatedAt: time.Now(), } a := assert.New(t) cid, err := m.CreateClassroom(classroom) a.NoError(err) a.NotZero(cid) // 設定 global 變數為剛才插入後的值 classroom.ID = cid }
package model var m *Model // 初始化本測試需要的環境 func setupTest() { // 把設定檔案帶進來 conf := config.New() // 設定資料庫連線, DB-Entity d := entity.New(conf) // 先 migrate d.Migrate() // 先取得一個已經有 db 設定好的商務邏輯 model 物件 m = New(d, conf) //Model 中的 New }
type ClassroomSuite struct { // 繼承 suite struct suite.Suite //可以塞一些要被共用的資料: classroom entity.Classroom } // SetupSuite func (cs *ClassroomSuite) SetupSuite() { setupTest() } func (cs *ClassroomSuite) CreateClassroom(t *testing.T) { classroom = entity.Classroom{ ClassName: "TEST CLASS", CreatedAt: time.Now(), } cid, err := m.CreateClassroom(classroom) s.NoError(err) s.NotZero(cid) // 設定 global 變數為剛才插入後的值 s.classroom.ID = cid } // TestAll (Integration Test) func (cs *ClassroomSuite) TestClassroomSuite() { // 有先後順序的跑 s.Run("CreateClassroomSuite", s.CreateClassroom) // 再跑 s.Run(.......) } // 這是要優先被執行測試的主體進入點 func TestClassroomSuite(t *testing.T) { suite.Run(t, new(ClassroomSuite)) }
*資料庫 seeding 可以設定要 truncate, drop table 也可以不要,建議兩者都可以留存供測試者選擇。
EM 模式接入 G (EMG)
現在,我們要開始探討 GraphQL 究竟要如何接入目前的 EM 模式,而且可以完美抽換這個邏輯呢?
首先, GraphQL 最後產生出來的 Resolver Function,它在相容 EM 模式裡,是一個呼叫 Model 商務邏輯的角色,它是透過 Web GraphQL 請求,由 Resolver 去呼叫那些商務邏輯後,把回傳資料轉成 GraphQL 能接受的格式,然後再轉手給前端。
也就是說, GraphQL Resolver 就只扮演轉介資料的角色,在核心理念的擴展中,它基本上只被允許做這幾件事:
- 輸入資料的轉介型態,因為 gqlgen 的 struct 和 entity-struct 是不一樣的,可以直接把 input 轉成 json ,再把 json 轉成 entity-struct,也可以用 reflection 映射的方式處理、或 map 的方式處理。
- 輸入資料 pointer 轉換、 enum pointer 轉換、options-struct 轉換
- 自動帶入 JWT Authenticate Context 的 User.id 等
- 輸出如果要把 classroomId 這種欄位,轉成符合 GraphQL 精神的 Classroom {} struct,可以在這邊一個一個回查、再帶出去
- 不可以因為 GraphQL 的格式精神,影響 Model 要輸出的結果,比方說 4. 的內容就盡量不應該再 Model 中 Patch 加上去,除非一開始就有必要的考量帶出 Struct 在 Data 中。
import ( "encoding/json" ) // This file will not be regenerated automatically. // // It serves as dependency injection for your app, add any dependencies you require here. // 原本給的 Resolver,在上面做我們的 EM 架構延伸,帶入 EM 的 Model(with Entity) type Resolver struct { model *model.Model } // 把 Resolver 建立物件出來 func New(model *model.Model) *Resolver { return &Resolver{ model, } } // 幫忙把兩個不同的 struct 做轉換 func refl(src interface{}, dest interface{}) error { marshaledJSON, err := json.Marshal(src) if err != nil { return err } err = json.Unmarshal(marshaledJSON, &dest) if err != nil { return err } return nil }
/* GraphQL: schema.gql scalar Time type Mutation { createClassroom(input: CreateClassroomInput!) } type Query { listClassroom(studentId: Int!): [Classroom!] } type Classroom { id: Int! className: string! createdAt: Time! deletedAt: Time! } input CreateClassroomInput { className: string! createdAt: Time! creatorUserId: ID! } */ package graph // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. import ( "context" "time" ) func (r *mutationResolver) CreateClassroom(ctx context.Context, input schema.CreateClassroomInput) (bool, error) { var classroom entity.Classroom err := refl(input, &classroom) // 把 GQL.INPUT 映射到 entity.classroom if err != nil { return false, err } // 從 JWT Authentication 帶出 Context 補完資訊,是符合 G 這層應該做的行為 if input.creatorUserId == nil { user := middleware.ForContext(ctx) classroom.creatorUserId = user.ID } // 執行 model 商業邏輯的 CreateClassroom 方法 cid, err = r.model.CreateClassroom(classroom) if err != nil { return false, err } // 把方法帶回 local-var classroom.ID = cid // reflect ,把 entity-classroom 轉換輸出成 gql-schema-classroom 格式 var out schema.Classroom err = refl(classroom, &out) return true, err } // 這是給 GQL 工具用的,唯有我們需要 Resolver 帶入 Resolver 的 model // Mutation returns schema.MutationResolver implementation. func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} } // 這是給 GQL 工具用的,唯有我們需要 Resolver 帶入 Resolver 的 model // Query returns schema.QueryResolver implementation. func (r *Resolver) Query() schema.QueryResolver { return &queryResolver{r} } // 這是給 GQL 工具用的,唯有 mutationRes, queryRes 要帶入我們自己訂的 Resolver 工具型態繼承 type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver }
package server import ( "cloud.google.com/go/storage" ) /* 根據 EMG 架構,從 WebServer 進入的點應該是終端層的 G,在 Server 可以取捨要帶入什麼服務 */ type Server struct { // DB 是 DB 相關的服務,你不一定會用到, // 因為給出下面 Resolver 的時候,本身已經有設定好連線的 Model, DB-Entity 在內了。 // DB *entity.DB // EMG 的 G 介面端,也可以是 REST-API 的 R 介面端 (總之你會 pass Model 到 Resolver 再 pass 過來) Resolver *graph.Resolver // GCP Client 也許在共用 Router 會用到 // GCP *storage.Client } func New(r *graph.Resolver/*, d *entity.DB, g *storage.Client*/) *Server { return &Server{ //DB: d, Resolver: r, //GCP: g, } }
package middleware import ( "context" "fmt" "net/http" "strings" "bitbucket.org/superbarkingdog/mmagymgo/config" "github.com/dgrijalva/jwt-go" ) // A private key for context that only this package can access. This is important // to prevent collisions between different context uses var userCtxKey = &contextKey{"user"} type contextKey struct { name string } // A stand-in for our database backed user object type User struct { Name string `json:"name"` UserId int `json:"staffId"` EXP int `json:"exp"` IAT int `json:"iat"` Permissions []interface{} `json:"permissions"` Role string `json:"role"` jwt.StandardClaims } func getTokenFromRequest(r *http.Request) string { // first, fetch token from the `access_token` cookie if c, err := r.Cookie("access_token"); err == nil { if c.Value != "" { return string(c.Value) } } // if it's not there, check in the Bearer token if substrings := strings.Split(r.Header.Get("Authorization"), "Bearer "); len(substrings) == 2 { return substrings[1] } return "" } func respHelper(w http.ResponseWriter, msg string) { w.WriteHeader(403) w.Header().Add("Content-Type", "application/json") w.Write([]byte(msg)) } var ACCSEC = "https://youtube.com/watch?fsdfsdfsdfdsfdsds" func JWTAuthMiddleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var token string = getTokenFromRequest(r) // Check token is exist in header if token == "" { respHelper(w, "no access token provided") return } t, err := jwt.ParseWithClaims(token, &User{}, func(payload *jwt.Token) (interface{}, error) { if _, ok := payload.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("Unexpected signing algorithm: %v", payload.Header["alg"]) } return []byte(ACCSEC), nil }) if err != nil || !t.Valid { respHelper(w, "JWT verification failed") return } user, ok := t.Claims.(*User) if !ok { respHelper(w, "claims failed") return } // set user jwt to context ctx := context.Background() ctx = context.WithValue(ctx, userCtxKey, user) // bring ctx to req r = r.WithContext(ctx) next.ServeHTTP(w, r) }) } } // ForContext finds the user from the context. REQUIRES Middleware to have run. func ForContext(ctx context.Context) *User { raw, _ := ctx.Value(userCtxKey).(*User) return raw }
package main import ( "io" "net/http" "net/url" "cloud.google.com/go/storage" "github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/playground" chi "github.com/go-chi/chi/v5" md "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" ) // 初始化帶入 GraphQL 的 Chi Router。 func NewRouter(/*d *entity.DB, */s *server.Server) *chi.Mux { router := chi.NewRouter() router.Use(md.Logger) router.Use(md.RequestID) router.Use(md.RealIP) router.Use(md.Logger) router.Use(md.Recoverer) // Basic CORS router.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"*"}, // Use this to allow specific origin hosts //AllowedOrigins: []string{"https://*", "http://*"}, //AllowOriginFunc: func(r *http.Request, origin string) bool { return true }, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, })) // 將需要保護的路由器分別 group router.Group(func(r chi.Router) { // 帶入 middleware 處理這個區域的路由 r.Use(middleware.JWTAuthMiddleware()) serv := handler.NewDefaultServer(schema.NewExecutableSchema(NewGraphQL(s))) r.Handle("/go-service/gql", serv) }) router.Get("/go-service/gql", playground.Handler("query", "/go-service/gql")) // GCP Router for file uploads // router.Post("/uploads", GCPFileUploader(s.GCP, "BUCKET_NAME")) return router }
// 載入設定檔案。 conf := config.New() // 載入 GCP Client gcp, err := storage.NewClient(context.Background(), option.WithCredentialsFile(conf.GCPApplicationCredentials)) if err != nil { //log.Fatalln("Faild to open GCP Storage.") fmt.Println("Failed to open GCP Storage.") } // 初始化資料庫 d := entity.New(conf) // 帶有設定好資料庫連線的商業邏輯 m := model.New(d, conf) // EMG 架構 (純 GraphQL 服務) // 傳入帶有資料庫的商業邏輯的 GraphQL Resolver 進入 Server s := server.New(graph.NewResolver(m)/*, d, gcp*/) // EMR 架構 (純 REST-API 服務) // restapi := restapi.New(m) // go restapi.Run(":8080") // 啟動整套服務 http.ListenAndServe(":1234", NewRouter(/*d, */s))
要稍微注意 GraphQL 這一邊,因為 Date 的格式不是所有服務都能識別,所以在前端使用 Time GraphQL types 的時候,可以轉成 ISO String 用 String 傳進來: new Date().toISOString()。
以上說明是針對前端使用 Query, Mutation 要定義 Date 型別時,可以這麼使用:
query Salary($yearMonth: Time) { ... }
然後定義 Typescript 該 yearMonth 型別的時候,使用 string 格式:
export interface SalaryQueryVariables {
yearMonth: string;
}
而在 Golang 端的 GraphQL 一樣也是使用 Time,而且自動就可以被轉成 time.Time 格式了。
Type 的另外一個問題是 ID,如果在資料庫會帶出 id 或使用 ID 查詢,可以直接使用 ID (string) 作為 GraphQL 的 type,如此前端傳入 int, string 都會被統一化。
總結,這樣的目錄結構會像是:
entity/
├─ entity.go
├─ classroom.go
model/
├─ classroom.go
├─ model.go
config/
├─ config.go
restapi/
graphql/
├─ schema.resolver.go
├─ resolver.go
middleware/
├─ middleware.go
server/
├─ server.go
main.go
router
跨系統整併 GraphQL Schema 的大挑戰
如今,一套 Legacy 系統被停止維護,接手的新系統也許不一定是同一個程式語言,這麼一來架構上想共用 ORM 就會變得不容易,因此如果是這樣的情形: PersonName 姓名存在 A 系統, B 系統只有 PersonId ,那麼 B 如果作為 GraphQL 伺服器,你可能需要建立與 A 系統一樣的 ORM Typing,並且回傳這些資料,如果 A 系統的 ORM Typing 太多,B 系統可以取捨是否要帶出這麼完整的資料。
Reference:
[1]: https://gorm.io/docs/models.html
[2]: https://github.com/guregu/null/issues
https://medium.com/@victorsteven/understanding-unit-and-integrationtesting-in-golang-ba60becb778d
https://github.com/carprice-tech/migorm
沒有留言:
張貼留言