go-zero之web框架

Kevin Wan發表於2020-11-19

go-zero 是一個整合了各種工程實踐的 web 和 rpc 框架,其中rest是web框架模組,基於Go語言原生的http包進行構建,是一個輕量的,高效能的,功能完整的,簡單易用的web框架

服務建立

go-zero中建立http服務非常簡單,官方推薦使用goctl工具來生成。為了方便演示,這裡通過手動建立服務,程式碼如下

package main

import (
	"log"
	"net/http"

	"github.com/tal-tech/go-zero/core/logx"
	"github.com/tal-tech/go-zero/core/service"
	"github.com/tal-tech/go-zero/rest"
	"github.com/tal-tech/go-zero/rest/httpx"
)

func main() {
	srv, err := rest.NewServer(rest.RestConf{
		Port: 9090, // 偵聽埠
		ServiceConf: service.ServiceConf{
			Log: logx.LogConf{Path: "./logs"}, // 日誌路徑
		},
	})
	if err != nil {
		log.Fatal(err)
	}
	defer srv.Stop()
	// 註冊路由
	srv.AddRoutes([]rest.Route{ 
		{
			Method:  http.MethodGet,
			Path:    "/user/info",
			Handler: userInfo,
		},
	})
	
	srv.Start() // 啟動服務
}

type User struct {
	Name  string `json:"name"`
	Addr  string `json:"addr"`
	Level int    `json:"level"`
}

func userInfo(w http.ResponseWriter, r *http.Request) {
	var req struct {
		UserId int64 `form:"user_id"` // 定義引數
	}
	if err := httpx.Parse(r, &req); err != nil { // 解析引數
		httpx.Error(w, err)
		return
	}
	users := map[int64]*User{
		1: &User{"go-zero", "shanghai", 1},
		2: &User{"go-queue", "beijing", 2},
	}
	httpx.WriteJson(w, http.StatusOK, users[req.UserId]) // 返回結果
}

通過rest.NewServer建立服務,示例配置了埠號和日誌路徑,服務啟動後偵聽在9090埠,並在當前目錄下建立logs目錄同時建立各等級日誌檔案

然後通過srv.AddRoutes註冊路由,每個路由需要定義該路由的方法、Path和Handler,其中Handler型別為http.HandlerFunc

最後通過srv.Start啟動服務,啟動服務後通過訪問http://localhost:9090/user/info?user_id=1可以看到返回結果

{
	name: "go-zero",
	addr: "shanghai",
	level: 1
}

到此一個簡單的http服務就建立完成了,可見使用rest建立http服務非常簡單,主要分為三個步驟:建立Server、註冊路由、啟動服務

JWT鑑權

鑑權幾乎是每個應用必備的能力,鑑權的方式很多,而jwt是其中比較簡單和可靠的一種方式,在rest框架中內建了jwt鑑權功能,jwt的原理流程如下圖

jwt

rest框架中通過rest.WithJwt(secret)啟用jwt鑑權,其中secret為伺服器祕鑰是不能洩露的,因為需要使用secret來算簽名驗證payload是否被篡改,如果secret洩露客戶端就可以自行簽發token,黑客就能肆意篡改token了。我們基於上面的例子進行改造來驗證在rest中如何使用jwt鑑權

獲取jwt

第一步客戶端需要先獲取jwt,在登入介面中實現jwt生成邏輯

srv.AddRoute(rest.Route{
		Method:  http.MethodPost,
		Path:    "/user/login",
		Handler: userLogin,
})

為了演示方便,userLogin的邏輯非常簡單,主要是獲取資訊然後生成jwt,獲取到的資訊存入jwt payload中,然後返回jwt

func userLogin(w http.ResponseWriter, r *http.Request) {
	var req struct {
		UserName string `json:"user_name"`
		UserId   int    `json:"user_id"`
	}
	if err := httpx.Parse(r, &req); err != nil {
		httpx.Error(w, err)
		return
	}
	token, _ := genToken(accessSecret, map[string]interface{}{
		"user_id":   req.UserId,
		"user_name": req.UserName,
	}, accessExpire)

	httpx.WriteJson(w, http.StatusOK, struct {
		UserId   int    `json:"user_id"`
		UserName string `json:"user_name"`
		Token    string `json:"token"`
	}{
		UserId:   req.UserId,
		UserName: req.UserName,
		Token:    token,
	})
}

生成jwt的方法如下

