本文将为httpd框架搭建具体的骨干,划分不同的模块,为后续的开发捋清脉络。本文暂不涉及http协议的解析过程,难度较低。

项目结构

框架流程可以见本系列上一篇文章,就以目前的基础框架搭建来说,我们暂时需要4个结构体:

①Server:代表WEB服务器,属性包含监听地址Addr以及Handler,负责服务器的启动逻辑。

②conn:代表HTTP连接,net.Conn表达能力过弱,故将其封装成conn。仅服务于框架内部,不应由用户使用,包内不可导出。

③Request:代表客户端的HTTP请求,由框架从字节流中解析http报文从而生成的结构。

④response:代表响应,实现了ResponseWriter接口。包内不可导出。

其中Request和response就是框架用户在handleFunc这个回调函数中,拿到的两个参数。

需要四个结构体,因此我们建立四个文件,分别为server.go,conn.go,request.go,response.go。目录树如下:

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

1对应今天的第一章,httpd是我们框架的名字,main.go是测试文件,以后不再赘述。

代码传送门

各文件详解

本节只为搭建框架的骨干,因此部分结构体内部暂时为空,部分函数也空实现,在随后的章节中我们一步步进行填充。

request.go

1
2
3
type Request struct{}

func readRequest(c *conn)(*Request,error){return nil,nil}		//暂时空实现

Request结构体就代表了客户端提交的http请求,我们使用readRequest函数从http连接上解析出这个对象。

它是http.HandlerServeHTTP方法的第二个参数。

response.go

1
2
3
type response struct{}

type ResponseWriter interface{}

response结构体就代表服务端的响应对象,我们后期会给其绑定些与客户端交互的方法,供用户使用。这里暂时让responseResponseWriter都空实现。

它是http.HandlerServeHTTP方法的第一个参数。

server.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Handler interface {
	ServeHTTP(w ResponseWriter,r *Request)
}

type Server struct{
	Addr string		//监听地址
	Handler Handler	//处理http请求的回调函数
}

func (s *Server) ListenAndServe()error{
	l,err:=net.Listen("tcp",s.Addr)
	if err!=nil{
		return err
	}
	for{
		rwc,err:=l.Accept()
		if err!=nil{
			continue
		}
		conn:=newConn(rwc,s)
		go conn.serve()			// 为每一个连接开启一个go程
	}
}

上一节提到启动一个服务器其必须项只有Addr以及Handler,他们分别告诉了框架监听地址以及如何处理客户端的请求。事实上,Server结构体中还可以加入很多字段如读取或写入超时时间、能接受的最大报文大小等控制信息,但为了专注于一个框架最核心的实现,我们忽略这些细节内容。

ListenAndServe方法中展现的是go语言socket编程的写法,其大致意思是在Addr上监听TCP连接,将得到的TCP连接rwc(ReadWriteCloser)以及s进行封装得到conn结构体。接着调用conn.serve()方法,开启goroutine处理请求。

项目的开发尽量遵循模块分工原则,server.go只负责WEB服务器的启动逻辑,接下来的http协议的解析交给另一个模块conn.go进行。

conn.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
type conn struct{
	svr  *Server	// 引用服务器对象
    rwc	 net.Conn	// 底层tcp连接
}

func newConn(rwc net.Conn,svr *Server)*conn{
	return &conn{svr: svr, rwc: rwc}
}

func (c *conn) serve(){
	defer func() {
		if err:=recover();err!=nil{
			log.Printf("panic recoverred,err:%v\n",err)
		}
		c.close()
	}()
	//http1.1支持keep-alive长连接,所以一个连接中可能读出个请求,因此实用for循环读取
	for{
		req,err:=c.readRequest()		//解析出Request
		if err!=nil{
			handleErr(err,c)			//将错误单独交给handleErr处理
			return
		}
		res:=c.setupResponse()			//设置response
        
       	// 有了用户关心的Request和response之后,传入用户提供的回调函数即可
		c.svr.Handler.ServeHTTP(res,req)
	}
}

//暂时为空实现,后续小节再填充
func (c *conn) readRequest()(*Request,error){return readRequest(c)}
func (c *conn) setupResponse()*response{return nil}
func (c *conn) close(){c.rwc.Close()}
func handleErr(err error,c *conn){fmt.Println(err)}

在serve方法中,会分别调用readRequest以及setupResponse方法,从而得到*Request以及*response,随后将它们传入用户指定的Handler中,开启实际的请求处理过程。defer中使用recover,防止用户指定的Handler中存在逻辑错误导致发生panic。

