gin

264 阅读41分钟

Go mod

不支持包循环调用,即现在有A包和B包,不能在A包中调用B过后再在B包中调用A

在项目架构时要特别注意

restful架构

REST与技术无关,代表的是一种软件架构风格,RESTRepresentational State Transfer的简称,中文翻译为“表征状态转移”或“表现层状态转化”

简单来说,REST的含义就是客户端与Web服务器之间进行交互的时候,使用HTTP协议中的4个请求方法代表不同的动作。

  • GET用来获取资源
  • POST用来新建资源
  • PUT用来更新资源
  • DELETE用来删除资源 只要API程序遵循了REST风格,那就可以称其为RESTful API。目前在前后端分离的架构中,前后端基本都是通过RESTful API来进行交互

gin框架

开始

下载并安装gin

在goland终端输入

 go get github.com/gin-gonic/gin

大致流程

  1. 初始化Gin实例

     r :=gin.Default()或者r :=gin.New()
     //这里返回的gin.Engine结构体实例
     //gin.Engine是一个容器对象,为整个框架的基础;
     //gin.RouterGroup负责存储所有的中间件,包括请求路径;
     //gin.trees负责存储路由和handle方法的映射,采用的是Radix树的结构;
     type Engine struct {
         RouterGroup
     ​
         // RedirectTrailingSlash enables automatic redirection if the current route can't be matched but a
         // handler for the path with (without) the trailing slash exists.
         // For example if /foo/ is requested but a route only exists for /foo, the
         // client is redirected to /foo with http status code 301 for GET requests
         // and 307 for all other request methods.
         RedirectTrailingSlash bool
     ​
         // RedirectFixedPath if enabled, the router tries to fix the current request path, if no
         // handle is registered for it.
         // First superfluous path elements like ../ or // are removed.
         // Afterwards the router does a case-insensitive lookup of the cleaned path.
         // If a handle can be found for this route, the router makes a redirection
         // to the corrected path with status code 301 for GET requests and 307 for
         // all other request methods.
         // For example /FOO and /..//Foo could be redirected to /foo.
         // RedirectTrailingSlash is independent of this option.
         RedirectFixedPath bool
     ​
         // HandleMethodNotAllowed if enabled, the router checks if another method is allowed for the
         // current route, if the current request can not be routed.
         // If this is the case, the request is answered with 'Method Not Allowed'
         // and HTTP status code 405.
         // If no other Method is allowed, the request is delegated to the NotFound
         // handler.
         HandleMethodNotAllowed bool
     ​
         // ForwardedByClientIP if enabled, client IP will be parsed from the request's headers that
         // match those stored at `(*gin.Engine).RemoteIPHeaders`. If no IP was
         // fetched, it falls back to the IP obtained from
         // `(*gin.Context).Request.RemoteAddr`.
         ForwardedByClientIP bool
     ​
         // AppEngine was deprecated.
         // Deprecated: USE `TrustedPlatform` WITH VALUE `gin.PlatformGoogleAppEngine` INSTEAD
         // #726 #755 If enabled, it will trust some headers starting with
         // 'X-AppEngine...' for better integration with that PaaS.
         AppEngine bool
     ​
         // UseRawPath if enabled, the url.RawPath will be used to find parameters.
         UseRawPath bool
     ​
         // UnescapePathValues if true, the path value will be unescaped.
         // If UseRawPath is false (by default), the UnescapePathValues effectively is true,
         // as url.Path gonna be used, which is already unescaped.
         UnescapePathValues bool
     ​
         // RemoveExtraSlash a parameter can be parsed from the URL even with extra slashes.
         // See the PR #1817 and issue #1644
         RemoveExtraSlash bool
     ​
         // RemoteIPHeaders list of headers used to obtain the client IP when
         // `(*gin.Engine).ForwardedByClientIP` is `true` and
         // `(*gin.Context).Request.RemoteAddr` is matched by at least one of the
         // network origins of list defined by `(*gin.Engine).SetTrustedProxies()`.
         RemoteIPHeaders []string
     ​
         // TrustedPlatform if set to a constant of value gin.Platform*, trusts the headers set by
         // that platform, for example to determine the client IP
         TrustedPlatform string
     ​
         // MaxMultipartMemory value of 'maxMemory' param that is given to http.Request's ParseMultipartForm
         // method call.
         MaxMultipartMemory int64
     ​
         // UseH2C enable h2c support.
         UseH2C bool
     ​
         // ContextWithFallback enable fallback Context.Deadline(), Context.Done(), Context.Err() and Context.Value() when Context.Request.Context() is not nil.
         ContextWithFallback bool
     ​
         delims           render.Delims
         secureJSONPrefix string
         HTMLRender       render.HTMLRender
         FuncMap          template.FuncMap
         allNoRoute       HandlersChain
         allNoMethod      HandlersChain
         noRoute          HandlersChain
         noMethod         HandlersChain
         pool             sync.Pool
         trees            methodTrees
         maxParams        uint16
         maxSections      uint16
         trustedProxies   []string
         trustedCIDRs     []*net.IPNet
     }
    
  2. 注册中间件

    Gin框架中主要包括两种中间件:全局中间件和路由组中间件。

  3. 注册路由

     //一般为以下四种请求:GET,POST,PUT,DELETE
     func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
         return group.handle(http.MethodGet, relativePath, handlers)
     }
     func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes {
         return group.handle(http.MethodPost, relativePath, handlers)
     }
     func (group *RouterGroup) PUT(relativePath string, handlers ...HandlerFunc) IRoutes {
         return group.handle(http.MethodPut, relativePath, handlers)
     }
     func (group *RouterGroup) DELETE(relativePath string, handlers ...HandlerFunc) IRoutes {
         return group.handle(http.MethodDelete, relativePath, handlers)
     }
     //例子
     r.GET("/test",func(c *context){
         c.JSON(http.StatusOK,gin.H{
             "message":"test",
             "statu":200
         })
     }
    
  4. 启动服务

     r.Run()
     //可以指定端口
     //如r.Run(":8080")   r.Run(":9090")
    

获取参数

获取querystring参数

querystring指的是URL中?后面携带的参数,例如:/user/search?username=小王子&address=沙河。 获取请求的querystring参数的方法如下:

 func main() {
     //Default返回一个默认的路由引擎
     r := gin.Default()
     r.GET("/user/search", func(c *gin.Context) {
         username := c.DefaultQuery("username", "小王子")
         //username := c.Query("username")
         address := c.Query("address")
         //输出json结果给调用方
         c.JSON(http.StatusOK, gin.H{
             "message":  "ok",
             "username": username,
             "address":  address,
         })
     })
     r.Run()
 }

获取form参数

当前端请求的数据通过form表单提交时,例如向/user/search发送一个POST请求,获取请求数据的方式如下:

 func main() {
     //Default返回一个默认的路由引擎
     r := gin.Default()
     r.POST("/user/search", func(c *gin.Context) {
         // DefaultPostForm取不到值时会返回指定的默认值
         //username := c.DefaultPostForm("username", "小王子")
         username := c.PostForm("username")
         address := c.PostForm("address")
         //输出json结果给调用方
         c.JSON(http.StatusOK, gin.H{
             "message":  "ok",
             "username": username,
             "address":  address,
         })
     })
     r.Run(":8080")
 }

获取json参数

当前端请求的数据通过JSON提交时,例如向/json发送一个POST请求,则获取请求参数的方式如下:

 r.POST("/json", func(c *gin.Context) {
     // 注意:下面为了举例子方便,暂时忽略了错误处理
     b, _ := c.GetRawData()  // 从c.Request.Body读取请求数据
     // 定义map或结构体
     var m map[string]interface{}
     // 反序列化
     _ = json.Unmarshal(b, &m)
 ​
     c.JSON(http.StatusOK, m)
 })

更便利的获取请求参数的方式,参见下面的 参数绑定 小节。

获取path参数

请求的参数通过URL路径传递,例如:/user/search/小王子/沙河。 获取请求URL路径中的参数的方式如下。

 func main() {
     //Default返回一个默认的路由引擎
     r := gin.Default()
     r.GET("/user/search/:username/:address", func(c *gin.Context) {
         username := c.Param("username")
         address := c.Param("address")
         //输出json结果给调用方
         c.JSON(http.StatusOK, gin.H{
             "message":  "ok",
             "username": username,
             "address":  address,
         })
     })
 ​
     r.Run(":8080")
 }

参数绑定

将参数绑定到结构体时

若是form-data类型数据,其自动提取时是基于结构体字段后的form标签

若是json类型数据,其自动提取时是基于结构体字段后的json标签

若是QueryString类型数据(get请求),其自动提取时是基于结构体字段后的form标签

 // c.ShouldBind()会根据请求的Content-Type自行选择绑定器
 type userInfo struct {
     //注意结构体字段要大写,不然是不可导出的,若想返回数据时为小写,应该使用json标签
     Username string `json:"username" form:"user" binding:"required"`
     Password string `json:"password" form:"pass" binding:"required"`
     //binding搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端
 }
 ​
 func main() {
     r := gin.Default()
     r.POST("/user", func(c *gin.Context) {
         var u userInfo
         err := c.ShouldBind(&u)
         if err != nil {
             c.JSON(http.StatusInternalServerError, gin.H{
                 "message": "Bind Failed",
             })
             return
         }
         c.JSON(http.StatusOK, u)
     })
     r.GET("/user", func(c *gin.Context) {
         var u userInfo
         err := c.ShouldBind(&u)
         if err != nil {
             c.JSON(http.StatusInternalServerError, gin.H{
                 "message": "Bind Failed",
             })
             return
         }
         c.JSON(http.StatusOK, u)
     })
 //post发送:user="zxj"&pass="hhhhh"
 //返回:{username:"zxj",password:"hhhhh"}
 ​
     
 //发送:{username:"cqqqq",password:"777777"}
 //返回:{username:"cqqqq",password:"777777"}
     
     
 //get发送:user="zxj"&pass="hhhhh"
 //返回:{username:"zxj",password:"hhhhh"}

ps:ShouldBind会按照下面的顺序解析请求中的数据完成绑定:

  1. 如果是 GET 请求,只使用 Form 绑定引擎(query)。
  2. 如果是 POST 请求,首先检查 content-type 是否为 JSONXML,然后再使用 Formform-data)。

重定向

http重定向

概念

URL 重定向,也称为 URL 转发,是一种当实际资源,如单个页面、表单或者整个 Web 应用被迁移到新的 URL 下的时候,保持(原有)链接可用的技术。HTTP 协议提供了一种特殊形式的响应—— HTTP 重定向(HTTP redirects)来执行此类操作。

重定向可实现许多目标:

  • 站点维护或停机期间的临时重定向。
  • 永久重定向将在更改站点的 URL,上传文件时的进度页等之后保留现有的链接/书签。
  • 上传文件时的表示进度的页面。

通俗来讲就是从一个页面跳转到另一个页面

分类
  1. 内部重定向(重写)发生在server内部,client不知情,即浏览器上显示的是访问a.html但实际上收到的却是b.php,即url不发生改变的重定向
  2. 外部重定向则是server端通知client端需要更改URL,并舍弃之前的request。client按要求发送第二个request,server发送对应的文件。如,client第一次发送request要求访问a.html,server告知其这个url已经更改成b.php了,于是client发送request要求访问b.php,server发送b.php给client,并且此时用户看见浏览器URL栏里显示的是.../b.php

gin框架下的重定向

HTTP重定向

HTTP 重定向很容易。 内部、外部重定向均支持。

 //此时访问/test就会跳转到搜狗
 r.GET("/test", func(c *gin.Context) {
     c.Redirect(http.StatusMovedPermanently, "http://www.sogo.com/")
 })
路由重定向

路由重定向,使用HandleContext

 //访问/test时会去执行url为/test2的路由处理函数
 //url不会发生改变
 r.GET("/test", func(c *gin.Context) {
     // 指定重定向的URL
     c.Request.URL.Path = "/test2"
     r.HandleContext(c)
 })
 r.GET("/test2", func(c *gin.Context) {
     c.JSON(http.StatusOK, gin.H{"hello": "world"})
 })

路由

普通路由

 r.GET("/index", func(c *gin.Context) {...})
 r.GET("/login", func(c *gin.Context) {...})
 r.POST("/login", func(c *gin.Context) {...})

有一个可以匹配所有请求方法的Any方法如下:

 r.Any("/test", func(c *gin.Context) {...})

当用户输入的url没有绑定路由时,可以使用以下程序处理

 r.NoRoute(func(c *gin.Context) {...})

路由组

将拥有共同URL前缀的路由划分为一个路由组。习惯性用一个大括号{}将其包含在一起,以突出他的整体性(不用{}也不影响功能)

 func main() {
     r := gin.Default()
     userGroup := r.Group("/user")
     {
         userGroup.GET("/index", func(c *gin.Context) {...})
         userGroup.GET("/login", func(c *gin.Context) {...})
         userGroup.POST("/login", func(c *gin.Context) {...})
 ​
     }
     //这一组都拥有/user前缀
     shopGroup := r.Group("/shop")
     {
         shopGroup.GET("/index", func(c *gin.Context) {...})
         shopGroup.GET("/cart", func(c *gin.Context) {...})
         shopGroup.POST("/checkout", func(c *gin.Context) {...})
     }
     //这一组都拥有/shop前缀
     r.Run()
 }

路由组也可以嵌套使用

 r:=gin.Default()
 userGroup :=r,Group("/user")
     {
         userGroup.GET("/index", func(c *gin.Context) {...})
         userGroup.GET("/login", func(c *gin.Context) {...})
         userGroup.POST("/login", func(c *gin.Context) {...})
         XXXGroup :=userGroup("/username")
         xxxGroup.GET("/aa",func(c *context){...})
         xxxGroup.GET("/oo",func(c *context){...})
     }

中间件

Gin框架允许开发者在处理请求的过程中 ,加入用户自己的逻辑函数(Hook函数),这个逻辑函数就被成为中间件,中间件适合处理一些公共逻辑,比如登录认证、权限校验、记录日志、耗时统计等。

image-20221118000008994

使用中间件

gin的中间件必须是一个gin.HandlerFunc类型,可以搭配c.Next(调用该请求的全部剩余处理程序,执行完后返回上一层处理程序)和c.Abort(不调用该请求的剩余处理程序,但会将自己执行完,可以搭配return使用)使用

 type HandlerFunc func(*Context)
  1. 普通路由中间件

     func main() {
         r := gin.Default()
         r.GET("/hello", a, func(c *gin.Context) {
             c.JSON(http.StatusOK, "你好")
         })
         r.Run()
     }
     func a(c *gin.Context) {
         c.JSON(http.StatusOK, "hello")
     }
    
  2. 全局中间件

     func main() {
         r := gin.Default()
         r.Use(a)
         //全局中间件最先执行
         r.GET("/hello", b, func(c *gin.Context) {
             c.JSON(http.StatusOK, "你好")
         })
         r.GET("/nihao", func(c *gin.Context) {
             c.JSON(http.StatusOK, "hhhh")
         })
         r.Run()
     }
     func a(c *gin.Context) {
         c.JSON(http.StatusOK, "hello")
     }
     func b(c *gin.Context) {
         c.JSON(http.StatusOK, "hello~")
     }
    
  3. 路由组的中间件

     //路由组的中间件相当于该路由组的全局中间件
     func main() {
         r := gin.Default()
         //路由组中间件在路由组中最先执行
         user := r.Group("/user", a)
         {
             user.GET("/aa", func(c *gin.Context) {
                 c.JSON(http.StatusOK, "hello~~")
             })
             user.GET("bb", func(c *gin.Context) {
                 c.JSON(http.StatusOK, "hello~~~")
             })
         }
         //以下写法跟上面等效
         user2 := r.Group("/user2", a)
         user2.Use(a)
         {
             user2.GET("/aa", func(c *gin.Context) {
                 c.JSON(http.StatusOK, "hello~~")
             })
             user2.GET("bb", func(c *gin.Context) {
                 c.JSON(http.StatusOK, "hello~~~")
             })
         }
     func a(c *gin.Context) {
         c.JSON(http.StatusOK, "hello~")
     }
    
  4. 一些常见中间件

     //统计请求耗时的中间件
     func StatCost(c *gin.Context) {
         start := time.Now()
         // 调用该请求的剩余处理程序
         c.Next()
         // 计算耗时
         cost := time.Since(start)
         fmt.Println(cost)
     }
    

中间件注意事项

  1. gin.Default()默认使用了LoggerRecovery中间件,其中:

    • Logger中间件将日志写入gin.DefaultWriter,即使配置了GIN_MODE=release
    • Recovery中间件会recover任何panic。如果有panic的话,会写入500响应码。
     func Default() *Engine {
         debugPrintWARNINGDefault()
         engine := New()
         engine.Use(Logger(), Recovery())
         return engine
     }
     func Logger() HandlerFunc {
         return LoggerWithConfig(LoggerConfig{})
     }
     func Recovery() HandlerFunc {
         return RecoveryWithWriter(DefaultErrorWriter)
     }
    

如果不想使用这两个中间件,可以直接使用c :=gin.New

  1. 当在中间件或handler中启动新的goroutine时,不能使用原始的上下文(c *gin.Context),必须使用其只读副本(c.Copy()),否则是并发不安全

    go func(*gin.Context){}(c.Copy())

跨域

什么是跨域

  • 域: 是指浏览器不能执行其他网站的脚本
  • 跨域: 它是由浏览器的 同源策略 造成的,是浏览器对 JavaScript 实施的安全限制,所谓同源(即指在同一个域)就是两个页面具有相同的协议 protocol,主机 host 和端口号 port 则就会造成 跨域

解决跨域

使用CORS(跨资源共享)中间件

在 api 中新建一个 middware 文件夹,并在下面新建 cors.go

api/middleware/cors.go
 package middleware
 ​
 import "github.com/gin-gonic/gin"
 ​
 func CORS() gin.HandlerFunc {
     return func(ctx *gin.Context) {
         ctx.Writer.Header().Set("Access-Control-Allow-Origin", "*")
         ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
         ctx.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, token, x-access-token")
         ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
 ​
         if ctx.Request.Method == "OPTIONS" {
             ctx.AbortWithStatus(204)
             return
         }
         ctx.Next()
     }
 }

接下来在 api/router.go 中使用此中间件。

 package api
 ​
 import (
     "gin-demo/api/middleware"
     "github.com/gin-gonic/gin"
 )
 ​
 func InitRouter() {
     r := gin.Default()
     r.Use(middleware.CORS())
 ​
     r.POST("/register", register) // 注册
     r.POST("/login", login)       // 登录
 ​
     r.Run(":8088") // 跑在 8088 端口上
 }

CORS 中间件建议每次都加上。

状态保持

HTTP 是一种无状态的协议 这样的设计简化了服务器的设计 由于不保存之前的一切请求的信息, 这样使得http能够更快的处理事务,确保协议的可伸缩性, 但在一些情境下 一些站点需要具备识别用户的能力 以便能够提升用户体验 或者 限制某些行为

实现状态保持

在实际生产中常用的是 Cookie SessionToken 这三种手段

  • Cookie HTTP Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据, 它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器, 如保持用户的登录状态。Cookie 使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能,
    • Cookie 的创建 当服务器收到 HTTP 请求时,服务器可以在响应头里面添加一个 Set-Cookie选项 Set-Cookie: <cookie 名>=<cookie 值> 浏览器收到带有该请求的响应后 将Cookie信息保存在本地 以便后续使用
    • Cookie的请求 当收到Set-Cookie的响应后 后续再访问该服务器的时候就会在请求头带上该CookieCookie:key=value 由此来实现有状态的连接
    • 弊端 由于每次请求都要带上Cookie 带来了额外的性能开销 同时由于Cookie保存在用户本地 用户可以查阅和修改 同时Cookie可能会被不法分子盗用,造成损失,所以,一般情况下不建议使用Cookie 或者在使用的时候对信息进行加密
    • Cookie 拓展 Cookie支持 Expires(过期时间设置) SecureHttpOnly(访问限制) Domain (指定主机接收)Path (路径限制) 等手段
    • image-20221121220717079
  • Session 借助Cookie实现 当用户访问服务器的时候,服务器将用户状态记录在服务器上 同时生成一个唯一的 session ID 用来标识用户 并通过Cookie 返回给客户端,以后在访问的时候携带session ID 与服务器上已经存在的进行比对 获取用户状态
  • Token 客户端请求的时候 将用户信息以及一些其他参数通过服务端设定的加密算法进行加密之后,发送回服务端,服务端收到后 将他存储起来,比如放在Cookie里 或者 Local Storage 客户端每次请求的时候携带所颁发的Token 服务端收到后根据之前的的加密算法进行校验 校验成功后执行用户后续操作,相较于其他两个,其应用范围更加广泛 ,实现更加灵活,安全性也得到了一定的保证。

JWT(JSON Web Token)

什么是JWT

JWT全称JSON Web Token是一种跨域认证解决方案,属于一个开放的标准,它规定了一种Token实现方式,目前多用于前后端分离项目和OAuth2.0业务场景下。

JWT的数据结构

实际的 JWT 大概就像下面这样。

img

它是一个很长的字符串,中间用点(.)分隔成三个部分。注意,JWT 内部是没有换行的,这里只是为了便于展示,将它写成了几行。

JWT 的三个部分依次如下。

  • Header(头部)
  • Payload(负载)
  • Signature(签名)

写成一行,就是下面的样子。

 Header.Payload.Signature

img

下面依次介绍这三个部分。

Header

Header 部分是一个 JSON 对象,描述 JWT 的元数据,通常是下面的样子。

 {
 "alg": "HS256",
 "typ": "JWT"
 }

上面代码中,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT

最后,将上面的 JSON 对象使用 Base64URL 算法(详见后文)转成字符串。

Payload

Payload 部分也是一个 JSON 对象,用来存放实际需要传递的数据。JWT 规定了7个官方字段,供选用。

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。

 {
 "sub": "1234567890",
 "name": "John Doe",
 "admin": true
 }

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

这个 JSON 对象也要使用 Base64URL 算法转成字符串。

Signature

Signature 部分是对前两部分的签名,防止数据篡改。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

 HMACSHA256(
 base64UrlEncode(header) + "." +
 base64UrlEncode(payload),
 secret)

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。

Base64URL

前面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。

JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+/=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-/替换成_ 。这就是 Base64URL 算法。

JWT在go中大致使用流程

引包

 go get github.com/dgrijalva/jwt-go
  1. 创建一个claim,包含想在要JWT中存储的信息和jwt.StandardClaims,例如想要在jwt中存username

     type MyClaims struct{
         username string
         StandardClaims jwt.StandardClaims
     }
     ​
     type StandardClaims struct {
         Audience  string `json:"aud,omitempty"`//受众
         ExpiresAt int64  `json:"exp,omitempty"`//过期时间
         Id        string `json:"jti,omitempty"`//编号
         IssuedAt  int64  `json:"iat,omitempty"`//签发时间
         Issuer    string `json:"iss,omitempty"`//签发人
         NotBefore int64  `json:"nbf,omitempty"`//生效时间
         Subject   string `json:"sub,omitempty"`//主题
     }
    
  2. 生成token,一般放在登录程序中,用户成功登录就生成一个token

     var Secret =[]byte("zxj")//这个很重要,注意是字节类型的切片
     ​
     // GenToken 生成token
     func GenToken(username string) string {
         //创建claim实例
         c := model.MyClaims{
             Username: username, StandardClaims: jwt.StandardClaims{
                 ExpiresAt: time.Now().Add(TokenExpireDuration).Unix(),
                 IssuedAt:  time.Now().Unix(),
                 Issuer:    "localhost",
             },
         }
         //这里用HS256加密,并用c这个claim实例生成了一个token实例
         token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
         //这里用Secret对token签名,生成一个字符串,这个字符串就是凭证(令牌)
         tokenString, err := token.SignedString(Secret)
         if err != nil {
             return ""
         }
         return tokenString
     }
    
  3. 编写基于JWT的鉴权中间件

     func JWTAuthMiddleware(c *gin.Context) {
         // 客户端携带Token有三种方式 1.放在请求头 2.放在请求体 3.放在URI
         // 这里假设Token放在Header的Authorization中,并使用Bearer开头
         header := c.Request.Header.Get("Authorization")
         if header == "" {
             c.JSON(http.StatusOK, gin.H{"message": "empty header"})
             c.Abort()
             return
         }
         //因为使用Bearer开头,要把Bearer与token主体分开,这里第一部分是Bearer,第二部分是token
         parts := strings.SplitN(header, " ", 2)
         //判断格式
         if !(len(parts) == 2 && parts[0] == "Bearer") {
             c.JSON(http.StatusOK, gin.H{
                 "code": 2004,
                 "msg":  "wrong format",
             })
             c.Abort()
             return
         }
         TokenString := parts[1]
         mc, err := ParseToken(TokenString)
         if err != nil {
             c.JSON(http.StatusOK, gin.H{
                 "message": "Invalid Token",
             })
             c.Abort()
             return
         }
         // 将当前请求的username信息保存到请求的上下文c上
         c.Set("username", mc.Username)
         c.Next() // 后续的处理函数可以用过c.Get("username")或者c.MustGet("username")来获取当前请求的用户信息
     }
     ​
     // ParseToken 解析token
     func ParseToken(TokenString string) (*model.MyClaims, error) {
         var mc = new(model.MyClaims)
         //把token解析后与claim实例绑定,这里就是放在mc中,注意要传指针
         token, err := jwt.ParseWithClaims(TokenString, mc, func(token *jwt.Token) (i interface{}, err error) {
             return Secret, nil
         })
         if err != nil {
             return nil, err
         }
         if token.Valid {
             return mc, nil
         }
         return nil, errors.New("invalid token")
    

JWT-Go中几个比较重要的结构

Claims
 type Claims interface {
     Valid() error
 }

claims是一个实现了Vaild方法的接口,Valid方法用于判断该claim是否合法

Keyfunc
 type Keyfunc func(*Token) (interface{}, error)

Keyfunc在使用时一般都是返回secret密钥,可以根据Token的种类不同返回不同的密钥

 官方文档:This allows you to use properties in the Header of the token (such as 'kid') to identify which key to use.
Mapclaims
 type MapClaims map[string]interface{}

一个用于放decode出来的claim的map,有Vaild和一系列VerifyXXX的方法

Parser
 type Parser struct {
     ValidMethods         []string // 有效的加密方法列表,如果不为空,则Parse.Method.Alg()必需是VaildMethods的一种,否则报错
     UseJSONNumber        bool     // Use JSON Number format in JSON decoder
     SkipClaimsValidation bool     // 在解析token时跳过claims的验证
 }

用来将tokenstr转换成token

SigningMethod
 type SigningMethod interface {
     Verify(signingString, signature string, key interface{}) error // Returns nil if signature is valid
     Sign(signingString string, key interface{}) (string, error)    // Returns encoded signature or error
     Alg() string                                                   // returns the alg identifier for this method (example: 'HS256')
 }

签名方法的接口,可以通过实现这个接口自定义签名方法,jwt-go内置一些实现了SigningMethod的结构体

StandardClaims
 type StandardClaims struct {
     Audience  string `json:"aud,omitempty"` 
     ExpiresAt int64  `json:"exp,omitempty"`
     Id        string `json:"jti,omitempty"`
     IssuedAt  int64  `json:"iat,omitempty"`
     Issuer    string `json:"iss,omitempty"`
     NotBefore int64  `json:"nbf,omitempty"`
     Subject   string `json:"sub,omitempty"`
 }

jwt官方规定的一些预定义的payload:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号
Token
 type Token struct {
     Raw       string                 // The raw token.  Populated when you Parse a token
     Method    SigningMethod          // The signing method used or to be used
     Header    map[string]interface{} // The first segment of the token
     Claims    Claims                 // The second segment of the token
     Signature string                 // The third segment of the token.  Populated when you Parse a token
     Valid     bool                   // Is the token valid?  Populated when you Parse/Verify a token
 }

Token的结构体

ValidationError
 type ValidationError struct {
     Inner  error  // stores the error returned by external dependencies, i.e.: KeyFunc
     Errors uint32 // bitfield.  see ValidationError... constants
     // contains filtered or unexported fields
 }

定义解析Token时遇到的一些错误

常用组件

Zap日志库

github.com/uber-go/zap…

在许多Go语言项目中,我们需要一个好的日志记录器能够提供下面这些功能:

  • 能够将事件记录到文件中,而不是应用程序控制台。
  • 日志切割-能够根据文件大小、时间或间隔等来切割日志文件。
  • 支持不同的日志级别。例如INFO,DEBUG,ERROR等。
  • 能够打印基本信息,如调用文件/函数名和行号,日志时间等。

Zap日志库的优点

  1. 它同时提供了结构化日志记录和printf风格的日志记录
  2. 速度非常快

安装

终端输入命令

 go get -u go.uber.org/zap

配置Zap Logger

两种日志记录器Sugared LoggerLogger

  1. 在性能很好但不是很关键的上下文中,使用SugaredLogger。它比其他结构化日志记录包快4-10倍,并且支持结构化和printf风格的日志记录。
  2. 在每一微秒和每一次内存分配都很重要的上下文中,使用Logger。它甚至比SugaredLogger更快,内存分配次数也更少,但它只支持强类型的结构化日志记录。

这两种logger支持互相替换

 func(Loger *zap.Logger)Sugar()*SugaredLogger//生成SugareLogger
 func(SugareLogger *zap.SugaredLogger)Desugar() *zap.Logger//生成一个Logger
Logger
  • 通过调用zap.NewProduction()/zap.NewDevelopment()或者zap.New()创建一个Logger。

  • 上面的每一个函数都将返回一个Logger的指针。唯一的区别在于它将记录的信息不同。例如production logger默认记录调用函数信息、日期和时间等。

  • 通过Logger调用Info/Error等。

  • 默认情况下日志都会打印到应用程序的console界面。

     var zapLogger *zap.Logger
     ​
     func main() {
         zapLogger.Info("hello", zap.String("name", "tom"))
     }
     func init() {
         zapLogger, _ = zap.NewProduction()//返回一个json格式的数据
     }
     //output:{"level":"info","ts":1669303320.870681,"caller":"main/main.go:8","msg":"hello","name":"tom"}
     ​
     ​
     func main() {
         zapLogger.Info("hello", zap.String("name", "tom"))
     }
     func init() {
         zapLogger, _ = zap.NewDevelopment()
     }
     //output:2022-11-24T23:31:28.217+0800    INFO    main/main.go:8  hello   {"name": "tom"}
    

zap.Logger是一个结构体,有以下方法

 (*Logger):
           Sugar() *SugaredLogger//生成一个SugaredLogger
           Named(s string) *Logger
           WithOptions(opts ...Option) *Logger
           With(fields ...Field) *Logger
           Check(lvl zapcore.Level, msg string) *zapcore.CheckedEntry
           Log(lvl zapcore.Level, msg string, fields ...Field)
           Debug(msg string, fields ...Field)//Debug级别的日志
           Info(msg string, fields ...Field)//Info级别的日志
           Warn(msg string, fields ...Field)//Warn级别的日志
           Error(msg string, fields ...Field)//Error级别的日志
           DPanic(msg string, fields ...Field)//DPanic级别的日志
           Panic(msg string, fields ...Field)//Panic级别的日志
           Fatal(msg string, fields ...Field)//Fatal级别的日志
           Sync() error
           Core() zapcore.Core
           clone() *Logger
           check(lvl zapcore.Level, msg string) *zapcore.CheckedEntry
SugareLogger
  • SugareLogger支持Log/f/ln等方法

使用一个Logger调用Sugar方法来生成

 var Logger *zap.SugaredLogger
 ​
 func main() {
     Logger.Info("hello")
 }
 func init() {
     l, _ := zap.NewProduction()
     Logger = l.Sugar()
 }
 //output: {"level":"info","ts":1669304744.2213397,"caller":"main/main.go:8","msg":"hello"}
 ​
 func main() {
     Logger.Info("hello")
 }
 func init() {
     l, _ := zap.NewDevelopment()
     Logger = l.Sugar()
 }
 //output: 2022-11-24T23:46:37.952+0800    INFO    main/main.go:8  hello

zap.SugareLogger是一个结构体,有以下方法

 (*SugaredLogger):
                  Desugar() *zap.Logger//生成一个Logger
                  Named(name string) *zap.SugaredLogger
                  WithOptions(opts ...zap.Option) *zap.SugaredLogger
                  With(args ...interface{}) *zap.SugaredLogger
                  Debug(args ...interface{})
                  Info(args ...interface{})
                  Warn(args ...interface{})
                  Error(args ...interface{})
                  DPanic(args ...interface{})
                  Panic(args ...interface{})
                  Fatal(args ...interface{})
                  Debugf(template string, args ...interface{})
                  Infof(template string, args ...interface{})
                  Warnf(template string, args ...interface{})
                  Errorf(template string, args ...interface{})
                  DPanicf(template string, args ...interface{})
                  Panicf(template string, args ...interface{})
                  Fatalf(template string, args ...interface{})
                  Debugw(msg string, keysAndValues ...interface{})
                  Infow(msg string, keysAndValues ...interface{})
                  Warnw(msg string, keysAndValues ...interface{})
                  Errorw(msg string, keysAndValues ...interface{})
                  DPanicw(msg string, keysAndValues ...interface{})
                  Panicw(msg string, keysAndValues ...interface{})
                  Fatalw(msg string, keysAndValues ...interface{})
                  Debugln(args ...interface{})
                  Infoln(args ...interface{})
                  Warnln(args ...interface{})
                  Errorln(args ...interface{})
                  DPanicln(args ...interface{})
                  Panicln(args ...interface{})
                  Fatalln(args ...interface{})
                  Sync() error
                  log(lvl zapcore.Level, template string, fmtArgs []interface{}, context []interface{})
                  logln(lvl zapcore.Level, fmtArgs []interface{}, context []interface{})
                  sweetenFields(args []interface{}) []zap.Field
定制化Logger

使用zap.New()函数生成Logger,手动为其传入参数,实现定制化的Logger

 //通过配置Core来配置Logger、
 //这里可以在第二个参数传入zap.AddCaller()来获取详细的函数调用信息
 //如果函数调用链比较长,可以再传入zap.AddCallerSkip()来获取准确的调用信息
 func New(core zapcore.Core, options ...Option) *Logger

这里的Core使用zapcore.NewCore生成

 func NewCore(enc Encoder, ws WriteSyncer, enab LevelEnabler) Core

NewCore需要三个参数——EncoderWriteSyncerLevelEnabler

  1. Encoder:编码器(如何写入日志)

    使用函数NewJSONEncoder或者NewConsoleEncoder来生成,需要传入一个配置结构体EncoderConfig来配置Encoder

     //传入一个配置Encoder的结构体EncoderConfig,返回一个Encoder实例
     func NewJSONEncoder(cfg EncoderConfig) Encoder//这里生成的Logger会以json格式写日志
     func NewConsoleEncoder(cfg EncoderConfig) Encoder
     ​
     //这里直接使用预先设置的NewProductionEncoderConfig函数来生成一个已经配置好了的结构体
     //可以对生成的结构体的字段进行修改或者自行编写一个结构体实例,以达到配置某一具体内容的目的
     //直接返回一个配置好了的EncoderConfig实例
     func NewProductionEncoderConfig() zapcore.EncoderConfig
     ​
     //示例
     enc :=zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig())
    

    配置结构体EncoderConfig的具体内容

     // An EncoderConfig allows users to configure the concrete encoders supplied by
     // zapcore.
     type EncoderConfig struct {
         // Set the keys used for each log entry. If any key is empty, that portion
         // of the entry is omitted.
         MessageKey     string `json:"messageKey" yaml:"messageKey"`
         LevelKey       string `json:"levelKey" yaml:"levelKey"`
         TimeKey        string `json:"timeKey" yaml:"timeKey"`
         NameKey        string `json:"nameKey" yaml:"nameKey"`
         CallerKey      string `json:"callerKey" yaml:"callerKey"`
         FunctionKey    string `json:"functionKey" yaml:"functionKey"`
         StacktraceKey  string `json:"stacktraceKey" yaml:"stacktraceKey"`
         SkipLineEnding bool   `json:"skipLineEnding" yaml:"skipLineEnding"`
         LineEnding     string `json:"lineEnding" yaml:"lineEnding"`
         // Configure the primitive representations of common complex types. For
         // example, some users may want all time.Times serialized as floating-point
         // seconds since epoch, while others may prefer ISO8601 strings.
         EncodeLevel    LevelEncoder    `json:"levelEncoder" yaml:"levelEncoder"`
         EncodeTime     TimeEncoder     `json:"timeEncoder" yaml:"timeEncoder"`
         EncodeDuration DurationEncoder `json:"durationEncoder" yaml:"durationEncoder"`
         EncodeCaller   CallerEncoder   `json:"callerEncoder" yaml:"callerEncoder"`
         // Unlike the other primitive type encoders, EncodeName is optional. The
         // zero value falls back to FullNameEncoder.
         EncodeName NameEncoder `json:"nameEncoder" yaml:"nameEncoder"`
         // Configure the encoder for interface{} type objects.
         // If not provided, objects are encoded using json.Encoder
         NewReflectedEncoder func(io.Writer) ReflectedEncoder `json:"-" yaml:"-"`
         // Configures the field separator used by the console encoder. Defaults
         // to tab.
         ConsoleSeparator string `json:"consoleSeparator" yaml:"consoleSeparator"`
     }
     ​
     //修改时间为我们可读的,而不是时间戳
     //在日志中用大写字母记录日志级别
     encConfig := zap.NewProductionEncoderConfig()
     encConfig.EncodeTime = zapcore.ISO8601TimeEncoder
     encConfig.EncodeLevel = zapcore.CapitalLevelEncoder
    
  2. WriteSyncer:指定写日志的目的地,就是写到哪里去

    使用AddSync函数生成

     //传入写日志的目的地,返回一个WriteSyncer实例
     func AddSync(w io.Writer) WriteSyncer
     ​
     //例子
     file, err := os.OpenFile("log.log", os.O_APPEND|os.O_WRONLY, 0666)
         if err != nil {
             return
         }
     WriteSyncer := zapcore.AddSync(file)//将日志写入log.log文件中去
     ​
     //可以使用io.MultiWriter函数实现多目的地输入
     func MultiWriter(writers ...Writer) Writer
     ​
     //例子
     file, err := os.OpenFile("log.log", os.O_APPEND|os.O_WRONLY, 0666)
         if err != nil {
             return
         }
     WriteSyncer := zapcore.AddSync(io.MultiWriter(file,os.Stdout))//将日志同时记录在log.log文件中与终端上
    
  3. LogLevel:指定哪种级别的日志会被记录

    直接使用zapcore包里面定义的常量

     //这里的日志是从低到高的,传入了某一个日志级别,那只有高于或者等于该级别的日志才能被记录
     const (
         // DebugLevel logs are typically voluminous, and are usually disabled in
         // production.
         DebugLevel Level = iota - 1
         // InfoLevel is the default logging priority.
         InfoLevel
         // WarnLevel logs are more important than Info, but don't need individual
         // human review.
         WarnLevel
         // ErrorLevel logs are high-priority. If an application is running smoothly,
         // it shouldn't generate any error-level logs.
         ErrorLevel
         // DPanicLevel logs are particularly important errors. In development the
         // logger panics after writing the message.
         DPanicLevel
         // PanicLevel logs a message, then panics.
         PanicLevel
         // FatalLevel logs a message, then calls os.Exit(1).
         FatalLevel
     ​
         _minLevel = DebugLevel
         _maxLevel = FatalLevel
     ​
         // InvalidLevel is an invalid value for Level.
         //
         // Core implementations may panic if they see messages of this level.
         InvalidLevel = _maxLevel + 1
     )
    

定制化logger示例

 var logger *zap.Logger
 ​
 func main() {
     logger.Info("hello")
 }
 func init() {
     //这里生成enc指定了日志是json格式的
     //如果想要普通格式就使用zapcore.NewConsoleEncoder()
     enc := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig())
     //指定向log.log文件里面以追加的形式记录日志
     file, err := os.OpenFile("log.log", os.O_APPEND|os.O_WRONLY, 0666)
     if err != nil {
         return
     }
     WriteSyncer := zapcore.AddSync(file)
     //第三个参数传入Info级别,代表只有等于或高于Info级别的日志才能被记录下来
     core := zapcore.NewCore(enc, WriteSyncer, zapcore.InfoLevel)
     logger = zap.New(core)
 }
 ​
 //output:{"level":"info","ts":1669308920.4415178,"msg":"hello"}

实现将某一级别的日志单独写在一个文件里面时使用zapcore.NewTee方法实现

 // NewTee creates a Core that duplicates log entries into two or more underlying Cores.
 // NewTee函数实现了将多个Core的功能集合在一起,生成一个新的Core
 func NewTee(cores ...Core) Core

例子

 func InitLogger() {
     encoder := zapcore.NewConsoleEncoder(zap.NewProductionEncoderConfig())
     // test.log记录全量日志
     logF, _ := os.Create("./test.log")
     c1 := zapcore.NewCore(encoder, zapcore.AddSync(logF), zapcore.DebugLevel)
     // test.err.log记录ERROR级别的日志
     errF, _ := os.Create("./test.err.log")
     c2 := zapcore.NewCore(encoder, zapcore.AddSync(errF), zap.ErrorLevel)
     // 使用NewTee将c1和c2合并到core
     core := zapcore.NewTee(c1, c2)
     logger = zap.New(core, zap.AddCaller())
 }
定制化SugareLogger

先定制化Logger,然后调用Sugar方法来转换成SugareLogger

日志切割归档

若项目业务量庞大,日志也会很大,如果不对日志进行切割,操作起来会很麻烦。

Zap不支持对日志的切割归档,需要使用第三方库Lumberjack来实现。

导入
 go get gopkg.in/natefinch/lumberjack.v2
使用

在zap中使用lumberjack需要将一个&lumberjack.Logger实例添加到WriteSyncer

//例子
lumberjackLogger := &lumberjack.Logger{
		Filename:   "log.log",  //文件名称问log.log
		MaxAge:     30,   //最大备份天数为30
		MaxSize:    10,   //最大10Mb
		MaxBackups: 5,    //最大备份数量为5
		Compress:   false,  //不压缩
}
	WriteSyncer := zapcore.AddSync(lumberjackLogger)
	core := zapcore.NewCore(enc, WriteSyncer, zapcore.InfoLevel)

Lumberjack Logger采用以下属性作为输入:

  • Filename: 日志文件的位置
  • MaxSize:在进行切割之前,日志文件的最大大小(以MB为单位)
  • MaxBackups:保留旧文件的最大个数
  • MaxAges:保留旧文件的最大天数
  • Compress:是否压缩/归档旧文件

Logger 在第一次写入时打开或创建日志文件。如果文件存在且小于 MaxSize 的值,lumberjack 将打开并追加到该文件。如果文件存在且其 size 的值为大于等于 MaxSize,文件通过将当前时间作为文件名的一部分进行重命名文件,然后使用原始文件名创建新的日志文件。

每当写入会导致当前日志文件超过 MaxSize 的值时,当前文件将关闭和重命名,并且使用原始名称创建的新日志文件。

在gin中使用zap

gin.Default中,默认使用了两个中间件来记录日志

//使用了Logger()和Recover()来记录日志
//这实现了在终端输出日志并在出现错误时及时恢复
func Default() *Engine {
	debugPrintWARNINGDefault()
	engine := New()
	engine.Use(Logger(), Recovery())
	return engine
}

在gin中使用zap,就是使用zap模仿gin框架源码来编写两个中间件,使用gin.New函数来生成Engine,再使用r.Use函数使用这两个中间件

 // GinLogger 接收gin框架默认的日志
 //记录一些默认的信息
 //传入一个已经配置好的Logger
 func GinLogger(logger *zap.Logger) gin.HandlerFunc {
     return func(c *gin.Context) {
         start := time.Now()
         path := c.Request.URL.Path
         query := c.Request.URL.RawQuery
         c.Next()
 ​
         cost := time.Since(start)
         logger.Info(path,
             zap.Int("status", c.Writer.Status()),
             zap.String("method", c.Request.Method),
             zap.String("path", path),
             zap.String("query", query),
             zap.String("ip", c.ClientIP()),
             zap.String("user-agent", c.Request.UserAgent()),
             zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),
             zap.Duration("cost", cost),
         )
     }
 }
 ​
 // GinRecovery recover掉项目可能出现的panic
 //传入一个配置好的Logger和一个bool值,第二个参数决定是否记录堆栈的信息
 func GinRecovery(logger *zap.Logger, stack bool) gin.HandlerFunc {
     return func(c *gin.Context) {
         defer func() {
             if err := recover(); err != nil {
                 // Check for a broken connection, as it is not really a
                 // condition that warrants a panic stack trace.
                 var brokenPipe bool
                 if ne, ok := err.(*net.OpError); ok {
                     if se, ok := ne.Err.(*os.SyscallError); ok {
                         if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
                             brokenPipe = true
                         }
                     }
                 }
 ​
                 httpRequest, _ := httputil.DumpRequest(c.Request, false)
                 if brokenPipe {
                     logger.Error(c.Request.URL.Path,
                         zap.Any("error", err),
                         zap.String("request", string(httpRequest)),
                     )
                     // If the connection is dead, we can't write a status to it.
                     c.Error(err.(error)) // nolint: errcheck
                     c.Abort()
                     return
                 }
 ​
                 if stack {
                     logger.Error("[Recovery from panic]",
                         zap.Any("error", err),
                         zap.String("request", string(httpRequest)),
                         zap.String("stack", string(debug.Stack())),
                     )
                 } else {
                     logger.Error("[Recovery from panic]",
                         zap.Any("error", err),
                         zap.String("request", string(httpRequest)),
                     )
                 }
                 c.AbortWithStatus(http.StatusInternalServerError)
             }
         }()
         c.Next()
     }
 }

