Go Echo 学习笔记(五)Binder


Binder请求数据绑定

Echo针对请求过来的数据提供了一套数据绑定的机制:Binder,其可以用于将请求中的信息与创建的结构体属性进行一一对应,将请求信息转换为对应的结构体对象。

使用结构体标签进行数据绑定

Echo 支持将对应的请求参数中的数据绑定到指定的结构体的属性上去,支持的请求数据包括:path params, query params, request body等。

绑定请求数据所使用的方法是:Context#Bind(i interface{}),该方法支持从 application/json, application/xml and application/x-www-form-urlencoded 等格式的数据绑定到对应的结构体中。

Echo 进行结构体标签数据绑定的格式为:

type User struct {
  ID string `param:"id" query:"id" form:"id" json:"id" xml:"id"`
}

其中在指定的结构体属性后面的字符串就是对应的数据绑定标签,分别表示(包括查询字段、路径字段以及请求body三类):

  • query - 数据绑定的来源是请求查询字段(仅支持 GET/DELETE 请求方式)
  • param - 数据绑定的来源是请求字段
  • form - 数据绑定的来源是form表单
  • json - 数据绑定的来源是请求体中的json数据
  • xml - 数据绑定的来源是请求体中的xml数据

需要绑定来自路径字段的数据使用如下的方法:

if err := (&DefaultBinder{}).BindPathParams(c, &payload); err != nil {
  return err
}

需要绑定来自查询字段的数据使用如下的方法:

if err := (&DefaultBinder{}).BindQueryParams(c, &payload); err != nil {
  return err
}

需要绑定来自请求 body 的数据使用如下的方法:

if err := (&DefaultBinder{}).BindBody(c, &payload); err != nil {
  return err
}

使用过程中的注意事项:

  • 对于 query, param, form 形式的数据绑定来说只能绑定具有标签的字段;
  • 对于 json, xml 形式的数据绑定可以绑定没有标签的公共字段;
  • 下一步的请求参数会改写上一步请求参数的值,比如查询字段中的数据会被请求 body 中相同字段的内容改写;
  • 为了系统的安全性,应该避免将直接进行数据绑定之后的结构体对象传递到其他方法中,应该针对其他方法创建专门的结构体对象。

例如针对如下的请求:

curl --location --request POST 'http://localhost:8080/users' \
--header 'Content-Type: application/json' \
--data-raw '{"name":"wangxin","email":"wangxin1248@gmail.com"}'

curl --location --request POST 'http://localhost:8080/users' \
--form 'name="wangxin"' \
--form 'email="wangxin1248@gmail.com"'

将请求的数据绑定到如下结构体当中:

type User struct {
	Name  string `json:"name" form:"name" query:"name"`
	Email string `json:"email" form:"email" query:"email"`
}

然后在请求当中进行相应的数据绑定:

func GetUser(c echo.Context) error {
	// 进行数据绑定
	u := new(model.User)
	if err := c.Bind(u); err != nil {
		return err
	}
	// 安全保障将绑定的信息进行拷贝
	user := &model.UserDTO{
		Name:    u.Name,
		Email:   u.Email,
		IsLogin: false,
	}
	// 以json的形式返回数据
	return c.JSON(http.StatusOK, user)
}

快速进行数据绑定

echo 也提供了一些快速方法来进行请求数据的绑定工作:

  • echo.QueryParamsBinder(c) :绑定URL中的查询字段
  • echo.PathParamsBinder(c) :绑定URL中的字段
  • echo.FormFieldBinder(c) :帮定Form字段

这些方法执行之后可以进行链式数据绑定操作,格式如下:

<Type>("param", &destination) // 如果请求中该 param 参数存在则将其绑定到 Type 类型的 destination 目标对象中
Must<Type>("param", &destination) // 在请求中该参数必须存在
<Type>s("param", &destination) // 针对切片目标对象进行绑定
Must<Type>s("param", &destination) // 针对切片目标对象必须存在于请求参数中

该方法所支持的绑定数据类型有:

  • bool
  • float32
  • float64
  • int
  • int8
  • int16
  • int32
  • int64
  • uint
  • uint8/byte (does not support bytes(). Use BindUnmarshaler/CustomFunc to convert value from base64 etc to []byte{})
  • uint16
  • uint32
  • uint64
  • string
  • time
  • duration
  • BindUnmarshaler() interface
  • UnixTime() - converts unix time (integer) to time.Time
  • UnixTimeNano() - converts unix time with nano second precision (integer) to time.Time
  • CustomFunc() - callback function for your custom conversion logic

在链式调用中遇到请求参数比较复杂的情况下(比如/api/search?id=1,2,3&id=1为了对id的值进行绑定)可以使用 BindWithDelimiter("param", &dest, ",") 方法来进行参数分割绑定操作,绑定结构为 []int64{1,2,3,1}。

