Droplet——一款輕量的Golang應用層框架

2022-10-27 18:01:11

Github地址

如標題所描述的,Droplet 是一個 輕量中間層框架,何為中間層呢?
通常來說,我們的程式(注意這裡我們僅僅討論程式的範圍,而非作為一個系統,因此這裡不設計如 LB、Gateway、Mesh等內容,因為它們都處於程式以外)按不同的職責可以分為不同的層次,而按照不同的設計風格,常見的如下:

  • 三層架構:UIL(UserInterfaceLayer), BLL(BusinessLogicLayer), DAL(DataAccessLayer)
  • DDD分層架構(參考ddd-oriented-microservice):ApplicationLayer,DomainLayer,InfrastructureLayer
  • 洋蔥架構(參考Onion Architecture ):Application, Infrastructure, ApplicationService, DomainService, DomainModel。

Tips

洋蔥架構其實也是基於DDD的,它是DDD分層架構的升級版本。

但是今天我想用於解釋中間層的架構並非以上的任何一種,它也源自於DDD的分層架構,不過我配合了六邊形架構來說明它,分層圖如下:

在六邊形架構中有個規則:依賴只能是由外部指向內部。
因此從外層到最內層分別是:

分層 職責
Access 程式的接入層(在六邊形架構中這被稱為輸入介面卡),通常位於整個請求 or 任務的起點,它可能是某種Web框架,也可能是一些佇列的消費框架等。
Application 程式應用層,包含了一些非業務的邏輯,如:業務邏輯的編排、引數繫結、校驗、請求紀錄檔、鏈路上報、狀態讀取等等
Domain & Utils 在最中心的地方我放入了兩個層次描述:Domain 與 Utils,這兩個分層都應該是位於依賴的最底層,意味著他們不應該參照本專案的其他層次。Domain層主要包含核心的業務邏輯,而Utils則是一些程式任何地方都可能會參照的程式碼段,比如常數定義、資料結構和語法糖等等
Infrastructure 基礎設施層(在六邊形架構中這被稱為輸出介面卡),程式所有需要對外進行資訊交換 or 功能依賴時都會放置在這一層實現,通常來說這些功能都是被依賴的那部分,因此我們如果要滿足依賴約束的話,這裡必須要引入 DIP(Dependency inversion principle),即在Application、Domain中定義依賴,而 Infrastructure 來實現它們,這樣保證了它們是可被替換的

六邊形架構優點在於解耦程式中業務無關的部分,以保證它們都是可被替換與擴充套件的
而 Droplet 就工作在 Application 層,它的核心能力只有一個:提供基於pipeline的請求/響應處理能力
可能有人會疑問,幾乎每個框架都會實現類似的能力,為什麼我們需要 Droplet 呢?
別急,我們來看看這些框架自帶的 pipeline/middleware 存在什麼弊端。
根據上面的架構圖我們可以知道諸如 gin、go-restful、fasthttp 之類的http框架都是工作在 Access 層,因此框架自帶的 pipeline/middleware 存在以下兩個弊端:

  1. 框架繫結: 這個很容易理解,這些機制只能工作於特定的框架下,如果切換框架則需要需要調整程式碼,除了中介軟體的程式碼外,我時常也會見到程式在 API Handler 中耦合了大量框架相關的程式碼,比如:讀取引數(header, query, body等)、根據業務結果回寫響應等,這些程式碼滲透到了業務程式中(有時它們甚至會比業務程式碼佔用了更多的行數),這加大了業務開發同學的維護成本,同時也降低了程式的可延伸性。

一些相關的BadCase