例子

 var logger *zap.Logger
 ​
 func main() {
     r := gin.New()
     r.Use(GinLogger(logger), GinRecovery(logger, true))
     r.GET("/hello", func(c *gin.Context) {
         c.String(http.StatusOK, "hello!")
     })
     r.Run()
 }
 func init() {
     encConfig := zap.NewProductionEncoderConfig()
     enc := zapcore.NewJSONEncoder(encConfig)
     lumberjackLogger := &lumberjack.Logger{
         Filename:   "log.log",
         MaxAge:     30,
         MaxSize:    10,
         MaxBackups: 5,
         Compress:   false,
     }
     WriteSyncer := zapcore.AddSync(lumberjackLogger)
     core := zapcore.NewCore(enc, WriteSyncer, zapcore.InfoLevel)
     logger = zap.New(core)
 }
 ​
 // GinLogger 接收gin框架默认的日志
 func GinLogger(logger *zap.Logger) gin.HandlerFunc {
     return func(c *gin.Context) {
         start := time.Now()
         path := c.Request.URL.Path
         query := c.Request.URL.RawQuery
         c.Next()
 ​
         cost := time.Since(start)
         logger.Info(path,
             zap.Int("status", c.Writer.Status()),
             zap.String("method", c.Request.Method),
             zap.String("path", path),
             zap.String("query", query),
             zap.String("ip", c.ClientIP()),
             zap.String("user-agent", c.Request.UserAgent()),
             zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),
             zap.Duration("cost", cost),
         )
     }
 }
 ​
 // GinRecovery recover掉项目可能出现的panic
 func GinRecovery(logger *zap.Logger, stack bool) gin.HandlerFunc {
     return func(c *gin.Context) {
         defer func() {
             if err := recover(); err != nil {
                 // Check for a broken connection, as it is not really a
                 // condition that warrants a panic stack trace.
                 var brokenPipe bool
                 if ne, ok := err.(*net.OpError); ok {
                     if se, ok := ne.Err.(*os.SyscallError); ok {
                         if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {
                             brokenPipe = true
                         }
                     }
                 }
 ​
                 httpRequest, _ := httputil.DumpRequest(c.Request, false)
                 if brokenPipe {
                     logger.Error(c.Request.URL.Path,
                         zap.Any("error", err),
                         zap.String("request", string(httpRequest)),
                     )
                     // If the connection is dead, we can't write a status to it.
                     c.Error(err.(error)) // nolint: errcheck
                     c.Abort()
                     return
                 }
 ​
                 if stack {
                     logger.Error("[Recovery from panic]",
                         zap.Any("error", err),
                         zap.String("request", string(httpRequest)),
                         zap.String("stack", string(debug.Stack())),
                     )
                 } else {
                     logger.Error("[Recovery from panic]",
                         zap.Any("error", err),
                         zap.String("request", string(httpRequest)),
                     )
                 }
                 c.AbortWithStatus(http.StatusInternalServerError)
             }
         }()
         c.Next()
     }
 }
 //在每次访问本地时都会记录一条日志到log.log文件中

