1. 程式人生 > >Golang Web入門(3):如何優雅的設計中介軟體

Golang Web入門(3):如何優雅的設計中介軟體

摘要

在上一篇文章中,我們已經可以實現一個性能較高,且支援RESTful風格的路由了。但是,在Web應用的開發中,我們還需要一些可以被擴充套件的功能。

因此,在設計框架的過程中,應該留出可以擴充套件的空間,比如:日誌記錄、故障恢復等功能,如果我們把這些業務邏輯全都塞進Controller/Handler中,會顯得程式碼特別的冗餘,雜亂。

所以在這篇文章中,我們來探究如何更優雅的設計這些中介軟體。

1 耦合的實現方式

比如我們要實現一個日誌記錄的功能,我們可以用這種簡單粗暴的方式:

package main

import (
	"fmt"
	"net/http"
	"time"
)

func helloWorldHandler(w http.ResponseWriter, r *http.Request) {
	record(r.URL.Path)
	fmt.Fprintf(w, "Hello World !")
}

func main() {
	http.HandleFunc("/hello", helloWorldHandler)
	http.ListenAndServe(":8000", nil)
}

func record(path string)  {
	fmt.Println(time.Now().Format("3:04:05 PM Mon Jan") + " " + path)
}

如果這樣做的話,確實是實現了我們的目標,記錄了訪問的日誌。

但是,這樣一點都不優雅。

每一個Handler內部都需要呼叫record函式,然後再把需要記錄的path作為引數傳進record函式中。

如果這樣做,不管我們需要新增什麼樣的額外功能,都必須得把這個額外的功能和我們的業務邏輯牢牢地繫結到一起,不能實現擴充套件功能與業務邏輯間的解耦。

2 將記錄與實現解耦

既然在上面的實現中,記錄日誌和業務實現完全的耦合在了一起,那麼我們能不能把他們的業務實現解耦開來呢?

來看這段程式碼:

func record(w http.ResponseWriter, r *http.Request)  {
	path := r.URL.Path
	method := r.Method
	fmt.Println(time.Now().Format("3:04:05 PM Mon Jan") + " " + method + " " + path)
}

func helloWorldHandler(w http.ResponseWriter, r *http.Request) {
	record(w ,r)
	fmt.Fprintf(w, "Hello World !")
}

在這裡,我們已經把業務實現和日誌記錄的耦合給解開了一部分。

我們只需要在業務程式碼中,呼叫record(w,r)函式,把請求的內容作為引數傳進record函式中,然後在record這個方法內記錄日誌。這個時候,我們可以在方法內部任意的處理請求,儲存如請求路徑、請求方法等資料。而這個過程,對業務實現是透明的。

這樣做的話,我們只需要在處理業務邏輯的Handler中呼叫函式,然後把引數傳進去。而這個函式的具體實現,則是與業務邏輯無關的。

那麼,有沒有辦法可以把業務邏輯和擴充套件功能完全分開,讓業務程式碼裡只有業務程式碼,使程式碼變得更加整潔呢?我們接著往下看。

3 設計中介軟體

我們在上一篇文章裡面,分析了httprouter這個包的實現。所以我們直接對他動手,修改他的程式碼,使得這個路由具有擴充套件性。

3.1 效果

在此之前,我們來看看效果:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/julienschmidt/httprouter"
)

func Hello(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
	fmt.Fprint(w, "Hello World!\n")
}

func record(w http.ResponseWriter, r *http.Request){
	path := r.URL.Path
	method := r.Method
	fmt.Println(time.Now().Format("3:04:05 PM Mon Jan") + " " + method + " " + path)
}


func main() {
	router := httprouter.New()
	router.AddBeforeHandle(record)
	router.GET("/hello", Hello)
	log.Fatal(http.ListenAndServe(":8080", router))
}

這部分的程式碼和上一篇的幾乎完全一樣。也是建立一個路由,將/hello這個路徑和Hello這個處理器繫結在GET的這顆字首樹中,然後開始監聽8080埠。

這裡比較重要的是main方法裡面的第二行:

router.AddBeforeHandle(record)

從方法名可以看出,這個方法是在Handle之前增加了一個處理過程。

再看看引數,就是我們上面提到的記錄訪問日誌的方法,這個方法記錄了請求的URL,請求的方法,以及時間。

