如何在Go中解析一个JSON请求体(详细指南)

730 阅读7分钟

假设你正在用Go建立一个JSON API。在一些处理程序中--可能是作为POST或PUT请求的一部分--你想从请求体中读取一个JSON对象,并将其分配给你代码中的一个结构。

经过一番研究,你很有可能最终会得到一些与这里的personCreate 处理程序相似的代码:

File: main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
)

type Person struct {
    Name string
    Age  int
}

func personCreate(w http.ResponseWriter, r *http.Request) {
    // Declare a new Person struct.
    var p Person

    // Try to decode the request body into the struct. If there is an error,
    // respond to the client with the error message and a 400 status code.
    err := json.NewDecoder(r.Body).Decode(&p)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    // Do something with the Person struct...
    fmt.Fprintf(w, "Person: %+v", p)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/person/create", personCreate)

    err := http.ListenAndServe(":4000", mux)
    log.Fatal(err)
}

如果你正在建立一个快速原型,或者建立一个仅供个人/内部使用的API,那么personCreate 处理程序中的代码可能是可以的。

但是,如果你正在构建一个供公众使用的生产型API,那么有几个问题需要注意,而且可以改进:

  1. 并非所有由 Decode()返回的所有错误都是由客户端的错误请求引起的。具体来说,Decode() 可以返回一个 json.InvalidUnmarshalError错误--这是由于一个不可篡改的目标目的地被传递给Decode() 。如果发生这种情况,那么它表明我们的应用程序有问题--而不是客户端的请求--所以真的应该记录这个错误并向客户端发送一个500 Internal Server Error 响应。

  2. Decode() 返回的错误信息并不适合发送至客户端。有些可以说是太详细了,暴露了底层程序的信息(如"json: cannot unmarshal number into Go struct field Person.Name of type string" )。其他的描述性不够(如"unexpected EOF" ),有些只是简单的迷惑(如"invalid character 'A' looking for beginning of object key string" )。格式和语言的使用也不一致。

  3. 客户端可以在他们的JSON中包含额外的意外字段,而这些字段将被默默地忽略,而客户端不会收到任何错误。我们可以通过使用解码器的 DisallowUnknownFields()方法来解决这个问题。

  4. Decode() 方法将读取的请求体的大小没有上限。如果一个恶意的客户端发送了一个非常大的请求体,限制它将有助于防止我们的服务器资源被浪费,这一点我们可以通过使用 http.MaxBytesReader()函数来实现。

  5. 在请求中没有检查出Content-Type: application/json 头。当然,这个标头可能并不总是存在,错误和恶意的客户端意味着它并不是实际内容类型的保证。但是,检查一个不正确的Content-Type 头将允许我们 "快速失败",并发送一个有用的错误信息,而不需要花费不必要的资源来解析正文。

  6. Decode() 方法被设计为解码JSON对象的流。这意味着像'{"Name": "Bob"}{"Name": "Carol": "Age": 54}''{"Name": "Dave"}{}' 这样的请求体被认为是有效的,不会导致客户端收到错误信息。但在每种情况下,只有请求体中的第一个JSON对象会被实际解析。

    这里有两个解决方案。我们可以在解码后检查解码器的More() 方法,看看请求体中是否还有其他JSON对象。或者我们可以完全避免使用Decode() ,将主体读成一个字节片,然后传给json.Unmarshal() ,如果主体包含多个JSON对象,就 返回一个错误。使用json.Unmarshal() 的缺点是没有办法禁止JSON中额外的意外字段,所以我们不能解决上述第3点。

一个改进的处理程序

让我们实现另一个版本的personCreate 处理程序,以解决所有这些问题。

你会注意到,我们在这里使用了新的 errors.Is()errors.As()函数,这是在Go 1.13中引入的,以帮助拦截来自Decode() 的错误:

File: main.go
package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "log"
    "net/http"
    "strings"

    "github.com/golang/gddo/httputil/header"
)

type Person struct {
    Name string
    Age  int
}