Viper

Viper是适用于Go应用程序的完整配置解决方案。它被设计用于在应用程序中工作,并且可以处理所有类型的配置需求和格式。

安装

go get github.com/spf13/viper

Vipei简介

Viper是适用于Go应用程序(包括Twelve-Factor App)的完整配置解决方案。它被设计用于在应用程序中工作,并且可以处理所有类型的配置需求和格式。它支持以下特性:

  • 设置默认值
  • JSONTOMLYAMLHCLenvfileJava properties格式的配置文件读取配置信息
  • 实时监控和重新读取配置文件(需手动指定)
  • 从环境变量中读取
  • 从远程配置系统(etcd或Consul)读取并监控配置变化
  • 从命令行参数读取配置
  • 从buffer读取配置
  • 显式配置值

Viper会按照下面的优先级。每个项目的优先级都高于它下面的项目:

  • 显式调用Set设置值
  • 命令行参数(flag)
  • 环境变量
  • 配置文件
  • key/value存储
  • 默认值

重要: 目前Viper配置的键(Key)是大小写不敏感的。目前正在讨论是否将这一选项设为可选。

具体使用方法

几乎所有的函数都需要在读取设置后调用才能生效

设置默认值

使用Viper.Default函数设置默认值

func SetDefault(key string, value interface{}) { v.SetDefault(key, value) }
读取配置文件中的配置

