Zwlin's Blog

HTTP-Trailer

Last updated: 2020/10/20     Published at: 2020/10/20

Concept

Trailer 是一个响应首部,允许发送方在分块发送的消息后面添加额外的元信息,这些元信息可能是随着消息主体的发送动态生成的,比如消息的完整性校验,消息的数字签名,或者消息经过处理之后的最终状态等。

Grammar

Trailer: header-names

header-names 是出现在分块信息挂载部分的消息首部。以下首部字段不允许出现:

Example

 1HTTP/1.1 200 OK 
 2Content-Type: text/plain 
 3Transfer-Encoding: chunked
 4Trailer: Expires
 5
 67\r\n 
 7Mozilla\r\n 
 89\r\n 
 9Developer\r\n 
107\r\n 
11Network\r\n 
120\r\n 
13Expires: Wed, 21 Oct 2015 07:28:00 GMT\r\n
14\r\n

解释:

  1. Header 里面的 Transfer-Encoding 必须是 chunked,也就是说不能指定 Content-Length
  2. Trailer 的字段名字必须在 Header 里面提前声明,比如上面的 Trailer: Expires
  3. TrailerBody 发完之后再发,格式和 Header 类似。

Implement in Go

用 Go 实现一个 HTTP 客户端,对所发的 Body 计算 MD5 并通过 Trailer 传给服务端。服务端收到请求并对 Body 进行校验。

Server Code

 1package main
 2
 3import (
 4	"crypto/md5"
 5	"fmt"
 6	"io/ioutil"
 7	"log"
 8	"net/http"
 9)
10
11func checkMd5(w http.ResponseWriter, req *http.Request) {
12	fmt.Printf("Header:%+v\n", req.Header)
13	fmt.Println("Trailer before read body:")
14	fmt.Println(req.Trailer)
15
16	data, err := ioutil.ReadAll(req.Body)
17	bodyMd5 := fmt.Sprintf("%x", md5.Sum(data))
18	defer req.Body.Close()
19
20	fmt.Println("body:", string(data))
21	fmt.Println("md5:", bodyMd5)
22	fmt.Println("error:", err)
23	fmt.Println("Trailer after read body:")
24	fmt.Println(req.Trailer)
25
26	if req.Trailer.Get("md5") != bodyMd5 {
27		panic("body md5 not equal")
28	}
29}
30
31func main() {
32	http.HandleFunc("/", checkMd5)
33	log.Fatal(http.ListenAndServe(":12345",nil))
34}

Client Code

 1package main
 2
 3import (
 4	"crypto/md5"
 5	"fmt"
 6	"hash"
 7	"io"
 8	"net/http"
 9	"os"
10	"strconv"
11	"strings"
12)
13
14type headerReader struct {
15	reader io.Reader
16	md5    hash.Hash
17	header http.Header
18}
19
20func (r *headerReader) Read(p []byte) (n int, err error) {
21	n, err = r.reader.Read(p)
22	if n > 0 {
23		r.md5.Write(p[:n])
24	}
25	if err == io.EOF {
26		r.header.Set("md5", fmt.Sprintf("%x", r.md5.Sum(nil)))
27	}
28	return
29}
30
31func main() {
32	h := &headerReader{
33		reader: strings.NewReader("body"),
34		md5:    md5.New(),
35		header: http.Header{"md5": nil, "size": []string{strconv.Itoa(len("body"))}},
36	}
37	req, err := http.NewRequest("POST", "http://localhost:12345", h)
38	if err != nil {
39		panic(err)
40	}
41	req.ContentLength = -1
42	req.Trailer = h.header
43	resp, err := http.DefaultClient.Do(req)
44	if err != nil {
45		panic(err)
46	}
47	fmt.Println(resp.Status)
48	_, err = io.Copy(os.Stdout, resp.Body)
49	if err != nil {
50		panic(err)
51	}
52}

Result

Server result

$ go run server.go
Header:map[Accept-Encoding:[gzip] User-Agent:[Go-http-client/1.1]]
Trailer before read body:
map[Md5:[] Size:[]]
body: body
md5: 841a2d689ad86bd1611447453c22c6fc
error: <nil>
Trailer after read body:
map[Md5:[841a2d689ad86bd1611447453c22c6fc] Size:[4]]

Client result

$ go run client.go
200 OK

nc result

$ nc -l 12345
POST / HTTP/1.1
Host: localhost:12345
User-Agent: Go-http-client/1.1
Transfer-Encoding: chunked
Trailer: Md5,Size
Accept-Encoding: gzip

4
body
0
Md5: 841a2d689ad86bd1611447453c22c6fc
size: 4

Conclusion

可以看到服务端在读完 body 之前只能知道有 Md5 这个 Trailer,值为空;读完 body 之后,能正常拿到 TrailerMd5 值。

Go 语言使用 Trailer 也有几个注意事项:

  1. req.ContentLength 必须设置为 0 或者 -1,这样 body 才会以 chunked 的形式传输。
  2. req.Trailer 需要在发请求之前声明所有的 key 字段,在 body 发完之后设置相应的 value,如果客户端提前知道 Trailer 的值的话也可以提前设置,比如上面例子里面的 size 字段。
  3. 发完 body 之后 Trailer 不允许再更改,否则可能会因为 map 并发读写,导致程序 panic,同样的道理服务端在读 body 的时候也不应该对 Trailer 有引用。
  4. 服务端必须读完 body 之后才能知道 Trailer 的值。

References