【Go】(二)Gin文档学习

329 阅读10分钟

AsciiJSON

关键在c.AsciiJSON(http.StatusOK, data),会将data表示的json字符串转为ascii码形式。

用自定义的结构体绑定数据请求

它的绑定流程是这样的: image.png 传入参数的field_a和field_b对应结构体中的: image.png 所以最后是将StructB的两个值绑定。

绑定HTML资源

这个主要用于前后端不分离的小型项目。

绑定的流程如下:

  • 项目目录结构:

image.png

  • r.LoadHTMLGlob("views/*"):加载HTML静态资源文件到路由中
  • indexHandler和formHandler分别对应get和post的处理结果
  • get返回form.html这个文件本身;post返回选择的绑定结果

关于这个post,一开始笔者有点懵,因为我在apifox试了一下发送get请求,又发送post请求之后发现返回的是

{
    "color": null
}

我怀疑是不是我哪里弄错了,于是去网上找资料,很多人的返回结果都是三个颜色,但最后有一位大佬写的记录贴里的返回值只有红色。于是我灵光一闪,想到返回的会不会是我的勾选值,于是在浏览器试了一下(相当于get),选中任意选项后提交(相当于带有参数的post),就会返回所选中的值:

image.png

image.png

绑定数据(支持多种格式)

这部分的文档写得不是很详细,可以结合里面提到的github issue来学习。

代码分析

示例代码中使用到了shouldBind()函数:

if c.ShouldBind(&person) == nil {
   log.Println(person.Name)
   log.Println(person.Address)
   log.Println(person.Birthday)
}

通过阅读源码可知:

// ShouldBind checks the Method and Content-Type to select a binding engine automatically,
// Depending on the "Content-Type" header different bindings are used, for example:
//
// "application/json" --> JSON binding
// "application/xml"  --> XML binding
//
// It parses the request's body as JSON if Content-Type == "application/json" using JSON or XML as a JSON input.
// It decodes the json payload into the struct specified as a pointer.
// Like c.Bind() but this method does not set the response status code to 400 or abort if input is not valid.
func (c *Context) ShouldBind(obj any) error {
   b := binding.Default(c.Request.Method, c.ContentType())
   return c.ShouldBindWith(obj, b)
}

若返回为nil,表示没有错误,即绑定成功。

该函数有两大特性:

  • 根据请求中的content-type不同自动选择相应绑定方式

以下是里面用到的Default()函数的代码:

// Default returns the appropriate Binding instance based on the HTTP method
// and the content type.
func Default(method, contentType string) Binding {
   if method == http.MethodGet {
      return Form
   }

   switch contentType {
   case MIMEJSON:
      return JSON
   case MIMEXML, MIMEXML2:
      return XML
   case MIMEPROTOBUF:
      return ProtoBuf
   case MIMEMSGPACK, MIMEMSGPACK2:
      return MsgPack
   case MIMEYAML:
      return YAML
   case MIMETOML:
      return TOML
   case MIMEMultipartPOSTForm:
      return FormMultipart
   default: // case MIMEPOSTForm:
      return Form
   }
}
  • 反应比较温和,绑定值无效也不会像Bind()一样返回状态码400或停止。

那么为什么示例代码注释里要提到这个函数对于get和post的区别呢?

// If `GET`, only `Form` binding engine (`query`) used.
// If `POST`, first checks the `content-type` for `JSON` or `XML`, then uses `Form` (`form-data`).
// See more at https://github.com/gin-gonic/gin/blob/master/binding/binding.go#L48

这是因为get默认的用处是url拼接参数的形式,而POST默认是往请求字段里面加数据。如果硬要往get塞body字段,也不是不可以,只是很多框架的支持不是很完善,有时候会出现数据丢失或者解析失败的情况。

笔者经过多次测试,认为如上说法比较可信。

最后,运行示例代码时如果想用POST,别忘了在main()中加入:

route.POST("/testing", startPage)