需要指定读取的地址,Viper支持JSONTOMLYAMLHCLenvfileJava properties格式的配置文件。Viper可以搜索多个路径,但目前单个Viper实例只支持单个配置文件。Viper不默认任何配置搜索路径,将默认决策留给应用程序。

//例子
viper.SetConfigFile("config.yaml") // 指定配置文件路径
viper.SetConfigName("config") // 指定配置文件名称
//viper.SetConfigType("yaml")  用于从远程获取配置文件时指定类型,如etcd
//AddConfigPath为Viper预定义路径
//使用AddConfigPath可以在多个目录下去寻找配置文件
viper.AddConfigPath("/etc/appname/")   // 查找配置文件所在的路径
viper.AddConfigPath("$HOME/.appname")  // 多次调用以添加多个搜索路径
viper.AddConfigPath(".")               // 还可以在工作目录中查找配置
err := viper.ReadInConfig() // 查找并读取配置文件
if err != nil { // 处理读取配置文件的错误
	panic(fmt.Errorf("Fatal error config file: %s \n", err))
}

//在使用了viper.ReadInConfig()后可以使用viper.Get函数获得一个具体的配置
// Get can retrieve any value given the key to use.
// Get is case-insensitive for a key.
// Get has the behavior of returning the value associated with the first
// place from where it is set. Viper will check in the following order:
// override, flag, env, config file, key/value store, default
//
// Get returns an interface. For a specific value use one of the Get____ methods.
func Get(key string) interface{} { return v.Get(key) }