func genToken(secret string, payload map[string]interface{}, expire int64) (string, error) {
	now := time.Now().Unix()
	claims := make(jwt.MapClaims)
	claims["exp"] = now + expire
	claims["iat"] = now
	for k, v := range payload {
		claims[k] = v
	}
	token := jwt.New(jwt.SigningMethodHS256)
	token.Claims = claims
	return token.SignedString([]byte(secret))
}

啟動服務後通過cURL訪問

curl -X "POST" "http://localhost:9090/user/login" \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{
  "user_name": "gozero",
  "user_id": 666
}'

會得到如下返回結果

{
  "user_id": 666,
  "user_name": "gozero",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDYxMDgwNDcsImlhdCI6MTYwNTUwMzI0NywidXNlcl9pZCI6NjY2LCJ1c2VyX25hbWUiOiJnb3plcm8ifQ.hhMd5gc3F9xZwCUoiuFqAWH48xptqnNGph0AKVkTmqM"
}

新增Header

通過rest.WithJwt(accessSecret)啟用jwt鑑權

srv.AddRoute(rest.Route{
		Method:  http.MethodGet,
		Path:    "/user/data",
		Handler: userData,
}, rest.WithJwt(accessSecret))

訪問/user/data介面返回 401 Unauthorized 鑑權不通過,新增Authorization Header,即能正常訪問

curl "http://localhost:9090/user/data?user_id=1" \
      -H 'Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MDYxMDgwNDcsImlhdCI6MTYwNTUwMzI0NywidXNlcl9pZCI6NjY2LCJ1c2VyX25hbWUiOiJnb3plcm8ifQ.hhMd5gc3F9xZwCUoiuFqAWH48xptqnNGph0AKVkTmqM'

獲取資訊

一般會將使用者的資訊比如使用者id或者使用者名稱存入jwt的payload中,然後從jwt的payload中解析出我們預存的資訊,即可知道本次請求時哪個使用者發起的

func userData(w http.ResponseWriter, r *http.Request) {
	var jwt struct {
		UserId   int    `ctx:"user_id"`
		UserName string `ctx:"user_name"`
	}
	err := contextx.For(r.Context(), &jwt)
	if err != nil {
		httpx.Error(w, err)
	}
	httpx.WriteJson(w, http.StatusOK, struct {
		UserId   int    `json:"user_id"`
		UserName string `json:"user_name"`
	}{
		UserId:   jwt.UserId,
		UserName: jwt.UserName,
	})
}

實現原理

jwt鑑權的實現在authhandler.go中,實現原理也比較簡單,先根據secret解析jwt token,驗證token是否有效,無效或者驗證出錯則返回401 Unauthorized

func unauthorized(w http.ResponseWriter, r *http.Request, err error, callback UnauthorizedCallback) {
	writer := newGuardedResponseWriter(w)

	if err != nil {
		detailAuthLog(r, err.Error())
	} else {
		detailAuthLog(r, noDetailReason)
	}
	if callback != nil {
		callback(writer, r, err)
	}

	writer.WriteHeader(http.StatusUnauthorized)
}

驗證通過後把payload中的資訊存入http request的context中

ctx := r.Context()
for k, v := range claims {
  switch k {
    case jwtAudience, jwtExpire, jwtId, jwtIssueAt, jwtIssuer, jwtNotBefore, jwtSubject:
    // ignore the standard claims
    default:
    ctx = context.WithValue(ctx, k, v)
  }
}

next.ServeHTTP(w, r.WithContext(ctx))

中介軟體

web框架中的中介軟體是實現業務和非業務功能解耦的一種方式,在web框架中我們可以通過中介軟體來實現諸如鑑權、限流、熔斷等等功能,中介軟體的原理流程如下圖

rest框架中內建了非常豐富的中介軟體,在rest/handler路徑下,通過alice工具把所有中介軟體連結起來,當發起請求時會依次通過每一箇中介軟體,當滿足所有條件後最終請求才會到達真正的業務Handler執行業務邏輯,上面介紹的jwt鑑權就是通過authHandler來實現的。由於內建中介軟體比較多篇幅有限不能一一介紹,感興趣的夥伴可以自行學習,這裡我們介紹一下prometheus指標收集的中介軟體PromethousHandler,程式碼如下

