本文将正式开始HTTP协议的解析,主要完成Request结构体的属性组成,HTTP请求报文首部字段以及请求行的解析,难度适中。

项目结构

因为在这一章中,我们会完成客户端请求报文中首部字段的解析,首部字段为一个个键值对,显而易见的是使用map结构进行一个存储,所以增加header.go文件,其中存在Header结构体,专门用于存放请求报文中请求行的键值对。此处并非重点也极为简单,所以直接从标准库中拷贝,读者自行理解:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package httpd

type Header map[string][]string

func (h Header) Add(key, value string) {
	h[key] = append(h[key], value)
}

// 插入键值对
func (h Header) Set(key, value string) {
	h[key] = []string{value}
}

// 获取值,key不存在则返回空
func (h Header) Get(key string) string {
	if value, ok := h[key]; ok && len(value) > 0 {
		return value[0]
	} else {
		return ""
	}
}

// 删除键
func (h Header) Del(key string) {
	delete(h, key)
}

今天的项目架构如下:

2
├── go.mod
├── httpd
│   ├── conn.go
│   ├── header.go
│   ├── request.go
│   ├── response.go
│   └── server.go
└── main.go

除了增加header.go外,其他所有的更改都在request.go中。

代码传送门

需求分析

我们需要为Request结构体增加相应的属性,就应该从http请求报文出发,看看我们需要保存哪些信息。一段http请求报文:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
POST /index?name=gu HTTP/1.1\r\n			#请求行
Content-Type: text/plain\r\n				#此处至报文主体为首部字段
User-Agent: PostmanRuntime/7.28.0\r\n
Host: 127.0.0.1:8080\r\n
Accept-Encoding: gzip, deflate, br\r\n
Connection: keep-alive\r\n
Cookie: uuid=12314753; tid=1BDB9E9; HOME=1\r\n
Content-Length: 18\r\n
\r\n
hello,I am client!							#报文主体

http的请求报文分为三部分:

  1. 请求行(第一行)分别由方法Method、请求路径URL以及协议版本Proto组成。将这三者加入到Request结构即可。

  2. 首部字段由一个个键值对组成,我们的头部信息就存放在此处。这部分就用上面讲到了的Header存储。

  3. 报文主体部分,相较于前面两个更为复杂,可能具有不同的编码方式,长度也可能特别大。平时前端提交的form表单就放置在报文主体部分。仅只有POSTPUT请求允许携带报文主体。

本篇只涉及前两部分的解析,读者需知。

除此之外,像cookie以及queryString(如上面的URL中的?name=gufeijun),是日常开发经常使用到的部分,为了方便用户的获取,我们分别用cookies以及queryString这两个map去保存解析后的字段。因此Request结构体如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Request struct{
	Method        string				//请求方法,如GET、POST、PUT
	URL           *url.URL				//URL
	Proto         string				//协议以及版本
	Header        Header				//首部字段
	Body          io.Reader				//用于读取报文主体
	RemoteAddr    string				// 客户端地址
	RequestURI    string 				//字符串形式的url
	conn           *conn				//产生此request的http连接
	cookies        map[string]string	//存储cookie
	queryString    map[string]string	//存储queryString
}

Body字段在下一章中讲解,读者只需要知道用户可以对(*Request).Body读取,进而读出当前请求的报文主体即可。

readRequest

上一章我们将框架的骨干搭好后,这里开始第一个空白处readRequest的填充,它的作用就是从tcp字节流中解析http报文,从而封装出一个Request对象。

直接上代码,请对比着上述的http报文观看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func readRequest(c *conn)(r *Request,err error){
	r = new(Request)
	r.conn = c
	r.RemoteAddr = c.rwc.RemoteAddr().String()
	//读出第一行,如:Get /index?name=gu HTTP/1.1
	line, err := readLine(c.bufr)
	if err != nil {
		return
	}
    // 按空格分割就得到了三个属性
	_, err = fmt.Sscanf(string(line), "%s%s%s", &r.Method, &r.RequestURI, &r.Proto)
	if err != nil {
		return
	}
    // 将字符串形式的URI变成url.URL形式
	r.URL, err = url.ParseRequestURI(r.RequestURI)
	if err != nil {
		return
	}
    //解析queryString
	r.parseQuery()
	//读header
	r.Header, err = readHeader(c.bufr)
	if err != nil {
		return
	}
	const noLimit = (1<<63)-1
	r.conn.lr.N = noLimit			//Body的读取无需进行读取字节数限制
	//设置body
	r.setupBody()
	return r, nil
}

6~16行为解析请求行,分别读出Method、URI以及Proto。21行解析queryString。23行读取首部字段。30行设置Body。上述代码中有四个辅助函数:readLineparseQueryreadHeader以及setupBody,这四个我们一个一个单独讲解。

readLine