例子

func main() {
	viper.SetConfigFile("config.yaml")
	err := viper.ReadInConfig() // 查找并读取配置文件
	if err != nil {             // 处理读取配置文件的错误
		panic(fmt.Errorf("Fatal error config file: %s \n", err))
	}
	fmt.Println(viper.Get("version"))
}
写入配置文件

将程序中更改的配置写入文件中,使用下面的命令

  • WriteConfig - 将当前的viper配置写入预定义的路径并覆盖(如果存在的话)。如果没有预定义的路径,则报错。
  • SafeWriteConfig - 将当前的viper配置写入预定义的路径。如果没有预定义的路径,则报错。如果存在,将不会覆盖当前的配置文件。
  • WriteConfigAs - 将当前的viper配置写入给定的文件路径。将覆盖给定的文件(如果存在的话)。
  • SafeWriteConfigAs - 将当前的viper配置写入给定的文件路径。不会覆盖给定的文件(如果它存在的话)。

根据经验,标记为safe的所有方法都不会覆盖任何文件,而是直接创建(如果不存在),而默认行为是创建或截断。

例子

viper.WriteConfig() // 将当前配置写入“viper.AddConfigPath()”和“viper.SetConfigName”设置的预定义路径
viper.SafeWriteConfig()
viper.WriteConfigAs("/path/to/my/.config")
viper.SafeWriteConfigAs("/path/to/my/.config") // 因为该配置文件写入过,所以会报错
viper.SafeWriteConfigAs("/path/to/my/.other_config")
监控配置文件