而在我們的Hello(w http.ResponseWriter, r *http.Request, _ httprouter.Params)函式中,已經不包含任何其他的業務邏輯了。

此時,這個Handler只專注於處理業務邏輯,至於別的,交給別的函式去實現。這樣,就實現了完全的解耦。

下面我們來看看具體的實現過程:

3.2 具體實現

先來看看AddBeforeHandle這個方法:

func (r *Router) AddBeforeHandle(fn func(w http.ResponseWriter, req *http.Request))  {
	r.beforeHandler = fn
}

這個方法很簡單,也就是接收一個處理器型別的引數,然後賦值給Router中的欄位beforeHandler

這個名為beforeHandler欄位也是我們新增在Router中的,相信你也能看得出來了,所謂的AddBeforeHandle方法,就是把我們傳進去的處理函式,儲存在Router中,在需要的時候呼叫他。

那麼我們來看看,什麼時候會呼叫這個方法。下面列出的這個方法,在上一篇文章有提到,是關於httprouter是如何處理路由的:

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	...
	if root := r.trees[req.Method]; root != nil {
		if handle, ps, tsr := root.getValue(path, r.getParams); handle != nil {
		    if r.beforeHandler != nil{
				r.beforeHandler(w, req)
			}
			if ps != nil {
				handle(w, req, *ps)
				r.putParams(ps)
			} else {
				handle(w, req, nil)
			}
			return
		} 
	}
    ...
}

注意看,router在找到了Handler,準備執行之前,我們添加了這麼幾行:

if r.beforeHandler != nil{
	r.beforeHandler(w, req)
}

也就是說,如果我們之前呼叫了AddBeforeHandle方法,給beforeHandler這個欄位賦了值,那麼他就不會為nil,然後呼叫這個函式。這也就實現了我們的目的,在處理請求之前,先執行我們設定的函式。

3.3 思考

現在我們已經實現了一個完全解耦的中介軟體。並且,這個中介軟體是可以任意配置的。你可以拿來做日誌記錄,也可以做許可權校驗等等,而且這些功能還不會對Handler中的業務邏輯造成影響。

如果你是個Java開發者,你可能會覺得這個很像Filter,或者是AOP

但是,和過濾器不同的是,我們不僅可以在請求到來之前處理,也可以在請求完成之後處理。比如這個請求發生了一些panic,你可以在最後處理它,或者你可以記錄這個請求的時間等等,你要做的,只是在Handle方法之後,呼叫你所註冊的方法。

比如:

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	...
	if root := r.trees[req.Method]; root != nil {
		if handle, ps, tsr := root.getValue(path, r.getParams); handle != nil {
		    if r.beforeHandler != nil{
				r.beforeHandler(w, req)
			}
			if ps != nil {
				handle(w, req, *ps)
				r.putParams(ps)
			} else {
				handle(w, req, nil)
			}
			if r.afterHandler != nil {
				r.afterHandler(w, req)
			}
			return
		} 
	}
    ...
}

我們只是添加了一個afterHandler方法,就是這麼的簡單。

那麼問題來了:現在這樣的處理操作,我們僅僅只能在請求前和請求後各自新增一箇中間件。如果我們想要新增任意多箇中間件,該怎麼做呢?

可以先自己思考一下,然後我們來看看在gin中,是怎麼實現的。

4 Gin的中介軟體

4.1 使用

總所周知,在閱讀原始碼之前,一定要先看看他是怎麼用的:

package main

import (
	"fmt"
	"github.com/gin-gonic/gin"
)

func Hello(ctx *gin.Context) {
	fmt.Fprint(ctx.Writer, "Hello World!\n")
}

func main() {
	router := gin.New()
	router.Use(gin.Logger(), gin.Recovery())
	router.GET("/hello", Hello)
	router.Run(":8080")
}

可以看到,在gin中,使用中介軟體的方法和上文中我們所設計的是差不多的。都是業務和中介軟體完全解耦,並且在註冊路由的時候,新增進去。

但是我們注意到,在gin中是不分Handle之前還是Handle之後的。那麼他是如何做到的呢,我們來看看原始碼。

4.2 原始碼解釋

先從Use方法看起:

func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes {
	engine.RouterGroup.Use(middleware...)
	engine.rebuild404Handlers()
	engine.rebuild405Handlers()
	return engine
}

func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
	group.Handlers = append(group.Handlers, middleware...)
	return group.returnObj()
}