想象一下:

  • 你一直在使用 gin,但是有一天運營拿著資料找到你,說機器佔用的成本太高了,而你發現只要切換到 fasthttp 就能為你帶來更高的效能,但是從 gin -> fasthttp 你需要調整大量的 API handler 程式碼,這可太讓人頭疼了。
  • API handler中充斥了諸如 param, ok := req.Query("param") / param, ok := req.Header.Get("param") / err := xxx.Bind(req, &param) 之類的程式碼,這和業務毫無關係
  1. 沒有請求/響應的結構化實體: 如果有開發過這些框架中介軟體的同學一定知道,大部分框架中介軟體的協定定義都是以 http.Request/httpResponse 為主體的,這意味著如果不做任何前置處理,你只能通過位元組陣列來感知 請求與響應 這在部分場景都不太方便,比如:根據請求、響應的結構體是否具備某些特徵(比如介面)來執行某些特定的業務通用邏輯;又或者想在中介軟體中融入一些自動化的引數校驗邏輯,因為你沒有一個具體的結構化物件;再或者你不想要在每一個 API handler 中去設定一個響應的 Wrapper(通常它類似於 {code: 0, msg: "", data:{}}),想要在中介軟體去自動包裝上它,也很難執行;最後就是——如果只依靠 http.Request/httpResponse,你也難在中介軟體感知到其他參與者的處理狀態,。

相信我說的這些問題,使用過的同學應該都有所感觸,而這些問題並非難以解決,它們中的大部分基本都是可以通過自行建立一套約定來得以緩解(比如將這些資訊都通過 context 去獲取),而 Droplet 也是誕生於我在過往團隊中去克服這些問題的實踐之中,是一個相對可靠的實現。

工作原理

帶著上面提到的這些問題,我們來看看 Droplet 的工作原理是怎樣的,如下圖所示:

如我所說的那樣,Droplet 的核心在於 提供基於pipeline的請求/響應處理能力,因此我們可以看見這個圖中涉及的所有模組都是基於 pipleline,可以說 Droplet 的所有能力都是由其擴充套件而來。
這裡我們先介紹下圖中出現的幾個中介軟體(Middleware,這是組成 pipepine 關鍵元素):

  1. HttpInfoInjector: 注入 http 相關的一些資訊,如 requestid, http.Request 等
  2. RespReshape: 根據 handler 的響應結果來進行一些調整,包括:發生錯誤時設定上預設的錯誤碼、錯誤資訊;如果缺少響應 wrapper 時包裝上設定好的 wrapper
  3. HttpInput: 如果你設定了 API 的輸入引數型別,那麼該中介軟體會自動根據 Content-Typestruct tag 來讀取對應的引數值,同時自動使用 validator 來檢測引數錯誤
  4. TrafficLog: 如名字所示,如果你設定了響應的 logger,那麼該中介軟體會執行紀錄檔記錄。請注意該中介軟體工作在其他預設中介軟體的後面、你的handler之前,因此它統計的耗時是你業務函數的真正耗時,而不包含其他中介軟體的耗時時間,你可以考慮通過 閘道器Mesh 來記錄完整的介面耗時。

Tips

  • Middleware 處理請求和響應順序是相反的——即第一個處理請求的中介軟體它會是最後一個處理響應的。
  • 框架工作在應用層的優勢有兩點:
    • 與接入層框架解耦,保證絕專案程式碼可平滑 擴充套件/切換 其他接入層框架
    • 能夠獲取到結構化的介面 輸入引數輸出引數 你可以對其進行更具精細的切面操作

GetStart

這裡以 Gin 為例,其他框架類似。

首先獲取對應 wrapper 的 submodule:

go get github.com/shiningrush/droplet/wrapper/gin

// if you want to ensure the droplet is latest, you can get droplet 
go get github.com/shiningrush/droplet

然後程式程式碼如下:


package main

import (
	"reflect"

	"github.com/gin-gonic/gin"
	"github.com/shiningrush/droplet/core"
	"github.com/shiningrush/droplet/wrapper"
	ginwrap "github.com/shiningrush/droplet/wrapper/gin"
)