实现当配置文件在程序运行中发生改变的情况下,程序中的配置也跟着发生改变

只需告诉viper实例watchConfig。可选地,你可以为Viper提供一个回调函数,以便在每次发生更改时运行。

确保在调用WatchConfig()之前添加了所有的配置路径。

//在读取配置完成后再使用这个函数
//实时监控配置的变化
viper.WatchConfig()
//当配置发生改变后调用传入的函数
viper.OnConfigChange(func(e fsnotify.Event) {
  // 配置文件发生变更之后会调用的回调函数
	fmt.Println("Config file changed:", e.Name)
})
io.Reader中读取配置

Viper预先定义了许多配置源,如文件、环境变量、标志和远程K/V存储,但你不受其约束。你还可以实现自己所需的配置源并将其提供给viper。

viper.SetConfigType("yaml") // 或者 viper.SetConfigType("YAML")

// 任何需要将此配置添加到程序中的方法。
var yamlExample = []byte(`
Hacker: true
name: steve
hobbies:
- skateboarding
- snowboarding
- go
clothing:
  jacket: leather
  trousers: denim
age: 35
eyes : brown
beard: true
`)

viper.ReadConfig(bytes.NewBuffer(yamlExample))

viper.Get("name") // 这里会得到 "steve"
覆盖设置(更改配置)