在這裡我們先不管group這個東西,他是路由分組,和我們這篇文章沒有關係,我們先不管他。我們只需要看到append方法。Use方法就是把引數裡面的函式,全部增加到group.Handlers中。這裡的group.Handlers,是一個Handler型別的陣列。

所以,在gin中,每一箇中間件,也是Handler型別的。

在上一節我們留了一個問題,要怎麼實現多箇中間件。答案就在這裡了,用陣列儲存。

那麼問題又來了:怎麼保證呼叫的順序呢?

我們繼續往下看看路由的註冊:

func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
	return group.handle(http.MethodGet, relativePath, handlers)
}

這裡是不是也有點熟悉呢?和上一篇文章提到的httprouter很相似,我們直接看group.handle

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
	absolutePath := group.calculateAbsolutePath(relativePath)
	handlers = group.combineHandlers(handlers)
	group.engine.addRoute(httpMethod, absolutePath, handlers)
	return group.returnObj()
}

在這段程式碼中,第一行關於path的我們先不管,這個也是和路由分組有關的,簡單來說就是拼接出完整的請求path

先看看第二行,方法名是combineHandlers,我們可以猜測一下這個方法的作用,把各個Handler結合起來。看看詳細的程式碼:

func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
	finalSize := len(group.Handlers) + len(handlers)
	if finalSize >= int(abortIndex) {
		panic("too many handlers")
	}
	mergedHandlers := make(HandlersChain, finalSize)
	copy(mergedHandlers, group.Handlers)
	copy(mergedHandlers[len(group.Handlers):], handlers)
	return mergedHandlers
}

先解釋一下,這裡返回的HandlersChain型別,是Handler的陣列。

也就是說,在這個方法裡面,把之前放入group中的中介軟體,和當前路由的Handler,組合成一個新的陣列。

並且,中介軟體在前面,路由Handler在後面。注意,這個順序很重要。

然後我們繼續往下,執行完這個方法之後執行的就是addRoute方法了。在這裡不展開講。所以最重要的是,這裡把中介軟體和Handler全都組合在了一起,繫結到了這個字首樹上。

到了這裡註冊方面的內容已經結束了,我們來看看他是怎麼處理各個中介軟體的呼叫順序。

因為我們的目的是看路由是怎麼處理請求的,所以我們直接看ginServeHTTP方法:

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	c := engine.pool.Get().(*Context)
	c.writermem.reset(w)
	c.Request = req
	c.reset()

	engine.handleHTTPRequest(c)

	engine.pool.Put(c)
}

這裡要注意的是*Context,他是對請求的封裝,包含了有responseWriter*http.Request等。

我們繼續往下看看handleHTTPRequest(c)這個方法:

func (engine *Engine) handleHTTPRequest(c *Context) {
    httpMethod := c.Request.Method
	rPath := c.Request.URL.Path
	...
    t := engine.trees
	for i, tl := 0, len(t); i < tl; i++ {
		if t[i].method != httpMethod {
			continue
		}
		root := t[i].root
		// Find route in tree
		value := root.getValue(rPath, c.Params, unescape)
		if value.handlers != nil {
			c.handlers = value.handlers
			c.Params = value.params
			c.fullPath = value.fullPath
			c.Next()
			c.writermem.WriteHeaderNow()
			return
		}
		...
	}
	...
}

在這個方法中,其實和之前我們研究的httprouter是很相似的。也是先根據請求方法找到相對應的字首樹,然後獲取相對應的Handler,並把獲取到的handler陣列儲存在Context中。

這裡我們注意看c.Next()方法,他是gin中關於中介軟體的呼叫最精妙的部分。我們來看看:

func (c *Context) Next() {
	c.index++
	for c.index < int8(len(c.handlers)) {
		c.handlers[c.index](c)
		c.index++
	}
}

我們可以看到,當呼叫這個Next()方法的時候,會增加儲存在Context中的下標,然後根據這個下標的順序執行handler

而在前面我們有提到,我們把中介軟體排在了這個handler陣列的前面,先執行中介軟體,然後最後才是執行使用者自定義的handler

我們再來看看日誌記錄這個中介軟體:

func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
	...
	return func(c *Context) {
		//開始計時
		start := time.Now()
		path := c.Request.URL.Path
		raw := c.Request.URL.RawQuery

		c.Next()
		...
		// Stop timer
		param.TimeStamp = time.Now()
		param.Latency = param.TimeStamp.Sub(start)
		...
	}
}

