大規模路由表與混雜模式的業務邏輯
公司内不少使用 Golang 編寫的業務,目前所在的部門使用 Go 開發業務 WebAPI 的時大多數采用原生的 HTTPHandler
func(w http.ResponseWriter, r *http.Request)
或者使用 gin 架構。于是便在代碼中看到長達數頁的路由系統資料庫:
router.Group("/v1", func() {
router.POST("/api/1", middlewares...)
router.POST("/api/2", middlewares...)
router.POST("/api/3", middlewares...)
router.POST("/api/4", middlewares...)
router.POST("/api/5", middlewares...)
router.POST("/api/6", middlewares...)
router.POST("/api/7", middlewares...)
router.POST("/api/8", middlewares...)
router.POST("/api/9", middlewares...)
router.POST("/api/10", middlewares...)
router.POST("/api/11", middlewares...)
router.POST("/api/12", middlewares...)
router.POST("/api/13", middlewares...)
router.POST("/api/14", middlewares...)
router.POST("/api/15", middlewares...)
...
}
不乏拼寫錯誤和重複注冊。雖然這樣看上去直覺,似乎很好排查錯誤,但是實際使用中發現,這樣的大型路由系統資料庫往往能讓錯誤排查更加複雜:
- 業務邏輯中混雜了大量的序列化反序列化操作;
- 防禦性程式設計與業務混編處理,某些錯誤處理不當導緻不具合參數依然進入了後繼邏輯;
- 需要手動調用不少工具函數通過辨別擷取會話對象,出現大量需要約定的臨時存儲空間;
- 雖然代碼即文檔的扁平路由表可以更直覺看到 API 路徑,但是實際需要的參數、正文接納的類型依然無法直覺感覺;
- 基礎參數通過 Query/Header 傳遞,對于日志記錄和遙測分析來說并不友好。
另外,還有:
- 與用戶端回話的加密解密手動處理;
- 無法生成符合 OpenAPI 協定的文檔做到代碼即文檔;
等等一些問題不大,但是确實讓人不爽的地方。曾經使用層疊路由表去解決大規模系統資料庫,但是發現,注冊層次清晰了,卻帶來了大量的子檔案,查端點異常辛苦,跟俄羅斯套娃似的,一套又一套。
.NET 與 Java 現場 Battle
Golang 這樣的開發模式對于很多以前使用 http://ASP.NET WebAPI、Spring Boot 的開發者來說,變化确實有點大,相對于
func(w http.ResponseWriter, r *http.Request)
來說,C# 與 Java 前輩的代碼更加直覺并且省時省力:
namespace WebApp.Controllers {
public class AdminController : Controller {
WebAppContext entity = new WebAppContext();
//POST
[HttpPost]
public ActionResult NewUser(User usr) {
//Business logic
return View(usr.Id);
}
}
}
運作起來的時候,通路位址就是:
(basepath)/admin/newuser
寫業務代碼的時候直接使用
usr
這個傳入參數,不需要像 Golang 那樣去做:
json.NewDecoder(request.Body).Decode(&obj)
每個函數都要這麼處理,枯燥備援并且更容易出錯。另外,http://ASP.NET MVC 中,如果方法還有 Int32、String 之類的參數,是可以展現在 URL 中的。例如:
public ActionResult Result(Int32 Id) {
//logic
return View("Detail", t);
}
通路的方法可以直接是:
(basepath)/result/{Id}
Go × WebAPI
同樣是 WebAPI 開發,Go 可不可以這樣的?我們簡單翻譯一下上面的 AdminController,用 Go 寫的話,應該這樣:
type AdminController struct {
webapi.Controller
entity *WebAppContext
}
func (controller *AdminController) Init() error {
entity = infrastructure.DatabaseConnection
}
func (*AdminController) NewUser(usr *models.User) ActionResult {
//Business logic
return ActionResult { Body: usr.ID }
}
對于 URL 參數化,最好還可以:
func (*AdminController) Result(id int) (result ActionResult) {
//logic
return
}
出于這樣的目的,寫了一個簡單的基礎類庫:go-webapi/webapi。
介紹 go-WebAPI
自動路由注冊與複合控制器
聚焦業務核心回避無關的規則,DDD 與 MVVM 等設計模式高度相容。不同業務子產品擁有自身控制器(組),支援子產品細分與多子產品統一整合的設計(允許多人協同)。基于約定的自動路由注冊降低備援代碼,提高設計效率同時回避公開位址與内部實作失去同步的可能。聲明控制器:
type Article struct {
webapi.Controller
}
聲明接入點:
func (article *Article) Show(query struct {
GUID string `json:"guid"`
}) string {
return fmt.Sprintf("you are reading post-%s", query.GUID)
}
WebAPI 遵循 Golang 原則,但凡可通路的方法(大寫字母開頭的函數)均會被注冊為 API 接入點。
接入點會被注冊為
/article/show?guid=[guid]
,如果有多個控制器處理不同業務,那麼可以通過
RouteAlias() string
方法來指定控制器别名:
type article struct {
webapi.Controller
id uint
}
func (article *article) RouteAlias() string {
return "article"
}
然後不管在
article
還是
Article
下的方法均會注冊到
/article
下。不過需要注意的是,此類注冊器需要回避重名問題。不過在運作時 WebAPI
不會針對重名方法做出緻命警告 (panic)。
使用時,不妨将各個業務子產品細分給不同的人去完成。最後通過 RouteAlias
可以輕松将他們整合在一起。
查詢/正文自動序列化與反序列化支援
完全消除在業務邏輯中的正文讀取、序列化/反序列化、查詢檢索與轉化,借助于中間件,甚至還可以配置 MsgPack 等非系統内建/私有化序列器。我們使用
curl
通路一下剛才的 API:
~ curl http://localhost:9527/article/show?guid=79526
#you are reading post-79526
可以看到參數自動放到了
query
中。亦可支援正文自動序列化,例如聲明方法:
func (article *article) Save(entity *struct {
ID uint
Title string
Content string
CreateTime time.Time
}, query struct {
CreateTime string `json:"time"`
}) {
entity.CreateTime, _ = time.Parse("2006-01-02", query.CreateTime)
entity.ID = article.id
article.Reply(http.StatusAccepted, entity)
}
這個方法将會注冊為
[POST] /article/{digits}/save
,因為存在
*struct{}
結構,是以預設為 POST,但是可以通過
method[HTTPMETHOD] struct
的私有字段的形式去顯式聲明 HTTP 方法。同樣使用
curl
:
~ curl -X "POST" "http://localhost:9527/article/123/save?time=2019-01-01"
-H 'Content-Type: application/json; charset=utf-8'
-d $'{
"Title": "Hello WebAPI for Golang",
"Content": "Awesome!"
}'
#{"ID":123,"Title":"Hello WebAPI for Golang","Content":"Awesome!","CreateTime":"2019-01-01T00:00:00Z"}
可以看到查詢中的時間成功被通路并賦到了正文。也請留意到,不管是之前使用的
string
作為傳回值,還是這個節點的,手動使用
.Reply(STATUSCODE, INTERFACE{})
都可以自動處理并回複給用戶端。
序列化器可以手動指定。在上下文 (Context) 的 Serializer 屬性中指定。
同時,查詢和正文的結構支援檢查,為他們添加
Check() error
方法即可在進入業務代碼之前檢查資料的合法性,将防範性編碼與業務隔離開來。
路由前置條件(前參數化通路)支援
收束控制器處理資料範疇設立 API 通路準入門檻。提供其他路由服務無法提供的比對-回落和具體業務控制器前置條件能力,從根本隔離開非法通路,降低出錯幾率,提高業務編碼效率并提高系統魯棒性。剛才的請求中我們看到,通路位址
/article/123/save
中的
123
被捕獲并且最後在回複正文的
ID
中出現。WebAPI 允許為控制器設立前置條件(Precondition),聲明的方法:
func (article *article) Init(id uint) (err error) {
article.id = id
return
}
隻需要為控制器聲明傳回值為
error
且名稱為
Init
的方法即可自動在進入實際方法前調用它。節點注冊形式也發生了些許變更。如果參數為
- 但凡整型(Int)會得到一個
的注冊點;/{digits}
- 所有浮點(Float)那麼将會得到一個
的注冊點;/{float}
- 布爾值(Bool)會産生
注冊點 ;/{bool}
- 字元串(String)将會得到一個 /{string} 的注冊點(回落注冊點)。
是以上面的
Init(uint) error
函數将會産生
/{digits}
的注冊節。
如果調用函數傳回的錯誤值不為空,那麼将會通知用戶端 Bad Request。這從側面區分了對象方法(Object Method)和靜态方法(Static Method),編碼時可以更關注業務本身而不用去操心各種前置條件審查,或節省大量近似的代碼。
端點條件(後參數化通路)支援
不需要反向代理配置僞靜态即可提供原生支援參數化通路,提供更直覺簡潔的 API。既然前參數化通路都支援,那麼自然後參數化通路也可以。剛才我們遇到,通路文章正文需要使用查詢參數,雖然可以正常工作,但是未免顯得太過單調。通過前置參數支援需要使用
Read
一類的方法,感覺不自然。我們可以通過後置參數的形式來提供形如
/article/{guid}
的通路形式:
func (article *Article) Index(guid string) string {
return fmt.Sprintf("you are reading post-%s", guid)
}
使用
curl
測試一下:
~ curl http://localhost:9527/article/id-233666
#you are reading post-id-233666
⚠️ 注意
此方法也可以通過
的方法實作。在本例中兩個方法允許共存,因為前者為
func (article *article) Index(id int)
後者為
/article/{string}
。在協作的時候務必注意此類問題。如果出現重複注冊節點,控制器将會注冊失敗并提示錯誤。
/article/{digits}
中途加密政策支援
完全可托付的原生加密解密,相容密鑰協商機制,即使密鑰變更或不唯一,隻需一次設定,流水線明文密文處理更加可靠,完全杜絕因為疏忽或者意外造成的機密中繼資料洩露的可能,資料安全高枕無憂。加解密服務依托于上下文,可以在中間件中指定上下文中的
CryptoService
來實作提供統一的加解密服務,将無關業務的加解密方法獨立出去,提高開發者效率。
方法亦可在使用中途更改,即此加密解密的子產品是動态可替換的。