func PromethousHandler(path string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			startTime := timex.Now() // 起始時間
			cw := &security.WithCodeResponseWriter{Writer: w}
			defer func() {
        // 耗時
				metricServerReqDur.Observe(int64(timex.Since(startTime)/time.Millisecond), path)
        // code碼
				metricServerReqCodeTotal.Inc(path, strconv.Itoa(cw.Code))
			}()
			
			next.ServeHTTP(cw, r)
		})
	}
}

在該中介軟體中,在請求開始時記錄了起始時間,在請求結束後在defer中通過prometheus的Histogram和Counter資料型別分別記錄了當前請求path的耗時和返回的code碼,此時我們通過訪問http://127.0.0.1:9101/metrics即可檢視相關的指標資訊

路由原理

rest框架中通過AddRoutes方法來註冊路由,每一個Route有Method、Path和Handler三個屬性,Handler型別為http.HandlerFunc,新增的路由會被換成featuredRoutes定義如下

featuredRoutes struct {
		priority  bool // 是否優先順序
		jwt       jwtSetting  // jwt配置
		signature signatureSetting // 驗籤配置
		routes    []Route  // 通過AddRoutes新增的路由
	}

featuredRoutes通過engine的AddRoutes新增到engine的routes屬性中

func (s *engine) AddRoutes(r featuredRoutes) {
	s.routes = append(s.routes, r)
}

呼叫Start方法啟動服務後會呼叫engine的Start方法,然後會呼叫StartWithRouter方法,該方法內通過bindRoutes繫結路由

func (s *engine) bindRoutes(router httpx.Router) error {
	metrics := s.createMetrics()

	for _, fr := range s.routes { 
		if err := s.bindFeaturedRoutes(router, fr, metrics); err != nil { // 繫結路由
			return err
		}
	}

	return nil
}

最終會呼叫patRouter的Handle方法進行繫結,patRouter實現了Router介面

type Router interface {
	http.Handler
	Handle(method string, path string, handler http.Handler) error
	SetNotFoundHandler(handler http.Handler)
	SetNotAllowedHandler(handler http.Handler)
}

patRouter中每一種請求方法都對應一個樹形結構,每個樹節點有兩個屬性item為path對應的handler,而children為帶路徑引數和不帶路徑引數對應的樹節點, 定義如下:

node struct {
  item     interface{}
  children [2]map[string]*node
}

Tree struct {
  root *node
}

通過Tree的Add方法把不同path與對應的handler註冊到該樹上我們通過一個圖來展示下該樹的儲存結構,比如我們定義路由如下

{
  Method:  http.MethodGet,
  Path:    "/user",
  Handler: userHander,
},
{
  Method:  http.MethodGet,
  Path:    "/user/infos",
  Handler: infosHandler,
},
{
  Method:  http.MethodGet,
  Path:    "/user/info/:id",
  Handler: infoHandler,
},

路由儲存的樹形結構如下圖

go-zero之web框架

當請求來的時候會呼叫patRouter的ServeHTTP方法,在該方法中通過tree.Search方法找到對應的handler進行執行,否則會執行notFound或者notAllow的邏輯

func (pr *patRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	reqPath := path.Clean(r.URL.Path)
	if tree, ok := pr.trees[r.Method]; ok {
		if result, ok := tree.Search(reqPath); ok { // 在樹中搜尋對應的handler
			if len(result.Params) > 0 {
				r = context.WithPathVars(r, result.Params)
			}
			result.Item.(http.Handler).ServeHTTP(w, r)
			return
		}
	}

	allow, ok := pr.methodNotAllowed(r.Method, reqPath)
	if !ok {
		pr.handleNotFound(w, r)
		return
	}

	if pr.notAllowed != nil {
		pr.notAllowed.ServeHTTP(w, r)
	} else {
		w.Header().Set(allowHeader, allow)
		w.WriteHeader(http.StatusMethodNotAllowed)
	}
}

總結

本文從整體上介紹了rest,通過該篇文章能夠基本瞭解rest的設計和主要功能,其中中介軟體部分是重點,裡面整合了各種服務治理相關的功能,並且是自動整合的不需要我們做任何配置,其他功能比如引數自動效驗等功能由於篇幅有限在這裡就不做介紹了,感興趣的朋友可以自行檢視官方文件進行學習。go-zero中不光有http協議還提供了rpc協議和各種提高效能和開發效率的工具,是一款值得我們深入學習和研究的框架。

專案地址

https://github.com/tal-tech/go-zero

如果覺得文章不錯,歡迎 github 點個 star ?

專案地址:
https://github.com/tal-tech/go-zero

相關文章