上文提到bufio.Reader具有ReadLine方法,其存在三个返回参数line []byte, isPrefix bool, err error,line和err都很好理解,但为什么还多出了一个isPrefix参数呢?这是因为ReadLine会借助到bufio.Reader的缓存切片(见上篇),如果一行大小超过了缓存的大小,这也会无法达到读出一行的要求,这时isPrefix会设置成true,代表只读取了一部分。

因此我们需要对ReadLine方法进行封装,得到readLine函数,能够保证缓存容量不足的情况下也能读出一行,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func readLine(bufr *bufio.Reader) ([]byte, error) {
	p, isPrefix, err := bufr.ReadLine()
	if err != nil {
		return p, err
	}
	var l []byte
	for isPrefix {
		l, isPrefix, err = bufr.ReadLine()
		if err != nil {
			break
		}
        p = append(p, l...)
	}
	return p, err
}

只要isPrefix一直为true,我们则一直读取,并将读取的部分汇总在一起,直至读到\r\n

parseQuery

对于客户端访问的url,如127.0.0.0?name=gu&token=1234,其中name=gu&token=1234即为queryString字段,将这部分以KV方式存入map中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func (r *Request) parseQuery() {
    //r.URL.RawQuery="name=gu&token=1234"
	r.queryString = parseQuery(r.URL.RawQuery)
}

func parseQuery(RawQuery string) map[string]string {
	parts := strings.Split(RawQuery, "&")
	queries := make(map[string]string, len(parts))
	for _, part := range parts {
		index := strings.IndexByte(part, '=')
		if index == -1 || index == len(part)-1 {
			continue
		}
		queries[strings.TrimSpace(part[:index])] = strings.TrimSpace(part[index+1:])
	}
	return queries
}

先以&符为分隔得到一个个k-v对,然后以=符为分割分别得到key以及value,存入map即可。

readHeader

readHeader实现很简单,一直调用readLine读取一行,如果碰到CR+LF(\r\n\r\n),这时readLine读取到的该行长度为0,也即代表首部字段的结束。我们将读到的每一行都保存到header这个map中,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func readHeader(bufr *bufio.Reader) (Header, error) {
	header := make(Header)
	for {
		line, err := readLine(bufr)
		if err != nil {
			return nil, err
		}
		//如果读到/r/n/r/n,代表报文首部的结束
		if len(line) == 0 {
			break
		}
        //example: Connection: keep-alive
		i := bytes.IndexByte(line, ':')
		if i == -1 {
			return nil, errors.New("unsupported protocol")
		}
        if i == len(line)-1 {
			continue
		}
        k, v := string(line[:i]), strings.TrimSpace(string(line[i+1:]))
		header[k] = append(header[k], v)
	}
	return header, nil
}

setupBody

Body的设置是本框架的重难点之一,它是一个提供给用户的读取报文主体的接口,其将涉及到报文主体的解析,将是下一章的内容。我们这里暂时将Body设置成eofReader,让用户目前在Handler中无法从Body中读取任何数据。此处读者可以暂时略过。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type eofReader struct {}

// 实现了io.Reader接口
func (er *eofReader)Read([]byte)(n int,err error){
    return 0,io.EOF
}

func (r *Request)setupBody(){
	r.Body=new(eofReader)
}

绑定方法

Request结构中的cookie以及queryString字段都是私有属性,因为只希望用户具有查询的权限,而不能够进行删除或者修改。为了让用户去查询这个私有字段,需要绑定相应的公共方法,这就是封装的思想。

除了安全性之外,利用公有方法的方式也能让我们的控制更加灵活,可以实现懒加载(lazy load),从而提升性能。

比如在gin框架中每一个gin.Context中都会有一个叫做keys的map,用于在HandlerChain中传输数据,用户可以调用Set方法存数据,Get方法取数据。显而易见,为了实现这个功能,我们需要在操作keys这个map之前,就为其make分配内存。问题就出现在,如果我在生成一个gin.Context之初就为这个map进行初始化,但如果用户的Handler中并未使用这个功能怎么办?这个为keys初始化的时间是不是白白浪费了?

所以gin采用了比较高明的方式,在用户使用Set方法时,Set方法会先检测keys这个map是否为nil,如果为nil,这时我们才为其初始化。这样懒加载就能减少一些不必要的开销。

我们给域名生成的cookie,一旦颁发给用户浏览器之后,浏览器在访问我们域名下的后端接口时都会在请求报文中将这个cookie带上,要是后端接口不关系客户端的cookie,而框架无脑全部提前解析,这就做了徒工。

所以也需要将Cookie的解析滞后,不是在readRequest中解析,而是在用户接口有需求,调用Cookie方法第一次查询时再进行解析。这就是为什么readRequest中没有解析cookie代码的原因。

接下来为Request绑定两个公有方法Query以及Cookie,分别用于查询queryString以及cookie:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 查询queryString
func (r *Request) Query(name string) string {
	return r.queryString[name]
}