func personCreate(w http.ResponseWriter, r *http.Request) {
    // If the Content-Type header is present, check that it has the value
    // application/json. Note that we are using the gddo/httputil/header
    // package to parse and extract the value here, so the check works
    // even if the client includes additional charset or boundary
    // information in the header.
    if r.Header.Get("Content-Type") != "" {
        value, _ := header.ParseValueAndParams(r.Header, "Content-Type")
        if value != "application/json" {
            msg := "Content-Type header is not application/json"
            http.Error(w, msg, http.StatusUnsupportedMediaType)
            return
        }
    }

    // Use http.MaxBytesReader to enforce a maximum read of 1MB from the
    // response body. A request body larger than that will now result in
    // Decode() returning a "http: request body too large" error.
    r.Body = http.MaxBytesReader(w, r.Body, 1048576)

    // Setup the decoder and call the DisallowUnknownFields() method on it.
    // This will cause Decode() to return a "json: unknown field ..." error
    // if it encounters any extra unexpected fields in the JSON. Strictly
    // speaking, it returns an error for "keys which do not match any
    // non-ignored, exported fields in the destination".
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()

    var p Person
    err := dec.Decode(&p)
    if err != nil {
        var syntaxError *json.SyntaxError
        var unmarshalTypeError *json.UnmarshalTypeError

        switch {
        // Catch any syntax errors in the JSON and send an error message
        // which interpolates the location of the problem to make it
        // easier for the client to fix.
        case errors.As(err, &syntaxError):
            msg := fmt.Sprintf("Request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
            http.Error(w, msg, http.StatusBadRequest)

        // In some circumstances Decode() may also return an
        // io.ErrUnexpectedEOF error for syntax errors in the JSON. There
        // is an open issue regarding this at
        // https://github.com/golang/go/issues/25956.
        case errors.Is(err, io.ErrUnexpectedEOF):
            msg := fmt.Sprintf("Request body contains badly-formed JSON")
            http.Error(w, msg, http.StatusBadRequest)

        // Catch any type errors, like trying to assign a string in the
        // JSON request body to a int field in our Person struct. We can
        // interpolate the relevant field name and position into the error
        // message to make it easier for the client to fix.
        case errors.As(err, &unmarshalTypeError):
            msg := fmt.Sprintf("Request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
            http.Error(w, msg, http.StatusBadRequest)

        // Catch the error caused by extra unexpected fields in the request
        // body. We extract the field name from the error message and
        // interpolate it in our custom error message. There is an open
        // issue at https://github.com/golang/go/issues/29035 regarding
        // turning this into a sentinel error.
        case strings.HasPrefix(err.Error(), "json: unknown field "):
            fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
            msg := fmt.Sprintf("Request body contains unknown field %s", fieldName)
            http.Error(w, msg, http.StatusBadRequest)

        // An io.EOF error is returned by Decode() if the request body is
        // empty.
        case errors.Is(err, io.EOF):
            msg := "Request body must not be empty"
            http.Error(w, msg, http.StatusBadRequest)

        // Catch the error caused by the request body being too large. Again
        // there is an open issue regarding turning this into a sentinel
        // error at https://github.com/golang/go/issues/30715.
        case err.Error() == "http: request body too large":
            msg := "Request body must not be larger than 1MB"
            http.Error(w, msg, http.StatusRequestEntityTooLarge)

        // Otherwise default to logging the error and sending a 500 Internal
        // Server Error response.
        default:
            log.Print(err.Error())
            http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
        }
        return
    }

    // Call decode again, using a pointer to an empty anonymous struct as 
    // the destination. If the request body only contained a single JSON 
    // object this will return an io.EOF error. So if we get anything else, 
    // we know that there is additional data in the request body.
	err = dec.Decode(&struct{}{})
	if err != io.EOF {
        msg := "Request body must only contain a single JSON object"
        http.Error(w, msg, http.StatusBadRequest)
        return
    }

    fmt.Fprintf(w, "Person: %+v", p)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/person/create", personCreate)

    err := http.ListenAndServe(":4000", mux)
    log.Fatal(err)
}

这里明显的缺点是,这段代码更加冗长,而且在我看来,有点难看。事实上,json/encoding 有很多公开的问题,在对软件包进行更广泛的审查之前,这些问题都被搁置了,这对事情没有帮助。

但从安全和客户的角度来看,这已经好得多了 : )

处理程序现在对它所接受的内容更加严格;我们减少了不必要地使用服务器资源的数量;客户得到了清晰和一致的错误信息,提供了相当数量的信息而没有过度分享。

顺便提一下,你可能已经注意到,json/encoding 包包含了一些其他的错误类型(如 json.UnmarshalFieldError),这些错误在上面的代码中没有被检查到--但是这些错误已经被废弃了,Go 1.13也没有使用。

制作一个辅助函数

如果你有几个处理程序需要处理JSON请求体,你可能不想在所有的处理程序中重复这些代码。

我发现一个效果很好的解决方案是创建一个decodeJSONBody 辅助函数,并让它返回一个自定义的malformedRequest 错误类型,其中包括错误和相关状态代码。

例如:"如果你是一个人,那么你就可以在你的网站上写下你的名字:

File: helpers.go
package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "strings"

    "github.com/golang/gddo/httputil/header"
)