可以看到,先開始計時,然後呼叫了c.Next()這個方法,然後才結束計時。

那麼我們可以由此推斷,c.Next()後面的程式碼,是執行完使用者自定義的Handler才執行的。

也就是說,其實中介軟體的業務邏輯是這樣的:

func Middleware(c *gin.Context){
    //請求前執行
    c.Next()
    //請求後執行
}

5 寫在最後

首先,謝謝你能看到這裡。

簡單的來講,我們應該考慮解耦合,使得業務程式碼可以專注於業務,中介軟體專注於實現功能。為了實現這點,我們可以修改路由的實現邏輯,在執行Handler的前後加入中介軟體的呼叫。

在本文中,可能會有很多的疏漏。如果在閱讀的過程中,有哪些解釋不到位,或者作者的理解出現了一些差錯,也請你留言指正。

再次感謝~

PS:如果有其他的問題,也可以在公眾號找到作者。並且,所有文章第一時間會在公眾號更新,歡迎來找作者玩~

相關推薦

Golang Web入門3如何優雅設計中介軟體

摘要 在上一篇文章中,我們已經可以實現一個性能較高,且支援RESTful風格的路由了。但是,在Web應用的開發中,我們還需要一些可以被擴充套件的功能。 因此,在設計框架的過程中,應該留出可以擴充套件的空間,比如:日誌記錄、故障恢復等功能,如果我們把這些業務邏輯全都塞進Controller/Handler中,會

Golang Web入門4如何設計API

## 摘要 在之前的幾篇文章中,我們從如何實現最簡單的HTTP伺服器,到如何對路由進行改進,到如何增加中介軟體。總的來講,我們已經把Web伺服器相關的內容大概梳理了一遍了。在這一篇文章中,我們將從最簡單的一個main函式開始,慢慢重構,來研究如何把API設計的更加規範和具有擴充套件性。 ## 1 構

Golang Web入門1自頂向下理解Http伺服器

摘要 由於Golang優秀的併發處理,很多公司使用Golang編寫微服務。對於Golang來說,只需要短短几行程式碼就可以實現一個簡單的Http伺服器。加上Golang的協程,這個伺服器可以擁有極高的效能。然而,正是因為程式碼過於簡單,我們才應該去研究他的底層實現,做到會用,也知道為什麼這麼用。 在本文中,會

微信小程式入門3移動端訪問PC

上一篇講了web-view的使用,然而你會發現這隻能訪問一些百度、淘寶、騰訊等一些存在的網站,但如何訪問自己本地ip以來訪問自己寫的一些網站呢? 注意:這篇文章是利用Python開發的web,可能對一些其他僅有HTML、css、js的靜態不太適用 對於Django部分本篇不做過多講解,以後

MySQL 入門3事務隔離

## 摘要 在這一篇內容中,我將從事務是什麼開始,聊一聊事務的必要性。 然後,介紹一下在InnoDB中,四種不同級別的事務隔離,能解決什麼問題,以及會帶來什麼問題。 最後,我會介紹一下InnoDB解決高併發事務的方式:多版本併發控制。 ## 1 什麼是事務 說到事務,一個最典型的例子就是

C++入門3C++開發環境搭建

本文首發 | 公眾號:lunvey 既然開始學C++,電腦上應該具備它的開發環境。 而C++歷史,lunvey老師覺得:初學者有興趣可以自行了解一下,不是必要的。 大家都在用最新款的蘋果手機,好用就行,又有多少人知道蘋果手機的歷代版本呢? 同理,工欲善其事,必先利其器。先搭建好C++開發環境,初窺一下它的

node.js搭建介面Node-使用中介軟體實現跨域

使用中介軟體進行跨域必須寫在使用路由之前 //使用中介軟體實現跨域請求 app.use((req,res,next) => { res.header("Access-Control-Allow-Origin","*"); //允許的來源 res

[Golang] 從零開始寫Socket Server3 對長、短連接的處理策略模擬心跳

microsoft ted 每次 range 點擊 關閉 ade 而在 href 通過前兩章,我們成功是寫出了一套湊合能用的Server和Client,並在二者之間實現了通過協議交流。這麽一來,一個簡易的socket通訊框架已經初具雛形了,那麽我們接下來做的