利用for循环读取的原因:

对于HTTP 1.0来说,客户端为了获取服务端的每一个资源,都需要为每一个请求进行TCP连接的建立,因此每一个请求都需要等待2个RTT(三次握手+服务端的返回)的延时。而往往一个html网页中往往引用了多个css或者js文件,每一个请求都要经历TCP的三次握手,其带来的代价无疑是昂贵的。

因此在HTTP 1.1中进行了巨大的改进,即如果将要请求的资源在同一台服务器上,则我只需要建立一个TCP连接,所有的HTTP请求都通过这个连接传输,平均下来可以减少一半的传播时延。

如果客户端的请求头中包含connection: keep-alive字段,则我们的服务器应该有义务保证长连接的维持,并持续从中读取HTTP请求,因此这里我们使用for循环。

将err交给handleErr函数处理的原因:

readRequest可能会出现各种错误,如用户连接的断开、请求报文格式错误、服务器系统故障、使用了不支持的http版本、使用了不支持的协议等等错误。

对于有些错误如客户端连接断开或者使用了不支持的协议,我们服务端不应该进行回复。但对于一些错误如使用了不支持的http版本,我们应该返回505状态码;对于请求报文过大的错误,我们应该返回413状态码。因此在handleErr中,我们应该对err进行分类处理。

同样为了尽量关注框架的核心内容,我们这里只进行对err的打印,读者可自行进行扩充。

改进conn结构体

目前conn结构体很简单,我们在读写两个方面进行分别改进,抽象出两个结构成员,一个负责对tcp连接读的逻辑,一个负责写的逻辑。两个改进如下:

写的改进

写的改进比较简单,主要是从性能优化角度出发。以下面代码为例:

1
2
3
4
5
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    for i:=0;i<100;i++{
        io.WriteString(w,strconv.Itoa(i))
    }	
})

100次循环每次写入1~2B的小片段,每一次写入都会进行一次系统调用、一次IO操作,这势必会极大降低应用程序的性能。很显然,可以对用户写入数据进行缓存,缓存不下时再发送就能较少IO次数,从而提升效率。

go标准库提供了现有的工具,不需要重复造轮子。bufio.Writer可以解决我们的问题,它的底层会分配一个缓存切片,我们对这bufio.Writer写入时会优先往这个切片中写入,如果缓存满了,则将切片中缓存的数据发送到最底层的writer中,因此可以保证每次写入的大小都是大于或等于缓存切片的大小。在conn结构中引入bufw成员:

 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
type conn struct{
	svr  *Server
	rwc  net.Conn
	bufw *bufio.Writer		//带缓存的writer
}

func newConn(rwc net.Conn,svr *Server)*conn{
	return &conn{
		svr: svr,
		rwc: rwc,
		bufw: bufio.NewWriterSize(rwc,4<<10), //缓存大小4kB
	}
}

func (c *conn) serve(){
	defer func() {
		if err:=recover();err!=nil{
			log.Printf("panic recoverred,err:%v\n",err)
		}
		c.close()
	}()
	//http1.1支持keep-alive长连接,所以一个连接中可能读出
	//多个请求,因此实用for循环读取
	for{
		req,err:=c.readRequest()
		if err!=nil{
			handleErr(err,c)
			return
		}
		resp:=c.setupResponse()
		c.svr.Handler.ServeHTTP(resp,req)
		//将缓存中的剩余的数据发送到rwc中
		if err=c.bufw.Flush();err!=nil{
			return	
		}
	}
}

我们给conn加入了bufw属性,以后的写入操作都将直接操纵bufw,其缓存的默认大小为4KB。同时在serve方法中,在一个请求处理结束后,bufw的缓存切片中还缓存有部分数据,我们需要调用Flush保证数据全部发送。

读的改进

对于HTTP协议来说,一个请求报文分为三部分:请求行、首部字段以及报文主体,一个post请求的报文如下:

1
2
3
4
5
6
7
8
9
POST / HTTP/1.1\r\n						   	#请求行
Content-Type: text/plain\r\n				#2~7行首部字段,首部字段为k-v对
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
Content-Length: 18\r\n
\r\n
hello,I am client!							#报文主体

其中首部字段部分是由一个个key-value对组成,每一对之间通过\r\n分割,首部字段与报文主体之间则是利用两个连续的CRLF即\r\n\r\n作为分界。首部字段到底有多少个key-value对于服务端程序来说是无法预知的,因此我们想正确解析出所有的首部字段,我们必须一直解析到出现两个连续的\r\n为止。

