如標題所描述的,Droplet 是一個 輕量 的 中間層框架,何為中間層呢?
通常來說,我們的程式(注意這裡我們僅僅討論程式的範圍,而非作為一個系統,因此這裡不設計如 LB、Gateway、Mesh等內容,因為它們都處於程式以外)按不同的職責可以分為不同的層次,而按照不同的設計風格,常見的如下:
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
存在以下兩個弊端:
一些相關的BadCase
想象一下:
- 你一直在使用 gin,但是有一天運營拿著資料找到你,說機器佔用的成本太高了,而你發現只要切換到 fasthttp 就能為你帶來更高的效能,但是從 gin -> fasthttp 你需要調整大量的 API handler 程式碼,這可太讓人頭疼了。
- API handler中充斥了諸如
param, ok := req.Query("param") / param, ok := req.Header.Get("param") / err := xxx.Bind(req, ¶m)
之類的程式碼,這和業務毫無關係
http.Request/httpResponse
為主體的,這意味著如果不做任何前置處理,你只能通過位元組陣列來感知 請求與響應
這在部分場景都不太方便,比如:根據請求、響應的結構體是否具備某些特徵(比如介面)來執行某些特定的業務通用邏輯;又或者想在中介軟體中融入一些自動化的引數校驗邏輯,因為你沒有一個具體的結構化物件;再或者你不想要在每一個 API handler 中去設定一個響應的 Wrapper(通常它類似於 {code: 0, msg: "", data:{}}
),想要在中介軟體去自動包裝上它,也很難執行;最後就是——如果只依靠 http.Request/httpResponse
,你也難在中介軟體感知到其他參與者的處理狀態,。相信我說的這些問題,使用過的同學應該都有所感觸,而這些問題並非難以解決,它們中的大部分基本都是可以通過自行建立一套約定來得以緩解(比如將這些資訊都通過 context 去獲取),而 Droplet 也是誕生於我在過往團隊中去克服這些問題的實踐之中,是一個相對可靠的實現。
帶著上面提到的這些問題,我們來看看 Droplet 的工作原理是怎樣的,如下圖所示:
如我所說的那樣,Droplet 的核心在於 提供基於pipeline的請求/響應處理能力
,因此我們可以看見這個圖中涉及的所有模組都是基於 pipleline,可以說 Droplet 的所有能力都是由其擴充套件而來。
這裡我們先介紹下圖中出現的幾個中介軟體(Middleware,這是組成 pipepine 關鍵元素):
Tips
- Middleware 處理請求和響應順序是相反的——即第一個處理請求的中介軟體它會是最後一個處理響應的。
- 框架工作在應用層的優勢有兩點:
- 與接入層框架解耦,保證絕專案程式碼可平滑 擴充套件/切換 其他接入層框架
- 能夠獲取到結構化的介面 輸入引數 與 輸出引數 你可以對其進行更具精細的切面操作
這裡以
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
其中值如下:
同時 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.FileResponse
、data.SpecCodeResponse
,根據其名字你可以在需要的場景選擇它們。
同時在整形過程中,為了業務研發不再需要關心錯誤處理,Droplet 會自動將 err != nil
的響應轉化到 code 與 message 欄位上。
如下圖所示:
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: 主要出於幾個考慮
- 考慮現代微服務的架構下,多數業務無關的通用能力都會下沉到閘道器以及Mesh,因此一個服務的切面不會太多,在通過這樣的方式來設定,成本是可以接受的。
- 通過 Orchestrator 方式,使用者還可以任意操作已新增的中介軟體,比如移除一些不必要的中介軟體,這是權重的方式無法做到的。
- 當然如果以後有需要,現在的設計並不妨礙我們支援基於權重的方式
正如文中所說,Droplet 的核心目標是 提供位於應用層的、pipeline 形式的請求處理能力,並以此為基礎提供了一些開箱即用的中介軟體。
它對專案帶來的收益總結為幾點:
希望 Droplet 能對你有所幫助與啟發。