绑定uri

在示例代码中,最关键的是这个

c.ShouldBindUri(&person)

它的源码如下:

func (c *Context) ShouldBindUri(obj any) error {
   m := make(map[string][]string)
   for _, v := range c.Params {
      m[v.Key] = []string{v.Value}
   }
   return binding.Uri.BindUri(m, obj)
}

通过将context中的Params转为map形式,再调用BindUri来绑定对象Person和传入数据:

func (uriBinding) BindUri(m map[string][]string, obj any) error {
   if err := mapURI(obj, m); err != nil {
      return err
   }
   return validate(obj)
}
func mapURI(ptr any, m map[string][]string) error {
   return mapFormByTag(ptr, m, "uri")
}
func mapFormByTag(ptr any, form map[string][]string, tag string) error {
   // Check if ptr is a map
   ptrVal := reflect.ValueOf(ptr)
   var pointed any
   if ptrVal.Kind() == reflect.Ptr {
      ptrVal = ptrVal.Elem()
      pointed = ptrVal.Interface()
   }
   if ptrVal.Kind() == reflect.Map &&
      ptrVal.Type().Key().Kind() == reflect.String {
      if pointed != nil {
         ptr = pointed
      }
      return setFormMap(ptr, form)
   }

   return mappingByPtr(ptr, formSource(form), tag)
}
func mappingByPtr(ptr any, setter setter, tag string) error {
   _, err := mapping(reflect.ValueOf(ptr), emptyField, setter, tag)
   return err
}
func mapping(value reflect.Value, field reflect.StructField, setter setter, tag string) (bool, error) {
   if field.Tag.Get(tag) == "-" { // just ignoring this field
      return false, nil
   }

   vKind := value.Kind()

   if vKind == reflect.Ptr {
      var isNew bool
      vPtr := value
      if value.IsNil() {
         isNew = true
         vPtr = reflect.New(value.Type().Elem())
      }
      isSet, err := mapping(vPtr.Elem(), field, setter, tag)
      if err != nil {
         return false, err
      }
      if isNew && isSet {
         value.Set(vPtr)
      }
      return isSet, nil
   }

   if vKind != reflect.Struct || !field.Anonymous {
      ok, err := tryToSetValue(value, field, setter, tag)
      if err != nil {
         return false, err
      }
      if ok {
         return true, nil
      }
   }

   if vKind == reflect.Struct {
      tValue := value.Type()

      var isSet bool
      for i := 0; i < value.NumField(); i++ {
         sf := tValue.Field(i)
         if sf.PkgPath != "" && !sf.Anonymous { // unexported
            continue
         }
         ok, err := mapping(value.Field(i), sf, setter, tag)
         if err != nil {
            return false, err
         }
         isSet = isSet || ok
      }
      return isSet, nil
   }
   return false, nil
}

关于uri数据有效性验证:

func validate(obj any) error {
   if Validator == nil {
      return nil
   }
   return Validator.ValidateStruct(obj)
}
type StructValidator interface {
   // ValidateStruct can receive any kind of type and it should never panic, even if the configuration is not right.
   // If the received type is a slice|array, the validation should be performed travel on every element.
   // If the received type is not a struct or slice|array, any validation should be skipped and nil must be returned.
   // If the received type is a struct or pointer to a struct, the validation should be performed.
   // If the struct is not valid or the validation itself fails, a descriptive error should be returned.
   // Otherwise nil must be returned.
   ValidateStruct(any) error

   // Engine returns the underlying validator engine which powers the
   // StructValidator implementation.
   Engine() any
}

关于uri

uuid含义

在示例curl中所用到的987fbc97-4bed-5078-9f07-9141ba07c9f3是一种uuid(Universally Unique Identifier),它是128bit的随机序列。其生成算法思想如下: UUID由以下几部分的组合:

  1. 当前日期和时间,UUID的第一个部分与时间有关,如果你在生成一个UUID之后,过几秒又生成一个UUID,则第一个部分不同,其余相同。
  2. 时钟序列。
  3. 全局唯一的IEEE机器识别号,如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得。

