2021年4月2日 星期五

Golang: Pattern for Building a GraphQL System and the Part of Caution

本文記載於 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
}

然後在該同 package 目錄下,會有各式各樣的 go 會依照以下方式實作:
  1. 定義 gorm 需要的 struct
  2. struct 的 func 需要定義 TableName() 給 gorm
  3. struct 的 func 開始寫需要存取 CRUD 純資料存取的功能
最主要的核心哲學,就是 Entity 應該保持乾淨,盡量沒有商業邏輯的牽扯。

隨便舉例一個資料庫 Table 的 CRUD 如何按照 Entity Pattern 實作 (/entity/classroom.go) :
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;

*按照這樣的哲學 Pattern,你不應該在 Entity 中的 CRUD Functions 存取同樣為 Entity 的 Functions,請使用 JOIN 之類的方法帶出符合 Entity-Struct 定義的資料。

*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,這整套方案如下:

  1. 使用一個可以提供 Migration 機制的服務,像是 Go-Migrations,他會在資料庫做一個表,用於紀錄目前版本,以及在 Migration 前是否有資料 (dirty 欄位) 的紀錄。
  2. 手動寫一個腳本,有兩個 function: up, down,意思就是 migration 升級此版,up 基本上要手寫所有變更的 SQL Scripts,包含 ALTER TABLE MODIFY COLUMN 至細微項目; down 則是如果 up 後想要回復,必須提供刪除新版退回舊版的機制。
  3.  up 的資料沒有預設值也沒有關係
  4.  若要使用 auto-migration,要確定該服務是否有對 column 建立 Index, Constraint。
所以 Migration 基本上就可以做到:
  1. Upgrade Database
  2. Rollback
  3. History
注意,如果您正在維護的系統中,大致上確認系統資料庫已經是 Migration 的新版,但是卻沒有 Migration 紀錄,或是遺失 Migration 紀錄、部分 Migration 紀錄,那您應該不要再次跑 Migration,而是使用此版本作為第一版,再日後才開始繼續做 Migration,但須要記得,如果發現專案有 Migration 腳本,並且是遺失紀錄的情況下,建議可以手動在資料庫一個個補上,這樣才能讓接手維護的下一版 Migrations 正常執行。

您可能要著手先了解 Legacy Migrations 在資料庫是否有留下 Migration Record Table,再檢查完整性,才開始動手修資料。

*小提醒: 您需要記得維護舊系統資料庫時,資料的可信度一般來說都是最低的,資料瑕疵造成您判斷的問題時常出現,出問題時應該先懷疑資料的正確性。


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)
}


*單元、整合測試的起點


開始維護舊系統、甚至在這樣的 Pattern 中,需要有測試才能保證原有系統的穩定性,避免我們新增一個功能後導致原有系統的功能無法正常運作,可是我們又沒有時間寫這麼多測試,這讓我們不禁思考要從哪邊測試才是最佳的方案。

依照歸納方式, Entity-Struct 在架構哲學上已經是當作個體看待,對於一個單純 CRUD 的資源,除了 JOIN 寫錯以外,事實上不太可能再出錯,於是如果有時間的話,可以對 Entity-Struct 做 Unit-Test (需要先做 DB Seeding)。

而我們定義商業邏輯都會在 Model 中出現,於是 Model 是最需要被測試的內容,而且 Model 最大的問題是它混了大量的商務邏輯,導致他們很難只做 Unit Test,因此必須取捨哪些功能才需要 Unit Test,或是全部都不做 Unit Test。

以混亂度非常高且不可避免之的 Model 而言,選擇做 Integration Test 整合測試可能是暫時的最佳解,從 Integration Test 做起驗證商業邏輯正確性,增加 Model 測試覆蓋率。

在做 Integrations 之前,我們要對 Database 做初始假資料處理,叫做 DB Seeding ,它的處理方式因人而異,你可以直接開成共享全域變數在 test 中,或是在資料庫直接插入好初始值。