在链式绑定的最后可以调用 BindError() 或者 BindErrors()来返回绑定过程中的错误,其区别是:

  • BindError() :返回绑定过程中的第一个错误,并重置该绑定器的所有错误;
  • BindErrors() :返回所有的绑定错误,并重置绑定器的错误。

一个进行数据绑定的案例:

// url =  "/api/search?active=true&id=1&id=2&id=3&length=25"
var opts struct {
  IDs []int64
  Active bool
}
length := int64(50) // default length is 50

// creates query params binder that stops binding at first error
err := echo.QueryParamsBinder(c).
  Int64("length", &length).
  Int64s("ids", &opts.IDs).
  Bool("active", &opts.Active).
  BindError() // returns first binding error

自定义数据绑定器

对于 Binder,Echo 默认提供了一个实现:echo.DefaultBinder,通常情况下,这个默认实现能够满足要求。

首先来看下 Binder 的实现。

首先,Echo 定义了一个接口:

type Binder interface{
  Bind(i interface{}, c Context) error
}

任何 Binder 必须实现该接口,也就是提供 Bind 方法。

一起看看 DefaultBinder 的 Bind 方法实现:

func (b *DefaultBinder) Bind(i interface{}, c Context) (err error) {
	if err := b.BindPathParams(c, i); err != nil {
		return err
	}
	// Issue #1670 - Query params are binded only for GET/DELETE and NOT for usual request with body (POST/PUT/PATCH)
	// Reasoning here is that parameters in query and bind destination struct could have UNEXPECTED matches and results due that.
	// i.e. is `&id=1&lang=en` from URL same as `{"id":100,"lang":"de"}` request body and which one should have priority when binding.
	// This HTTP method check restores pre v4.1.11 behavior and avoids different problems when query is mixed with body
	if c.Request().Method == http.MethodGet || c.Request().Method == http.MethodDelete {
		if err = b.BindQueryParams(c, i); err != nil {
			return err
		}
	}
	return b.BindBody(c, i)
}

func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) {
	req := c.Request()
	if req.ContentLength == 0 {
		return
	}

	ctype := req.Header.Get(HeaderContentType)
	switch {
	case strings.HasPrefix(ctype, MIMEApplicationJSON):
		if err = json.NewDecoder(req.Body).Decode(i); err != nil {
			if ute, ok := err.(*json.UnmarshalTypeError); ok {
				return NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unmarshal type error: expected=%v, got=%v, field=%v, offset=%v", ute.Type, ute.Value, ute.Field, ute.Offset)).SetInternal(err)
			} else if se, ok := err.(*json.SyntaxError); ok {
				return NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Syntax error: offset=%v, error=%v", se.Offset, se.Error())).SetInternal(err)
			}
			return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
		}
	case strings.HasPrefix(ctype, MIMEApplicationXML), strings.HasPrefix(ctype, MIMETextXML):
		if err = xml.NewDecoder(req.Body).Decode(i); err != nil {
			if ute, ok := err.(*xml.UnsupportedTypeError); ok {
				return NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unsupported type error: type=%v, error=%v", ute.Type, ute.Error())).SetInternal(err)
			} else if se, ok := err.(*xml.SyntaxError); ok {
				return NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Syntax error: line=%v, error=%v", se.Line, se.Error())).SetInternal(err)
			}
			return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
		}
	case strings.HasPrefix(ctype, MIMEApplicationForm), strings.HasPrefix(ctype, MIMEMultipartForm):
		params, err := c.FormParams()
		if err != nil {
			return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
		}
		if err = b.bindData(i, params, "form"); err != nil {
			return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
		}
	default:
		return ErrUnsupportedMediaType
	}
	return nil
}

func (b *DefaultBinder) bindData(destination interface{}, data map[string][]string, tag string) error {
	if destination == nil || len(data) == 0 {
		return nil
	}
	typ := reflect.TypeOf(destination).Elem()
	val := reflect.ValueOf(destination).Elem()

	// Map
	if typ.Kind() == reflect.Map {
		for k, v := range data {
			val.SetMapIndex(reflect.ValueOf(k), reflect.ValueOf(v[0]))
		}
		return nil
	}

	// !struct
	if typ.Kind() != reflect.Struct {
		if tag == "param" || tag == "query" {
			// incompatible type, data is probably to be found in the body
			return nil
		}
		return errors.New("binding element must be a struct")
	}

	for i := 0; i < typ.NumField(); i++ {
		typeField := typ.Field(i)
		structField := val.Field(i)
		if !structField.CanSet() {
			continue
		}
		structFieldKind := structField.Kind()
		inputFieldName := typeField.Tag.Get(tag)

		if inputFieldName == "" {
			// If tag is nil, we inspect if the field is a not BindUnmarshaler struct and try to bind data into it (might contains fields with tags).
			// structs that implement BindUnmarshaler are binded only when they have explicit tag
			if _, ok := structField.Addr().Interface().(BindUnmarshaler); !ok && structFieldKind == reflect.Struct {
				if err := b.bindData(structField.Addr().Interface(), data, tag); err != nil {
					return err
				}
			}
			// does not have explicit tag and is not an ordinary struct - so move to next field
			continue
		}

		inputValue, exists := data[inputFieldName]
		if !exists {
			// Go json.Unmarshal supports case insensitive binding.  However the
			// url params are bound case sensitive which is inconsistent.  To
			// fix this we must check all of the map values in a
			// case-insensitive search.
			for k, v := range data {
				if strings.EqualFold(k, inputFieldName) {
					inputValue = v
					exists = true
					break
				}
			}
		}

		if !exists {
			continue
		}

		// Call this first, in case we're dealing with an alias to an array type
		if ok, err := unmarshalField(typeField.Type.Kind(), inputValue[0], structField); ok {
			if err != nil {
				return err
			}
			continue
		}

		numElems := len(inputValue)
		if structFieldKind == reflect.Slice && numElems > 0 {
			sliceOf := structField.Type().Elem().Kind()
			slice := reflect.MakeSlice(structField.Type(), numElems, numElems)
			for j := 0; j < numElems; j++ {
				if err := setWithProperType(sliceOf, inputValue[j], slice.Index(j)); err != nil {
					return err
				}
			}
			val.Field(i).Set(slice)
		} else if err := setWithProperType(typeField.Type.Kind(), inputValue[0], structField); err != nil {
			return err

		}
	}
	return nil
}