对于一个正常的http请求报文,其首部字段总长度不会超过1MB,所以直接不加限制的读到空行完全可行,但问题是无法保证所有的客户端都没有恶意。

他可能在阅读框架源码后发现对首部字段的读取未采取任何限制措施,于是发送了一个首部字段无限长的http请求,导致服务器无限解析最终用掉了所有内存直至程序崩溃。因此我们应该为我们的reader限制最大读取量,这是第一个改进,改进用到了标准库的io.LimitedReader

除此之外,首部字段的每个key-value都占用一行(\r\n是换行符),为了方便解析,我们的reader应该有ReadLine方法。这是第二个改进,改进用到了标准库的bufio.Reader

代码变动如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type conn struct{
	svr  *Server
	rwc  net.Conn
	lr   *io.LimitedReader
	bufr *bufio.Reader		//bufr是对lr的封装
	bufw *bufio.Writer
}

func newConn(rwc net.Conn,svr *Server)*conn{
	lr:=&io.LimitedReader{R: rwc, N: 1<<20}
	return &conn{
		svr: svr,
		rwc: rwc,
		bufw: bufio.NewWriterSize(rwc,4<<10),
		lr:lr,
		bufr: bufio.NewReaderSize(lr,4<<10),
	}
}

第一处改进:为conn增加了lr字段,它是一个io.LimitedReader,它包含一个属性N代表能够在这个reader上读取的最多字节数,如果在此reader上读取的总字节数超过了上限,则接下来对这个reader的读取都会返回io.EOF,从而有效终止读取过程,避免首部字段的无限读。

第二处改进:为conn增加bufr字段,它是一个bufio.Reader,其底层的reader为上述的LimitedReader。对于一个io.Reader接口而言,它是无法提供ReadLine方法的,而将其封装程bufio.Reader后,就可以使用这个方法。

bufio.Reader相较io.Reader来说多出了ReadLine方法的原因:

io.Reader提供的Read方法需要传入一个切片,如果传入的切片太小了,可能导致一行未读完;如果传入的切片太大了,则可能导致读取超过了一行。首部字段中的任何一行其长度是不可预知的,所以单纯利用io.Reader的Read方法很难达成目的。当然你可以传入一个字节大小的切片,每次读取1B然后通过不断append的方式,但这样会带来多次的IO开销。

bufio.Reader相较于io.Reader的改进就是,它会存在一个缓存切片,如果缓存切片中存在数据,我们对bufio.Reader进行Read优先会从这个缓存中取。我们平时会遇到一个使用场景就是,我们希望查看一下某个reader中的前多少B的数据,但又不希望我们这次查看之后后续的Read方法再也读不到这些数据,这时我们会将其转为一个bufio.Reader,通过其Peek方法就可以实现上述的要求。其原理就是Peek方法会将你peek出的数据暂存入切片缓存,尽管底层的reader流中不存在了这些数据,但对bufio.Reader进行Read会优先从缓存取,依旧可以将以前消费的数据读取出来。

ReadLine方法就是借助了这个缓存,它会不断地读取数据,如果读取的数据不够一行,则会将这些数据暂存;如果读取的数据够了一行,则将这一行返回,并将剩余未够一行的数据继续缓存。这样不论是一次读多读少,都不会影响Read方法的调用,同时也能减少IO次数提升性能。具体实现可以查看标准库bufio.go源码。

那么以后,我们直接操作的IO对象就是bufrbufw

  • 读数据时直接操作bufr,bufr进而读取io.LimitedReader,进而读取tcp连接。

  • 写数据时直接操作bufw,bufw进而写入到tcp连接。

测试

编写main.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
	"example/httpd"
	"fmt"
)

type myHandler struct {}

func (*myHandler) ServeHTTP(w httpd.ResponseWriter,r *httpd.Request){
	fmt.Println("hello world")
}

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

由于目前未解析request以及response,所以无法去真正写我们的业务代码,但可以测试我们的Handler是否能被正常触发。执行curl命令:

1
curl 127.0.0.1:8080

不出意料的话则会不停的输出hello world。

这一章我们完成了httpd框架骨干的搭建,以及完成对conn的封装。下一章则正式开始HTTP协议的解析工作,我们将封装Request、完成请求行以及请求首部字段的解析。

系列目录