实现对配置的更改

注意在更改后若想永久保存需要使用写入配置文件的函数,要不然这些设置在程序结束运行后就会失效

// Set sets the value for the key in the override register.
// Set is case-insensitive for a key.
// Will be used instead of values obtained via
// flags, config file, ENV, default, or key/value store.
func Set(key string, value interface{}) { v.Set(key, value) }
注册和使用别名

取别名,在后续使用中就可以用别名代替原本的名字

// RegisterAlias creates an alias that provides another accessor for the same key.
// This enables one to change a name without breaking the application.
func RegisterAlias(alias string, key string) { v.RegisterAlias(alias, key) }
使用环境变量

Viper完全支持环境变量。这使Twelve-Factor App开箱即用。有五种方法可以帮助与ENV协作:

  • AutomaticEnv()
  • BindEnv(string...) : error
  • SetEnvPrefix(string)
  • SetEnvKeyReplacer(string...) *strings.Replacer
  • AllowEmptyEnv(bool)

使用ENV变量时,务必要意识到Viper将ENV变量视为区分大小写。

Viper提供了一种机制来确保ENV变量是唯一的。通过使用SetEnvPrefix,你可以告诉Viper在读取环境变量时使用前缀。BindEnvAutomaticEnv都将使用这个前缀。(设置了前缀在查找时只会查找带有此前缀的,提高效率

BindEnv使用一个或两个参数。第一个参数是键名称,第二个是环境变量的名称。环境变量的名称区分大小写。如果没有提供ENV变量名,那么Viper将自动假设ENV变量与以下格式匹配:前缀+ “_” +键名全部大写。当你显式提供ENV变量名(第二个参数)时,它 不会 自动添加前缀。例如,如果第二个参数是“id”,Viper将查找环境变量“ID”。

在使用ENV变量时,需要注意的一件重要事情是,每次访问该值时都将读取它。Viper在调用BindEnv时不固定该值。

AutomaticEnv是一个强大的助手,尤其是与SetEnvPrefix结合使用时。调用时,Viper会在发出viper.Get请求时随时检查环境变量。它将应用以下规则。它将检查环境变量的名称是否与键匹配(如果设置了EnvPrefix)。

SetEnvKeyReplacer允许你使用strings.Replacer对象在一定程度上重写 Env 键。如果你希望在Get()调用中使用-或者其他什么符号,但是环境变量里使用_分隔符,那么这个功能是非常有用的。可以在viper_test.go中找到它的使用示例。

或者,你可以使用带有NewWithOptions工厂函数的EnvKeyReplacer。与SetEnvKeyReplacer不同,它接受StringReplacer接口,允许你编写自定义字符串替换逻辑。

默认情况下,空环境变量被认为是未设置的,并将返回到下一个配置源。若要将空环境变量视为已设置,请使用AllowEmptyEnv方法

 SetEnvPrefix("spf") // 将自动转为大写,环境变量都是大写的
 BindEnv("id")
 ​
 os.Setenv("SPF_ID", "13") // 通常是在应用程序之外完成的
 ​
 id := viper.Get("id") // 13
远程读取key/value

在Viper中启用远程支持,需要在代码中匿名导入viper/remote这个包。

 import _ "github.com/spf13/viper/remote"

远程读取的值的优先级高于默认值,但是低于环境变量

Viper加载配置值的优先级为:磁盘上的配置文件>命令行标志位>环境变量>远程Key/Value存储>默认值

从Viper中获取值

在Viper中,有几种方法可以根据值的类型获取值。存在以下功能和方法:

  • Get(key string) : interface{}
  • GetBool(key string) : bool
  • GetFloat64(key string) : float64
  • GetInt(key string) : int
  • GetIntSlice(key string) : []int
  • GetString(key string) : string
  • GetStringMap(key string) : map[string]interface{}
  • GetStringMapString(key string) : map[string]string
  • GetStringSlice(key string) : []string
  • GetTime(key string) : time.Time
  • GetDuration(key string) : time.Duration
  • IsSet(key string) : bool
  • AllSettings() : map[string]interface{}

每一个Get方法在找不到值的时候都会返回零值。为了检查给定的键是否存在,提供了IsSet()方法。

 // IsSet checks to see if the key has been set in any of the data locations.
 // IsSet is case-insensitive for a key.
 func IsSet(key string) bool { return v.IsSet(key) }

在使用这些Get函数的时候,若是嵌套的配置参数,使用.操作符操作

 {
     "host": {
         "address": "localhost",
         "port": 5799
     },
     "datastore": {
         "metric": {
             "host": "127.0.0.1",
             "port": 3099
         },
         "warehouse": {
             "host": "198.0.0.1",
             "port": 2112
         }
     }
 }
 ​
 ​
 GetString("datastore.metric.host") // (返回 "127.0.0.1")

因为Viper支持从多种配置来源,例如磁盘上的配置文件>命令行标志位>环境变量>远程Key/Value存储>默认值,我们在查找一个配置的时候如果在当前配置源中没找到,就会继续从后续的配置源查找,直到找到为止

例如,在给定此配置文件的情况下,datastore.metric.hostdatastore.metric.port均已定义(并且可以被覆盖)。如果另外在默认值中定义了datastore.metric.protocol,Viper也会找到它。

如果存在与分隔的键路径匹配的键,则返回其值。

 {
     "datastore.metric.host": "0.0.0.0",
     "host": {
         "address": "localhost",
         "port": 5799
     },
     "datastore": {
         "metric": {
             "host": "127.0.0.1",
             "port": 3099
         },
         "warehouse": {
             "host": "198.0.0.1",
             "port": 2112
         }
     }
 }
 ​
 GetString("datastore.metric.host") // 返回 "0.0.0.0"
提取子树

从Viper中提取子树。

例如,viper实例现在代表了以下配置:

 app:
   cache1:
     max-items: 100
     item-size: 64
   cache2:
     max-items: 200
     item-size: 80

使用Sub函数

 //返回的Viper可以用来调用Get等方法
 func Sub(key string) *Viper { return v.Sub(key) }

执行后:

 subv := viper.Sub("app.cache1")

subv现在就代表:

 max-items: 100
 item-size: 64

在后续就可以使用subv来调用Get等方法

 fmt.Println(subv.Get(max-items)) 
 //output:100
反序列化

你还可以选择将所有或特定的值解析到结构体、map等。

有两种方法可以做到这一点:

  • Unmarshal(rawVal interface{}) : error
  • UnmarshalKey(key string, rawVal interface{}) : error

举个例子:

注意结构体tag一定要使用mapstructure,而不是json

 type config struct {
     Port int
     Name string
     PathMap string `mapstructure:"path_map"`
 }
 ​
 var C config
 ​
 err := viper.Unmarshal(&C)
 if err != nil {
     t.Fatalf("unable to decode into struct, %v", err)
 }

如果你想要解析那些键本身就包含.(默认的键分隔符)的配置,你需要修改分隔符:

v := viper.NewWithOptions(viper.KeyDelimiter("::"))

v.SetDefault("chart::values", map[string]interface{}{
    "ingress": map[string]interface{}{
        "annotations": map[string]interface{}{
            "traefik.frontend.rule.type":                 "PathPrefix",
            "traefik.ingress.kubernetes.io/ssl-redirect": "true",
        },
    },
})

type config struct {
	Chart struct{
        Values map[string]interface{}
    }
}

var C config

v.Unmarshal(&C)

Viper还支持解析到嵌入的结构体:

/*
Example config:

module:
    enabled: true
    token: 89h3f98hbwf987h3f98wenf89ehf
*/
type config struct {
	Module struct {
		Enabled bool

		moduleConfig `mapstructure:",squash"`
	}
}

// moduleConfig could be in a module specific package
type moduleConfig struct {
	Token string
}

var C config

err := viper.Unmarshal(&C)
if err != nil {
	t.Fatalf("unable to decode into struct, %v", err)
}
序列化

你可能需要将viper中保存的所有设置序列化到一个字符串中,而不是将它们写入到一个文件中。你可以将自己喜欢的格式的序列化器与AllSettings()返回的配置一起使用。

import (
    yaml "gopkg.in/yaml.v2"
    // ...
)

func yamlStringSettings() string {
    c := viper.AllSettings()
    bs, err := yaml.Marshal(c)
    if err != nil {
        log.Fatalf("unable to marshal config to YAML: %v", err)
    }
    return string(bs)
}


// AllSettings将所有的配置集合在一个map里面并返回这个map
// AllSettings merges all settings and returns them as a map[string]interface{}.
func AllSettings() map[string]interface{} { return v.AllSettings() }

关机和重启

编写的Web项目部署之后,经常会因为需要进行配置变更或功能迭代而重启服务,单纯的kill -9 pid的方式会强制关闭进程,这样就会导致服务端当前正在处理的请求失败

优雅关机就是服务端关机命令发出后不是立即关机,而是等待当前还在处理的请求全部处理完毕后再退出程序,是一种对客户端友好的关机方式。而执行Ctrl+C关闭服务端时,会强制结束进程导致正在访问的请求出现问题。

优雅关机

使用http.Server内置的Shutdown()

 // +build go1.8
 ​
 package main
 ​
 import (
     "context"
     "log"
     "net/http"
     "os"
     "os/signal"
     "syscall"
     "time"
 ​
     "github.com/gin-gonic/gin"
 )
 ​
 func main() {
     router := gin.Default()
     router.GET("/", func(c *gin.Context) {
         time.Sleep(5 * time.Second)
         c.String(http.StatusOK, "Welcome Gin Server")
     })
 ​
     srv := &http.Server{
         Addr:    ":8080",
         Handler: router,
     }
 ​
     go func() {
         // 开启一个goroutine启动服务
         if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
             log.Fatalf("listen: %s\n", err)
         }
     }()
 ​
     // 等待中断信号来优雅地关闭服务器,为关闭服务器操作设置一个5秒的超时
     quit := make(chan os.Signal, 1) // 创建一个接收信号的通道
     // kill 默认会发送 syscall.SIGTERM 信号
     // kill -2 发送 syscall.SIGINT 信号,我们常用的Ctrl+C就是触发系统SIGINT信号
     // kill -9 发送 syscall.SIGKILL 信号,但是不能被捕获,所以不需要添加它
     // signal.Notify把收到的 syscall.SIGINT或syscall.SIGTERM 信号转发给quit
     signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)  // 此处不会阻塞
     <-quit  // 阻塞在此,当接收到上述两种信号时才会往下执行
     log.Println("Shutdown Server ...")
     // 创建一个5秒超时的context
     ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
     defer cancel()
     // 5秒内优雅关闭服务(将未处理完的请求处理完再关闭服务),超过5秒就超时退出
     if err := srv.Shutdown(ctx); err != nil {
         log.Fatal("Server Shutdown: ", err)
     }
 ​
     log.Println("Server exiting")
 }