当前共有五个版本,每个版本都在特定的领域比其他版本更有优势:

  • Version-1 UUIDs are generated from a time and a node ID (usually the MAC address); 时间和物理地址
  • version-2 UUIDs are generated from an identifier (usually a group or user ID), time, and a node ID; 群组或用户id、时间、物理地址
  • versions 3 and 5 produce deterministic UUIDs generated by hashing a namespace identifier and name; 哈希化命名空间id和名字
  • version-4 UUIDs are generated using a random or pseudo-random number. 产生随机数和伪随机数

uuid生成

笔者此前了解到,Go的最大优势并不在网站开发,而在云原生、区块链等领域,所以对于加解密、id生成等算法是有可用的包支持的。比如笔者找到了这一个: github.com/google/uuid

可通过go get导入使用。

雪花算法

还有另外一种算法是雪花算法,可用于生成分布式id。它最初由推特开源,是一种64bit的算法。

image.png

后63位的编码如上图(因为第0位表示是正数还是负数,这个id最终会以有符号数的形式展现)

  • The first 41 bits are a timestamp, representing milliseconds since the chosen epoch.
  • The next 10 bits represent a machine ID, preventing clashes.
  • Twelve more bits represent a per-machine sequence number.

Go版本的github地址如下:github.com/snowflakedb…

通过静态模板建立一个简单的二进制

  • 使用第三方库go-asserts-builder
  • 原理:使用 go-assets-builder 命令将目录下的资源文件打包到一个叫assets.go 文件里,然后就可以只引用 assets.go 实现文件访问,不再依赖于原先的静态资源目录。
  • 安装:go get github.com/jessevdk/go-assets-builder

渲染HTML

用LoadHTMLGlob() 或 LoadHTMLFiles()

自定义HTTP配置

格式参考:

func main() {
	router := gin.Default()

	s := &http.Server{
		Addr:           ":8080",
		Handler:        router,
		ReadTimeout:    10 * time.Second,
		WriteTimeout:   10 * time.Second,
		MaxHeaderBytes: 1 << 20,
	}
	s.ListenAndServe()
}

关于分组路由

上一篇文章已经提过这个问题了,这里不再赘述。放一个笔者魔改的代码:

package main

import "github.com/gin-gonic/gin"

func main() {
   router := gin.Default()

   // Simple group: v1
   v1 := router.Group("/v1")
   {
      v1.GET("/login", func(c *gin.Context) {
         c.String(200, "pong")
      })
      v1.GET("/submit", func(c *gin.Context) {
         c.String(200, "bong")
      })
      v1.GET("/read", func(c *gin.Context) {
         c.String(200, "tong")
      })
   }

   router.Run(":8080")
}

服务器基于HTTP2协议主动推送

HTTP2协议的一个重要特点就是可以主动向客户端推送资源,而不必等到对方请求时,在协议层面,这是依靠PUSH_PROMISE字段实现的。

关键在于这段代码:

if pusher := c.Writer.Pusher(); pusher != nil {
   // use pusher.Push() to do server push
   if err := pusher.Push("/assets/app.js", nil); err != nil {
      log.Printf("Failed to push: %v", err)
   }
}

关于JSONP的使用

JSONP就是JSON with padding,是一种实现跨域读取数据的机制。

JSONP实现原理

<script>文件可以引用任何域名下的文件

GIN的JSONP实现原理

下面来看一下源码:

// JSONP serializes the given struct as JSON into the response body.
// It adds padding to response body to request data from a server residing in a different domain than the client.
// It also sets the Content-Type as "application/javascript".
func (c *Context) JSONP(code int, obj any) {
   callback := c.DefaultQuery("callback", "")
   if callback == "" {
      c.Render(code, render.JSON{Data: obj})
      return
   }
   c.Render(code, render.JsonpJSON{Callback: callback, Data: obj})
}