Web是如何工作的3HTTP&REST

medium 方法 post ogg 兩臺 gecko 希望 種類 正常 原文地址:https://medium.freecodecamp.org/how-the-web-works-part-iii-http-rest-e61bc50fa0a 我們在第一篇文章中介紹了

web前端學習3認識HTML基本標簽

鼠標 首頁 一份 格式 所在地 有著 同時 網上 今天 本章主旨:   介紹常用的文本相關標簽,如<h>,<p>,<a>;簡介常見的HTML標簽屬性,如title屬性,href屬性,id/class屬性等;重點掌握<a>標簽。

grpc3使用 golang 開發 grpc 服務端和client

ava 相互調用 相互 localhost rpcclient int err pri nec 1,關於grpc-go golang 能夠能夠做grpc的服務端

學習筆記版Hadoop入門Hadoop2.7.3完全分布式集群安裝

min property per cal mon 分別是 master 修改 node 在這裏寫下安裝hadoop2.7.3版本的完全分布式的過程,因為剛開始學習hadoop不久,希望自己把學習的東西記錄下來,和大家一起分享,可能有錯誤的地方,還請大牛們批評指正,在我學習的

Ros入門3ROS學習中遇到的問題1.rospack depends1 beginner_tutorials出現錯誤

rospack depends1 beginner_tutorials 出現錯誤 [rospack] Error: the rosdep view is empty: call 'sudo rosdep init' and 'rosdep update'、 解決方法如下: 按提示sudo

arcgis jsapi介面入門系列3各種型別的圖層新增

這裡說的tomcat切片,是指arcgis server切片後,把切片圖片檔案用tomcat釋出(其他任意web伺服器釋出都行) //新增tomcat切片圖層 addTomcatTileLayer: function () { //圖層

爬蟲入門系列優雅的HTTP庫requests

爬蟲入門系列目錄: urllib、urllib2、urllib3、httplib、httplib2 都是和 HTTP 相關的 Python 模組,看名字就覺得很反人類,更糟糕的是這些模組在 Python2 與 Python3 中有很大的差異,如果業務程式碼要同時相容 2 和 3,寫起來

ApolloStudio高手之路3用Python以最優雅簡潔的方式讀寫支援Modbus RTU/TCP協議的裝置資料

縱觀整個工業界生態鏈,可能Modbus協議(包括了通過串列埠方式連線的Modbus RTU協議和通過網路傳輸的Modbus TCP協議,這裡統稱為Modbus)作為一種工業領域通訊協議的業界標準在所有裝置互聯的協議群中獨佔鰲頭,也許正是因為其相較與其他協議的優越性(1.公開發表並且無版權要求;2

HTTP入門確保Web安全的HTTPS

7.1HTTP的缺點 通訊使用明文(不加密),內容可能會被竊聽 不驗證通訊方的身份,因此有可能遭遇偽裝 無法證明報文的完整性,所以有可能已遭篡改 7.1.1 通訊使用明文可能會被竊聽 由於HTTP本身不具備加密的功能,所以也無法做到對通訊整體(使用HTTP

Asp.Net MVC4入門指南3新增一個檢視

在本節中,您需要修改HelloWorldController類,從而使用檢視模板檔案,乾淨優雅的封裝生成返回到客戶端瀏覽器HTML的過程。 您將建立一個檢視模板檔案,其中使用了ASP.NET MVC 3所引入的Razor檢視引擎。Razor檢視模板檔案使用.cshtml副檔名,並提供了一個優雅的方式來使用C

Java Web基礎知識之Servlet3Session管理

Session 管理是Web應用開發中的一個重要的內容,其實每天我們瀏覽網站,網站的後臺都是通過這門技術來記錄我們的瀏覽狀態,最典型的就是登入,每次你在網站上登入一次,當跳轉到該網站的任何其他頁面都不會再次要求你登入,這就是使用了Session管理技術。那麼問題來了我們為什

SSH利用Struts2+Hibernate4+Spring4+SQLServer框架,搭建一個前後端web網站3

為你推薦(模仿cache排程演算法) 網站有為你推薦模組,我是模仿計算機組成原理中的cache的排程演算法。簡單介紹:網站通過將所有的資訊和標籤掛鉤。我給每個使用者20條的“儲存標籤”的空間。其中10條為被標記成old的標籤,10條為被標記成new