你的 Unit Test 可以這樣設定所有 Model 都能被單測的 Pattern (/model/classroom_test.go):
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
}

此時如果需要一個 Integration Test,那麼它的 Pattern 可能會長這樣 (/model/model_test.go):
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
}

上面是主要的測試 main ,接著示範 classroom 單一做整合測試 (/model/classroom_test.go):
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 就只扮演轉介資料的角色,在核心理念的擴展中,它基本上只被允許做這幾件事:

  1. 輸入資料的轉介型態,因為 gqlgen 的 struct 和 entity-struct 是不一樣的,可以直接把 input 轉成 json ,再把 json 轉成 entity-struct,也可以用 reflection 映射的方式處理、或 map 的方式處理。
  2. 輸入資料 pointer 轉換、 enum pointer 轉換、options-struct 轉換
  3. 自動帶入 JWT Authenticate Context 的 User.id 等
  4. 輸出如果要把 classroomId 這種欄位,轉成符合 GraphQL 精神的 Classroom {} struct,可以在這邊一個一個回查、再帶出去
  5. 不可以因為 GraphQL 的格式精神,影響 Model 要輸出的結果,比方說 4. 的內容就盡量不應該再 Model 中 Patch 加上去,除非一開始就有必要的考量帶出 Struct 在 Data 中。

*注意 EMG 不應該引起原架構的改變,因此如果你需要從 classroomId 帶出整個 classroom,甚至又是陣列,那應該在 resolver 跑回圈一個一個帶出來到 GraphQL 的 struct,盡量不要影響 Model 要輸出的內容,這也正是符合EMG 原則。


GraphQL-Resolver (gqlgen) 相容進 EM 模式的 Pattern,你會看到這樣的開頭範例,與 package graph 同一個目錄,有個 (graph/resolver.go):
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
}


這個 resolver.go 原本只是一個空的 struct,用來給予 graphql query, mutation 掛在 resolver 底下的,可是我們還是可以帶入自己想要的東西,把 EM 模式串進 Resolver 變成 EMG。

在 Resolver 這邊,用一個終端存取的範例告知你在 gqlgen 出來的 (graph/schema.resolvers.go) 中,它是長什麼樣子:
/* 
	
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 }


*再次補充,G 這層只做資料輸入處理、輸出處理,輸入就包含帶出 Context JWT 中使用者的 id,不做邏輯處理,因為這會造成邏輯分散,邏輯分散就需要花心思重新做測試。



HTTP 服務整合 EMG, JWT With Context
在 HTTP 端事實上也應該被當作獨立的模組處理,所以這並不是 Main,而是要開啟對 Resolver 的接入點,假設不是使用 Resolver 串 EMG 架構,是使用 REST-API 串 EMR 架構,也是同理在這一層做接入,以 EMG GraphQL 為例子,一個 WSGI Server 這個模組會長像這樣 (/server/server.go):
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,
	}
}


接著,我們需要設計一個 Middleware 做 JWT 管控 (只有驗證,沒有發驗證的功能) /middleware/middleware.go:
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
}


完成後,最後要在 / 目錄下建立一個 router.go (也可以自己選地方放) 層級的服務去處理 HTTP Request Router 相關事務,他會長這樣:
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
}

其中如果在多服務的伺服器上,使用 /prefix/suffix 去區分, 對於使用 nginx 的服務會比較好控制。  (e.g: /prefix/suffix/query, /prefix/suffix/mutation)

最後需要一個 main function 去啟動這些服務,他的模式大概就是長這樣 main.go:
// 載入設定檔案。
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))


在 main 統整的最後一刻,呈現了 EMG, EMR(REST API), 以及 JWT, Middleware With Context 是如何被傳進去整套服務 Pattern 的。


要稍微注意 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

沒有留言:

張貼留言

© Mac Taylor, 歡迎自由轉貼。
Background Email Pattern by Toby Elliott
Since 2014