顺便提一下,笔者认为官方文档里的代码可能有点错误,应该改为如下:

package main

import (
   "github.com/gin-gonic/gin"
   "net/http"
)

func main() {
   r := gin.Default()

   r.GET("/JSONP", func(c *gin.Context) {
      data := map[string]interface{}{
         "foo": "bar",
      }

      //callback is x
      // Will output  :   x({"foo":"bar"})
      c.JSONP(http.StatusOK, data)
   })

   // Listen and serve on 0.0.0.0:8080
   r.Run(":8080")
}

即GET那一行/JSONP后面没有?callback=x

Gin解决JSON防劫持问题

JSONP劫持是JSON劫持的子集,JSON劫持是恶意网站通过<script>标签获取用户的JSON数据。现代浏览器基本修复了这个问题。

以下是Gin框架的处理措施: c.SecureJSON(200, a)

该方法的源码如下:

// SecureJSON serializes the given struct as Secure JSON into the response body.
// Default prepends "while(1)," to response body if the given struct is array values.
// It also sets the Content-Type as "application/json".
func (c *Context) SecureJSON(code int, obj any) {
   c.Render(code, render.SecureJSON{Prefix: c.engine.secureJSONPrefix, Data: obj})
}

可以看到,是在<script>标签执行返回的数据前进行无限循环操作。

但是这种方法还是有风险。

你还知道其他跨域方式吗? CORS、代理(比如ngnix)

关于中间件

不用任何中间件的路由配置

r := gin.New()

使用默认配置(默认模式会有Logger和Recovery中间件)

r := gin.Default()

自定义中间件

中间件使用

官方文档提供了一个自定义的Logger中间件作为示例。

要使用中间件很简单:

r.Use(Logger()) //r.use(中间件的函数)

main()中的MustGet()的意思是:要么get到key对应的value,要么抛出panic。

// MustGet returns the value for the given key if it exists, otherwise it panics.
func (c *Context) MustGet(key string) any {
   if value, exists := c.Get(key); exists {
      return value
   }
   panic("Key "" + key + "" does not exist")
}

中间件定义

  • 如示例的Logger:
func Logger() gin.HandlerFunc

中间件需要返回gin.HandlerFunc

  • next()是请求执行前后的分割线
// before request

c.Next()

// after request
  • 笔者现在对自定义中间件还没有强烈需求,但从示例代码看,主要是对context的内容的封装和操作。

自定义日志文件格式

格式和选项参考:

func main() {
	router := gin.New()
	// LoggerWithFormatter middleware will write the logs to gin.DefaultWriter
	// By default gin.DefaultWriter = os.Stdout
	router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
		// your custom format
		return fmt.Sprintf("%s - [%s] "%s %s %s %d %s "%s" %s"\n",
				param.ClientIP,
				param.TimeStamp.Format(time.RFC1123),
				param.Method,
				param.Path,
				param.Request.Proto,
				param.StatusCode,
				param.Latency,
				param.Request.UserAgent(),
				param.ErrorMessage,
		)
	}))
	router.Use(gin.Recovery())
	router.GET("/ping", func(c *gin.Context) {
		c.String(200, "pong")
	})
	router.Run(":8080")
}

将日志写入特定文件

以下代码在当前目录下创建gin.log文件,并将日志写入该文件

// Logging to a file.
f, _ := os.Create("gin.log")
gin.DefaultWriter = io.MultiWriter(f)

配置打印日志的提示词的颜色

gin.ForceConsoleColor()

自定义验证器

在示例代码中,虽然是“自定义”,但实际上也是借助并修改了第三方库:

"github.com/go-playground/validator/v10"

这个库的功能很强大:

Go Struct and Field validation, including Cross Field, Cross Struct, Map, Slice and Array diving

示例代码中的自定义部分的功能为:

  • 确保借出时间小于归还时间