如何验证优雅关机的效果呢?

上面的代码运行后会在本地的8080端口开启一个web服务,它只注册了一条路由/,后端服务会先sleep 5秒钟然后才返回响应信息。

我们按下Ctrl+C时会发送syscall.SIGINT来通知程序优雅关机,具体做法如下:

  1. 打开终端,编译并执行上面的代码
  2. 打开一个浏览器,访问127.0.0.1:8080/,此时浏览器白屏等待服务端返回响应。
  3. 在终端迅速执行Ctrl+C命令给程序发送syscall.SIGINT信号
  4. 此时程序并不立即退出而是等我们第2步的响应返回之后再退出,从而实现优雅关机。

优雅重启

我们可以使用 fvbock/endless 来替换默认的 ListenAndServe启动服务来实现, 示例代码如下:

 package main
 ​
 import (
     "log"
     "net/http"
     "time"
 ​
     "github.com/fvbock/endless"
     "github.com/gin-gonic/gin"
 )
 ​
 func main() {
     router := gin.Default()
     router.GET("/", func(c *gin.Context) {
         time.Sleep(5 * time.Second)
         c.String(http.StatusOK, "hello gin!")
     })
     // 默认endless服务器会监听下列信号:
     // syscall.SIGHUP,syscall.SIGUSR1,syscall.SIGUSR2,syscall.SIGINT,syscall.SIGTERM和syscall.SIGTSTP
     // 接收到 SIGHUP 信号将触发`fork/restart` 实现优雅重启(kill -1 pid会发送SIGHUP信号)
     // 接收到 syscall.SIGINT或syscall.SIGTERM 信号将触发优雅关机
     // 接收到 SIGUSR2 信号将触发HammerTime
     // SIGUSR1 和 SIGTSTP 被用来触发一些用户自定义的hook函数
     if err := endless.ListenAndServe(":8080", router); err!=nil{
         log.Fatalf("listen: %s\n", err)
     }
 ​
     log.Println("Server exiting")
 }

如何验证优雅重启的效果呢?

我们通过执行kill -1 pid命令发送syscall.SIGINT来通知程序优雅重启,具体做法如下:

  1. 打开终端,go build -o graceful_restart编译并执行./graceful_restart,终端输出当前pid(假设为43682)
  2. 将代码中处理请求函数返回的hello gin!修改为hello q1mi!,再次编译go build -o graceful_restart
  3. 打开一个浏览器,访问127.0.0.1:8080/,此时浏览器白屏等待服务端返回响应。
  4. 在终端迅速执行kill -1 43682命令给程序发送syscall.SIGHUP信号
  5. 等第3步浏览器收到响应信息hello gin!后再次访问127.0.0.1:8080/会收到hello q1mi!的响应。
  6. 在不影响当前未处理完请求的同时完成了程序代码的替换,实现了优雅重启。

但是需要注意的是,此时程序的PID变化了,因为endless 是通过fork子进程处理新请求,待原进程处理完当前请求后再退出的方式实现优雅重启的。所以当你的项目是使用类似supervisor的软件管理进程时就不适用这种方式了。

开发流程

  1. 加载配置
  2. 初始化日志
  3. 初始化数据库
  4. 注册路由
  5. 启动服务