type malformedRequest struct {
    status int
    msg    string
}

func (mr *malformedRequest) Error() string {
    return mr.msg
}

func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error {
    if r.Header.Get("Content-Type") != "" {
        value, _ := header.ParseValueAndParams(r.Header, "Content-Type")
        if value != "application/json" {
            msg := "Content-Type header is not application/json"
            return &malformedRequest{status: http.StatusUnsupportedMediaType, msg: msg}
        }
    }

    r.Body = http.MaxBytesReader(w, r.Body, 1048576)

    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()

    err := dec.Decode(&dst)
    if err != nil {
        var syntaxError *json.SyntaxError
        var unmarshalTypeError *json.UnmarshalTypeError

        switch {
        case errors.As(err, &syntaxError):
            msg := fmt.Sprintf("Request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
            return &malformedRequest{status: http.StatusBadRequest, msg: msg}

        case errors.Is(err, io.ErrUnexpectedEOF):
            msg := fmt.Sprintf("Request body contains badly-formed JSON")
            return &malformedRequest{status: http.StatusBadRequest, msg: msg}

        case errors.As(err, &unmarshalTypeError):
            msg := fmt.Sprintf("Request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
            return &malformedRequest{status: http.StatusBadRequest, msg: msg}

        case strings.HasPrefix(err.Error(), "json: unknown field "):
            fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
            msg := fmt.Sprintf("Request body contains unknown field %s", fieldName)
            return &malformedRequest{status: http.StatusBadRequest, msg: msg}

        case errors.Is(err, io.EOF):
            msg := "Request body must not be empty"
            return &malformedRequest{status: http.StatusBadRequest, msg: msg}

        case err.Error() == "http: request body too large":
            msg := "Request body must not be larger than 1MB"
            return &malformedRequest{status: http.StatusRequestEntityTooLarge, msg: msg}

        default:
            return err
        }
    }

	err = dec.Decode(&struct{}{})
	if err != io.EOF {
        msg := "Request body must only contain a single JSON object"
        return &malformedRequest{status: http.StatusBadRequest, msg: msg}
    }

    return nil
}

一旦写好了,你的处理程序中的代码就可以保持得非常好和紧凑:

File: main.go
package main

import (
    "errors"
    "fmt"
    "log"
    "net/http"
)

type Person struct {
    Name string
    Age  int
}

func personCreate(w http.ResponseWriter, r *http.Request) {
    var p Person

    err := decodeJSONBody(w, r, &p)
    if err != nil {
        var mr *malformedRequest
        if errors.As(err, &mr) {
            http.Error(w, mr.msg, mr.status)
        } else {
            log.Print(err.Error())
            http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
        }
        return
    }

    fmt.Fprintf(w, "Person: %+v", p)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/person/create", personCreate)

    log.Print("Starting server on :4000...")
    err := http.ListenAndServe(":4000", mux)
    log.Fatal(err)
}