写点什么

go-zero 之 rest 实战与原理

用户头像
Kevin Wan
关注
发布于: 2020 年 12 月 02 日

go-zero 是一个集成了各种工程实践的 web 和 rpc 框架,其中 rest 是 web 模块,该模块基于 Go 语言原生的 http 包进行构建,是一个轻量的,高性能的,功能完整的,简单易用的 web 框架。使用 rest 能够快速构建 restful 风格 api 服务,同时具备服务监控和弹性服务治理能力

快速开始

我们先来快速构建一个服务感受一下,使用 rest 创建 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、注册路由、启动服务



服务监控

服务监控的重要性不言而喻,没有监控我们就没法清晰的了解到服务的运行情况,也就没有办法提前发现问题,当我们感知到问题存在时候往往为时已晚。服务监控甚至和服务本身同等重要,通过监控我们可以了解到当前服务的运行状况,比如当前的资源使用率、接口的 QPS,接口的耗时,错误率等等,以及在业务处理过程中我们也会记录一些日志帮助定位排查问题, 对于微服务的性能问题进行定位的时候我们往往还需要知道整条调用链路。在 rest 中内置了自动的服务的监控,主要分为三个方面:日志、指标和调用链

日志

快速开始的示例中我们配置了日志的路径,服务启动后会在该路径下生成日志文件,默认情况下所有日志级别均开启,可以通过



logx.SetLevel(1)

来设置日志级别,日志级别的定义如下:



const (
// InfoLevel logs everything
InfoLevel = iota
// ErrorLevel includes errors, slows, stacks
ErrorLevel
// SevereLevel only log severe messages
SevereLevel
)


通过 logx 包进行日志记录,比如我们想要在参数解析出错的时候记录一个错误日志如下:



if err := httpx.Parse(r, &req); err != nil { // 解析参数
logx.Errorf("parse req: %v error: %v", req, err)
httpx.Error(w, err)
return
}


访问服务的时候我们故意把参数类型传错,因为 user_id 为 int64 类型,我们传入字符串 aaa,参数解析就会出错,查看 error 日志



http://localhost:9090/user/info?user_id=aaa




{"@timestamp":"2020-12-01T10:37:25.654+08","level":"error","content":"main.go:47 parse req: {0} error: the value \"aaa\" cannot parsed as int"}

rest 框架中还会记录慢日志,当服务的响应时间大于 3s 的时候就会产生慢日志,慢日志自动记录不需要手动配置,慢日志如下



{"@timestamp":"2020-12-01T10:45:47.679+08","level":"slow","content":"[HTTP] 200 - /user/info?user_id=123 - [::1]:60349 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.67 Safari/537.36 - slowcall(5004.3ms)","trace":"401274358783b491","span":"0"}

日志监控对于我们排查问题非常有帮助,但是文件记录的方式并不方便对日志进行检索,在生产环境一般会借助 elk,把日志同步到 elasticsearch 然后通过 kibana 界面实现快速的检索

指标

服务指标监控的种类繁多,可以根据自身的服务特点进行监控,比较通用的指标有比如:接口 QPS、接口耗时、错误率等等,通过对这些指标的监控可以了解到服务的运行时的一些信息,rest 框架默认支持 prometheus 指标收集能力,通过添加 Prometheus 配置即可查看对应的指标信息





有了这些指标信息之后就可以配合 grafana 进行界面化的展示



调用链



在微服务中服务依赖的关系往往比较复杂,那么横跨多个服务的慢请求要如何查询呢?这时候我们需要一个能串联整个调用链路的标识 (traceId) 和表示调用关系的标识 (spanId),也就是使用 traceId 串联起单次请求,用 spanId 记录每一次调用,原理如下图





基于 rest 的 api 服务往往是作为入口,首先会先从 http header 中获取 traceid,如果没有获取到则会生成一个新的 traceid,通过 context 上下文进行传递,我们知道 context 上下文的传递是进程内的,那么跨服务跨进程是如何传递的呢?比如 api 服务调用 rpc 服务其实是利用了 rpc 提供的 metadata 功能,先把上下文中的调用链信息从 context 读取出来存入 metadata,然后再从 metadata 中读取调用链信息再存入 context 中





rest 默认会把调用链信息记录在日志中,通过在 elk 中搜索某一个 taceid 即可得到所有的调用链信息,日志记录如下



{"@timestamp":"2020-12-01T10:03:01.280+08","level":"info","content":"200 - /user/info?user_id=1 - [::1]:58955 - Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.67 Safari/537.36 - 0.4ms","trace":"7a27076abd932c87","span":"0"}

JWT 鉴权

鉴权几乎是每个应用必备的能力,鉴权的方式很多,而 jwt 是其中比较简单和可靠的一种方式,在 rest 框架中内置了 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 来实现的,框架中内置的都是一些通用的中间件,比如业务上有一些特殊的处理我们也可以自定义中间件



func userMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
v := r.URL.Query().Get("user_id")
if v == "1" {
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
}

通过 Use 方法注册中间件



srv.Use(userMiddleware)

当发起请求 user_id 为 1 的时候 http status 就回返回 403 Forbidden



路由原理



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,
},

路由存储的树形结构如下图



当请求来的时候会调用 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 框架的基本使用、服务监控、jwt 鉴权、中间件和路由原理等方面进行了介绍,可见 rest 是一个功能强大的 web 框架,还有很多其他的功能由于篇幅有限后续再详细介绍,希望本篇文章能给大家带来帮助

项目地址

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

框架地址

https://github.com/tal-tech/go-zero/tree/master/rest

文档地址

https://www.yuque.com/tal-tech/go-zero/rhakzy



go-zero在开源3个多月的star曲线如下:



欢迎使用并 star go-zero



发布于: 2020 年 12 月 02 日阅读数: 713
用户头像

Kevin Wan

关注

保持简单 2017.10.24 加入

go-zero作者

评论

发布
暂无评论
go-zero 之 rest 实战与原理