func main() {
	r := gin.Default()

    // 使用 wrapper 包裝原始的 API
	r.POST("/json_input/:id", ginwrap.Wraps(JsonInputDo, wrapper.InputType(reflect.TypeOf(&JsonInput{}))))
	r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

type JsonInput struct {
    // 從 path 讀取, 並且為必須引數
	ID    string   `auto_read:"id,path" json:"id" validate:"required"`
    // 從 header 讀取, 並且為必須引數
	User  string   `auto_read:"user,header" json:"user" validate:"required"`
    // 從 json unmarshal 後的ips欄位讀取
	IPs   []string `json:"ips"`
    // 從 json unmarshal 後的 count 欄位讀取
	Count int      `json:"count"`
    // 讀取原始的 http body,接收引數型別必須為 []byte or io.ReadCloser
	Body  []byte   `auto_read:"@body"`
}

func JsonInputDo(ctx core.Context) (interface{}, error) {
	input := ctx.Input().(*JsonInput)

	return input, nil
}

引數繫結

Usage 一節中所展示的,我們可以通過 wrapper.InputType 選項來告訴 Droplet 是否期望自動化進行引數繫結,如果某些場景下你不需要從 Body 進行自動的引數繫結了,可以通過顯式的選項來禁止它,如:

r.POST("/json_input/:id", ginwrap.Wraps(JsonInputDo, wrapper.InputType(reflect.TypeOf(&JsonInput{}), wrapper.DisableUnmarshalBody())))

引數繫結的Tag格式如下:

auto_read: ({key},{source}) or @body

其中值如下:

  • key: 用於到各個來源中匹配對應值
  • source: 可選值有 query, header, path, body(預設預設)
  • @body: 特殊的取值,意味著獲取原生的body作為欄位值,此時你的欄位型別應該為 []byte or io.ReadCloser

同時 Droplet 會自動使用 validator 對入參進行校驗,因此你可以使用其 tag 來輔助驗證引數合法性。

響應整形

通常來說API都會在響應的最外層進行一層包裝,比如 Droplet 自帶的 wrapper 如下所示:

{
    "code": 0,       // API 錯誤碼
    "message": "",   // API 訊息
    "data": {},      // 響應資料
    "request_id": "" // 請求ID
}

當然你可以完全去掉這個預設 Wrapper 或者 使用滿足你們團隊規範的 Wrapper(需要實現 data.HttpResponse 介面) 來替換它:

type NativeJsonResp struct {
	data interface{}
}

func (n *NativeJsonResp) Set(code int, msg string, data interface{}) {
	n.data = data
}

func (n *NativeJsonResp) SetReqID(reqId string) {
}

func (n *NativeJsonResp) MarshalJSON() ([]byte, error) {
	return json.Marshal(n.data)
}

func main() {
    ...
	droplet.Option.ResponseNewFunc = func() data.HttpResponse {
		return &NativeJsonResp{}
	}
    ...
}

對於另外一些並不需要 Wrapper 或者 你想要自行控制返回的內容時可以在 Handler 中使用一些實現了特定介面的返回值,如下所示:

func GetLoginQRCode(ctx droplet.Context) (interface{}, error) {
	type makeQRCodeResp struct {
		SceneID string `json:"scene_id"`
		State   int    `json:"state"`
		Url     string `json:"url"`
	}

	var resp makeQRCodeResp
	if err := goreq.Get(UrlMakeQRCode, goreq.SetHeader(fakeClientHeader()), goreq.JsonResp(&resp)).Do(); err != nil {
		return nil, fmt.Errorf("get qrcode failed: %w", err)
	}

	return &data.RawResponse{
		StatusCode: http.StatusOK,
		Body:       []byte(fmt.Sprintf(QRCodeBase, resp.SceneID, resp.SceneID, resp.Url)),
	}, nil
}

類似的還有 data.FileResponsedata.SpecCodeResponse,根據其名字你可以在需要的場景選擇它們。

同時在整形過程中,為了業務研發不再需要關心錯誤處理,Droplet 會自動將 err != nil 的響應轉化到 codemessage 欄位上。
如下圖所示:

func ErrorAPI(ctx droplet.Context) (interface{}, error) {
    return nil, errors.New("failed")
}

那麼你將得到如下的響應:

{
    "code": 10000,
    "message": "failed"
}

當然,你可以使用 data.BaseError 來指定你想返回的錯誤碼:

func ErrorAPI(ctx droplet.Context) (interface{}, error) {
    return nil, data.BaseError{Code: 100, Message: "custom message"}
}

Tips

  • 這些特定的響應其背後都是實現了某一類介面,如果有需要你也完全可以自行實現。

流量記錄

Droplet 自帶了記錄 API 出參與入參的能力,但是預設所有記錄資訊都會被拋棄,如果想要啟用它,你需要實現 Droplet 的全域性 Logger,如下所示:

import (
    "github.com/shiningrush/droplet/log"
)

func main() {
    ...
    // CustomLogger 需要實現 log.Interface
	log.DefLogger = &CustomLogger{}

    // droplet 預設只會記錄 Path,Method,耗時等資訊,如果你需要列印 API 的輸入與輸出,可以在全域性選項中開啟(在Wraps函數中也可指定)
    droplet.Option.TrafficLogOpt = &middleware.TrafficLogOpt{
		LogReq:  true,
		LogResp: true,
	}
    ...
}

自定義中介軟體

實現一個自定義中介軟體很簡單,你只需要實現與 Hanler 類似的介面即可,下圖是一個簡單的中介軟體,它會用於檢測輸入引數是否需要 Quota 並執行相關邏輯:

type DemoMiddleware struct {
    // 繼承基本的middleware,裡面有用於實現處理鏈路的公共邏輯
    middleware.BaseMiddleware
}

func (mw *HttpInputMiddleware) Handle(ctx core.Context) error {
    if ck, ok := ctx.Input().(QuotaChecker); !ok {
        if err := ck.IsQuotaEnough(); err != nil {
            return err
        }
    }

    // 呼叫下一個中介軟體,有需要的話你也可以在響應返回後執行部分邏輯
    return mw.Handle(ctx)
}

func main() {
    // 如果你需要所有API都新增該中介軟體,可以在全域性選項中將你的中介軟體編排
	droplet.Option.Orchestrator = func(mws []core.Middleware) []core.Middleware {
		return append(mws, &DemoMiddleware{})
	}

    ...
    // 在單個API上啟用
	r.POST("/json_input/:id", ginwrap.Wraps(APIHandler,
        wrapper.Orchestrator(func(mws []core.Middleware) []core.Middleware {
			return append(mws, &DemoMiddleware{})
	})))
    ...
}

Tips

Q: 為什麼使用 Orchestrator 這樣的形式來設定中介軟體,而非通過 Priorty 之類的權重來實現中介軟體的編排,這樣在未來可以做到通過組態檔來調整中介軟體

A: 主要出於幾個考慮

  1. 考慮現代微服務的架構下,多數業務無關的通用能力都會下沉到閘道器以及Mesh,因此一個服務的切面不會太多,在通過這樣的方式來設定,成本是可以接受的。
  2. 通過 Orchestrator 方式,使用者還可以任意操作已新增的中介軟體,比如移除一些不必要的中介軟體,這是權重的方式無法做到的。
  3. 當然如果以後有需要,現在的設計並不妨礙我們支援基於權重的方式

小結

正如文中所說,Droplet 的核心目標是 提供位於應用層的、pipeline 形式的請求處理能力,並以此為基礎提供了一些開箱即用的中介軟體。
它對專案帶來的收益總結為幾點:

  • 提供了框架無關的請求處理能力,這使得我們的服務更具韌性
  • 在應用層我們可以接觸到 已序列化後的介面輸入 以及 尚未序列化的介面輸出 ,這使得我們在離業務更近的地方進行切面操作,進而將更多的通用程式碼沉澱到切面而降低業務程式碼的複雜度,更聚焦業務邏輯。

希望 Droplet 能對你有所幫助與啟發。