假设你正在用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,那么有几个问题需要注意,而且可以改进:
-
并非所有由
Decode()返回的所有错误都是由客户端的错误请求引起的。具体来说,Decode()可以返回一个json.InvalidUnmarshalError错误--这是由于一个不可篡改的目标目的地被传递给Decode()。如果发生这种情况,那么它表明我们的应用程序有问题--而不是客户端的请求--所以真的应该记录这个错误并向客户端发送一个500 Internal Server Error响应。 -
由
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")。格式和语言的使用也不一致。 -
客户端可以在他们的JSON中包含额外的意外字段,而这些字段将被默默地忽略,而客户端不会收到任何错误。我们可以通过使用解码器的
DisallowUnknownFields()方法来解决这个问题。 -
Decode()方法将读取的请求体的大小没有上限。如果一个恶意的客户端发送了一个非常大的请求体,限制它将有助于防止我们的服务器资源被浪费,这一点我们可以通过使用http.MaxBytesReader()函数来实现。 -
在请求中没有检查出
Content-Type: application/json头。当然,这个标头可能并不总是存在,错误和恶意的客户端意味着它并不是实际内容类型的保证。但是,检查一个不正确的Content-Type头将允许我们 "快速失败",并发送一个有用的错误信息,而不需要花费不必要的资源来解析正文。 -
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)
}