// Booking contains binded and validated data.
type Booking struct {
   CheckIn  time.Time `form:"check_in" binding:"required,bookabledate" time_format:"2006-01-02"`
   CheckOut time.Time `form:"check_out" binding:"required,gtfield=CheckIn,bookabledate" time_format:"2006-01-02"`
}

这里的gtffield表示Field Greater Than Another Field。更多参数详见官方的README文档。

  • 确保预约时间晚于当前时间
var bookableDate validator.Func = func(fl validator.FieldLevel) bool {
   date, ok := fl.Field().Interface().(time.Time)
   if ok {
      today := time.Now()
      if today.After(date) {
         return false
      }
   }
   return true
}

上传文件

平时在访问一些课堂派、雨课堂之类的网页在线版时,经常需要上传文件。

Gin框架也可以帮助我们做这个事情:

上传单个或多个文件

package main

import (
   "net/http"
   "path/filepath"

   "github.com/gin-gonic/gin"
)

func main() {
   router := gin.Default()
   // Set a lower memory limit for multipart forms (default is 32 MiB)
   router.MaxMultipartMemory = 8 << 20 // 8 MiB
   router.Static("/", "./public")
   router.POST("/upload", func(c *gin.Context) {

      // Multipart form
      form, err := c.MultipartForm()
      if err != nil {
         c.String(http.StatusBadRequest, "get form err: %s", err.Error())
         return
      }
      files := form.File["files"]

      for _, file := range files {
         filename := filepath.Base(file.Filename)
         if err := c.SaveUploadedFile(file, filename); err != nil {
            c.String(http.StatusBadRequest, "upload file err: %s", err.Error())
            return
         }
      }

      c.String(http.StatusOK, "Uploaded successfully %d files ", len(files))
   })
   router.Run(":8080")
}

这段代码可一次上传多个文件,且不限制文件的类型。

可以用一个html的表单看一下效果:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
<form action="http://localhost:8080/upload" method="post" enctype="multipart/form-data">
    上传文件:<input type="file" name="files" multiple>
    <input type="submit" value="提交">
</form>
</body>
</html>

上传文件 按钮与后端的upload绑定。

点击 上传文件 按钮后,会弹出操作系统的文件目录,可以点击选择多个文件,若是单个文件会显示该文件名称,多个文件则会显示文件数量,点击提交后会出现Uploaded successfully (文件数量) files(还没有重定向、按钮禁用等等,这个版本可以多次重复提交),同时上传的文件会出现在Gin项目当前目录下。

只允许特定格式的文件

若要修改为只允许上传特定格式的文件,可以在POST方法中判断Content-type的类型:比如只允许图片类:

//若不是图片类的:
        if headers.Header.Get("Content-Type") != "image/png" {
     
            //做相应处理
        }

注意事项

在中间件中使用协程

要注意不要直接使用原始的context,而应该用它的只读备份(应该是因为考虑到并发操作的数据一致性问题)

cCp := c.Copy()

优雅重启或停止

为什么会有这样一个问题?因为有时候会因为异常导致服务强行终止,这里讨论的就是让这种终止“优雅”、温和一点,尽可能降低影响。

官方作了如下推荐:

以下是官方示例代码,想到之后会多次重复使用,故记录如下:

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() {
		// service connections
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("listen: %s\n", err)
		}
	}()

	// Wait for interrupt signal to gracefully shutdown the server with
	// a timeout of 5 seconds.
	quit := make(chan os.Signal)
	// kill (no param) default send syscanll.SIGTERM
	// kill -2 is syscall.SIGINT
	// kill -9 is syscall. SIGKILL but can"t be catch, so don't need add it
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	log.Println("Shutdown Server ...")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		log.Fatal("Server Shutdown:", err)
	}
	// catching ctx.Done(). timeout of 5 seconds.
	select {
	case <-ctx.Done():
		log.Println("timeout of 5 seconds.")
	}
	log.Println("Server exiting")
}

参考资料