本文記載於 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
沒有留言:
張貼留言