// 查询cookie
func (r *Request) Cookie(name string) string {
	if r.cookies == nil {	//将cookie的解析滞后到第一次cookie查询处
		r.parseCookies()
	}
	return r.cookies[name]
}

func (r *Request) parseCookies()  {
	if r.cookies != nil {
		return
	}
	r.cookies = make(map[string]string)
	rawCookies, ok := r.Header["Cookie"]
	if !ok {
		return
	}
	for _, line := range rawCookies {
        //example(line): uuid=12314753; tid=1BDB9E9; HOME=1(见上述的http请求报文)
		kvs := strings.Split(strings.TrimSpace(line), ";")
		if len(kvs) == 1 && kvs[0] == "" {
			continue
		}
		for i := 0; i < len(kvs); i++ {
            //example(kvs[i]): uuid=12314753
			index := strings.IndexByte(kvs[i], '=')
			if index == -1 {
				continue
			}
			r.cookies[strings.TrimSpace(kvs[i][:index])] = strings.TrimSpace(kvs[i][index+1:])
		}
	}
	return
}

解析很简单,就是字符串的处理。

测试

为了方便我们的测试,我们为response.go进行如下更改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type response struct{
	c  *conn
}

type ResponseWriter interface{
	Write([]byte)(n int,err error)
}

func setupResponse(c *conn)*response{
    return &response{c:c}
}

func (w *response) Write(p []byte)(int,error){
	return w.c.bufw.Write(p)
}

为ResponseWriter加入Write方法,同时让response实现这个接口。这样我们可以在Handler中可以使用Write方法与客户端交互,从而手动构造http响应报文。

同时记得要在conn.go文件中对setupResponse进行调用:

1
2
3
func (c *conn) setupResponse()*response{
	return setupResponse(c)
}

测试文件main.go如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
	"bytes"
	"example/httpd"
	"fmt"
	"io"
)

type myHandler struct {}

func (*myHandler) ServeHTTP(w httpd.ResponseWriter,r *httpd.Request){
    // 用户的头部信息保存到buff中
	buff:=&bytes.Buffer{}
    // 测试Request的解析
	fmt.Fprintf(buff,"[query]name=%s\n",r.Query("name"))
	fmt.Fprintf(buff,"[query]token=%s\n",r.Query("token"))
	fmt.Fprintf(buff,"[cookie]foo1=%s\n",r.Cookie("foo1"))
	fmt.Fprintf(buff,"[cookie]foo2=%s\n",r.Cookie("foo2"))
	fmt.Fprintf(buff,"[Header]User-Agent=%s\n",r.Header.Get("User-Agent"))
	fmt.Fprintf(buff,"[Header]Proto=%s\n",r.Proto)
	fmt.Fprintf(buff,"[Header]Method=%s\n",r.Method)
	fmt.Fprintf(buff,"[Addr]Addr=%s\n",r.RemoteAddr)
	fmt.Fprintf(buff,"[Request]%+v\n",r)
    
	//手动发送响应报文
	io.WriteString(w, "HTTP/1.1 200 OK\r\n")
	io.WriteString(w, fmt.Sprintf("Content-Length: %d\r\n",buff.Len()))
	io.WriteString(w,"\r\n")
	io.Copy(w,buff)	//将buff缓存数据发送给客户端
}

func main(){
	svr:=&httpd.Server{
		Addr: "127.0.0.1:8080",
		Handler: new(myHandler),
	}
	panic(svr.ListenAndServe())
}

利用curl测试我们的接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# -b发送cookie,-i显示响应报文
$ curl "127.0.0.1:8080?name=gu&token=123" -b "foo1=bar1;foo2=bar2;" -i

输出:
HTTP/1.1 200 OK
Content-Length: 488

[query]name=gu
[query]token=123
[cookie]foo1=bar1
[cookie]foo2=bar2
[Header]User-Agent=curl/7.55.1
[Header]Proto=HTTP/1.1
[Header]Method=GET
[Addr]Addr=127.0.0.1:4179
[Request]&{Method:GET URL:/?name=gu&token=123 Proto:HTTP/1.1 Header:map[Accept:[*/*] Cookie:[foo1=bar1;foo2=bar2;] Host:[127.0.0.1:8080] User-Agent:[curl/7.55.1]] Body:0x489290 RemoteAddr:127.0.0.1:4179 RequestURI:/?name=gu&token=123 conn:0xc00007e690 cookies:map[foo1:bar1 foo2:bar2] queryString:map[name:gu token:123]}

这一章我们对Request完成了请求行以及首部字段的解析,同时提供了更简便的查询queryString以及cookie的方法,我们已经完成了除POST以及PUT之外请求地解析。下一章我们将对Request的Body属性进行更为细致地设置,即对应POST请求的解析。

系列目录