其中:

  • DefaultBinder 使用 bindData 方法进行实际的数据绑定,主要通过反射进行处理,要求被绑定的类型是 map[string]interface{}struct(实际是实现它们的指针);
  • 通过给 Struct 的字段加上不同的 Tag 来接收不同类型的值:
    • param tag 对应路径参数;
    • query tag 对应 URL 参数;
    • json tag 对应 application/json 方式参数;
    • form tag 对应 POST 表单数据;
    • xml tag 对应 application/xml 或 text/xml;
  • 从代码的顺序可以看出,当同一个字段在多种方式存在值时,优先级顺序:param < query < 其他;

使用 Echo 的 DefaultBinder 进行请求数据绑定的时候只需要在声明一个请求对象 type 的时候在对应的参数后面添加相应的 tag:

type User struct {
    Name string `query:"name" form:"name" json:"name" xml:"name"`
    Sex  string `query:"sex" form:"sex" json:"sex" xml:"sex"`
}

这样只需在请求处理器当中对请求数据进行绑定就可以将请求数据绑定到一个新建的结构体对象上了:

func TestBinder(c echo.Context) error{
    user := &User{}
    if err := c.Bind(user);err != nil{
        return err
    }
    return c.Json(http.StatusOK,user)
}

DefaultBinder 已经可以满足针对 query、form、path、json、xml 几种数据类型的绑定工作,但是对于其他数据类型的绑定工作就不支持了。

比如前端使用 msgpack 格式来传递数据的话就得需要自定义 Binder。

自定义 MsgpackBinder 并实现 Bind 接口:

type MsgpackBinder struct {
}

// Bind 实现接口
func (binder *MsgpackBinder) Bind(i interface{}, c echo.Context) (err error) {

	// 支持默认的binder
	db := new(echo.DefaultBinder)
	if err1 := db.Bind(i, c); err1 != echo.ErrUnsupportedMediaType {
		// 默认解析成功返回
		return
	}
	// 获取请求数据
	request := c.Request()
	// 获取请求数据类型
	ctype := request.Header.Get(echo.HeaderContentType)
	// 针对msgpack类型的数据进行解析
	if strings.HasPrefix(ctype, echo.MIMEApplicationMsgpack) {
		if err := msgpack.NewDecoder(request.Body).Decode(i); err != nil {
			return echo.NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err)
		}
		// 解析成功返回
		return
	}
	// 不支持的类型
	return echo.ErrUnsupportedMediaType
}

设置 Echo 的 Binder:

func main() {
	e := echo.New()
	// 使用自定义Binder
	e.Binder = new(models.MsgpackBinder)
	e.POST("/", func(context echo.Context) error {
		user := new(models.User)
		if err := context.Bind(user); err != nil {
			return err
		}
		return context.JSON(http.StatusOK, user)
	})
	e.Logger.Fatal(e.Start(":8080"))
}

模拟用户发送 Msgpack 类型的数据:

func main() {
	// 发送数据
	type User struct {
		Name string
		Sex  string
	}

	data, err := msgpack.Marshal(&User{Name: "wangxin", Sex: "male"})
	if err != nil {
		panic(err)
	}

	response, err := http.DefaultClient.Post("http://localhost:8080/", "application/msgpack", bytes.NewReader(data))
	if err != nil {
		panic(err)
	}
	// 延迟关闭读写流
	defer response.Body.Close()

	// 读取响应数据
	result, err := ioutil.ReadAll(response.Body)
	if err != nil {
		panic(err)
	}
	// 输出响应数据
	fmt.Printf("%s\n", result)
}

运行成功获取到返回过来的 Json 数据。