同学们好!在这节课里,我们将一起学习Go语言对网络编程的支持。Go语言的主要设计目标之一就是构建大规模互联网后端服务器程序。网络通信显然是服务器程序不可或缺的基本能力。提到网络编程,大家首先想到的就是套接字。在POSIX标准推出以后,基于套接字的网络编程在各大主流操作系统上都得到了很好的支持,进而成为每个从事网络应用开发的软件工程师的必备技能。Go语言提供给开发者的网络编程接口是建立在系统级原生套接字接口之上的,但为了满足Go语言运行时调度的需要,二者在行为特点和异常处理等方面不可避免地存在着一些差别。
在这一节我们将首先重温OSI和TCP/IP的网络模型,并了解工作在模型中每一层的网络协议。基于套接字的网络编程为网络应用的开发者提供了良好的网络抽象,Go语言在这方面又做了哪些工作?最后我们还需要了解Go语言对基于HTTP协议的Web编程都提供了哪些支持?
计算机网络是由通信线路互相连接的若干能够自主工作的计算机所构成的集合体。网络中各个部件之间以何种规则进行通信,这就是网络模型所要研究的问题。计算机网络模型一般是指OSI的七层参考模型和TCP/IP的四层参考模型。这两种模型在计算机网络中的应用最为广泛。
OSI的七层网络模型由上至下依次为应用层、表示层、会话层、传输层、网络层、数据链路层和物理层。而TCP/IP的四层网络模型则将OSI七层模型中的上三层,即应用层、表示层和会话层合并为应用层,将OSI七层模型中的下两层,即数据链路层和物理层合并为数据链路层。网络模型中的每一层都由若干网络协议提供支撑。比如我们所熟知的HTTP、FTP、Telnet、DNS等协议就是典型的应用层协议,而象TCP、UDP则属于传输层协议,IP、ICMP、ARP和RARP工作在网络层,更底层的诸如FDDI、Ethernet、PPP和IEEE 802系列等统统被归为数据链路层协议。
套接字是POSIX标准定义的一种对运行在网络中不同主机上的应用程序间进行双向通信的端点的抽象。一个套接字就是一个被运行在网络中一台主机上的应用程序所持有的通信端子。套接字为运行在应用层的进程提供了基于网络协议实现数据交换的机制和策略。套接字上连应用程序下连网络协议栈,是工作在用户态的应用程序与工作在内核态的网络协议栈之间传输数据的接口。
对应到前面介绍的网络模型,套接字介于应用层与传输层之间,它以API的形式赋予上层应用访问底层网络的能力。比如我们所熟知的基于TCP协议的套接字网络编程模型。服务器首先需要启动监听,即让套接字具备感知连接请求的能力,然后等待客户机的连接。客户机向服务器发起连接请求,待服务器接受该请求后,客户机和服务器之间的TCP连接即被建立起来。客户机可以通过该连接向服务器发送业务请求,服务器接收该请求,执行必要的业务处理,并将处理结果以响应的形式发送给客户机,客户机从连接中接收来自服务器的业务响应,即完成一次完整的业务交互。当客户机不再需要服务器继续为其提供业务服务,或者服务器不想再继续为客户机提供业务服务时,可以关闭TCP连接,通信的对方可以感知到连接被关闭,也执行相应的关闭动作,通信过程宣告结束。
下面我们将实现一个史上最简单的TCP客户机,它只能完成单次的报文收发,即请求连接——发送请求——接收响应——关闭连接。
这里我们创建一个名为SimpleClient的工程:
x1package main
2
3import (
4 "fmt"
5 "net"
6)
7
8func main() {
9 fmt.Println("请求连接")
10
11 conn, err := net.Dial("tcp", "127.0.0.1:8000")
12 if err != nil {
13 fmt.Println("net.Dial错误:", err)
14 return
15 }
16
17 fmt.Printf("请求报文: ")
18 req := ""
19 fmt.Scan(&req)
20
21 fmt.Println("发送请求")
22
23 num, err := conn.Write([]byte(req))
24 if err != nil {
25 fmt.Println("Conn.Write错误:", err)
26 return
27 }
28
29 fmt.Printf("成功发送: %d字节\n", num)
30
31 fmt.Println("接收响应")
32
33 res := make([]byte, 1024)
34 num, err = conn.Read(res)
35 if err != nil {
36 fmt.Println("Conn.Read错误:", err)
37 return
38 }
39
40 fmt.Printf("成功接收: %d字节\n", num)
41 res = res[:num]
42 fmt.Println("响应报文:", string(res))
43
44 fmt.Println("关闭连接")
45
46 err = conn.Close()
47 if err != nil {
48 fmt.Println("Conn.Close错误:", err)
49 return
50 }
51
52 fmt.Println("成功结束")
53}
首先我们通过net包里的Dail函数向服务器发起连接请求,在该函数的参数中指明所使用的通信协议“tcp”,以及目标主机的IP地址和端口号“127.0.0.1:8000”。如果连接建立成功,我们会得到一个类型为Conn的连接对象conn。接着我们从标准输入读取用户输入的字符串,将其转换为字节切片,作为调用连接对象Write方法的参数,发送给服务器,该方法会返回成功发送的字节数。创建一个可容纳1024字节的响应切片,作为参数传递给连接对象的Read方法,接收来自服务器的响应,该方法会返回成功接收的字节数。以该字节数截取响应切片中的有效内容,转换为字符串并打印。最后通过连接对象的Close方法关闭连接,通信过程结束。
运行上面的客户机,我们会看到net包里的Dail函数返回了一个错误,打印出的错误日志“No connection could be made because the target machine actively refused it”表明因目标主机拒绝而无法建立连接。产生这个错误是因为我们并没有一个服务器在127.0.0.1地址上监听8000端口并等待连接请求。为此我们将实现一个同样最简单的TCP服务器,完成单次的报文收发,即启动监听——等待并接受连接——接收请求——业务处理——发送响应——关闭连接。
这里我们创建一个名为SimpleServer的工程:
xxxxxxxxxx
631package main
2
3import (
4 "fmt"
5 "net"
6)
7
8func main() {
9 fmt.Println("启动监听")
10
11 listener, err := net.Listen("tcp", "127.0.0.1:8000")
12 if err != nil {
13 fmt.Println("net.Listen错误:", err)
14 return
15 }
16
17 fmt.Println("等待连接")
18
19 conn, err := listener.Accept()
20 if err != nil {
21 fmt.Println("Listener.Accept错误:", err)
22 return
23 }
24
25 fmt.Println("接受连接")
26
27 fmt.Println("接收请求")
28
29 req := make([]byte, 1024)
30 num, err := conn.Read(req)
31 if err != nil {
32 fmt.Println("Conn.Read错误:", err)
33 return
34 }
35
36 fmt.Printf("成功接收: %d字节\n", num)
37 req = req[:num]
38 fmt.Println("请求报文:", string(req))
39
40 fmt.Println("业务处理")
41 res := req
42 fmt.Println("响应报文:", string(res))
43
44 fmt.Println("发送响应")
45
46 num, err = conn.Write(res)
47 if err != nil {
48 fmt.Println("Conn.Write错误:", err)
49 return
50 }
51
52 fmt.Printf("成功发送: %d字节\n", num)
53
54 fmt.Println("关闭连接")
55
56 err = conn.Close()
57 if err != nil {
58 fmt.Println("Conn.Close错误:", err)
59 return
60 }
61
62 fmt.Println("成功结束")
63}
首先我们通过net包里的Listen函数启动监听,在该函数的参数中指明所使用的通信协议“tcp”,以及被监听的IP地址和端口号“127.0.0.1:8000”。如果监听启动成功,我们会得到一个类型为Listener的监听器对象listener。接着我们调用监听器对象的Accept方法,等待来自客户机的连接请求,并在连接被成功建立以后返回可用于后续通信的连接对象conn。创建一个可容纳1024字节的请求切片作为参数传递给连接对象的Read方法,接收来自客户机的请求,该方法会返回成功接收的字节数。以该字节数截取请求切片中的有效内容,放到响应切片中。将响应切片作为连接对象Write方法的参数发送给客户机,该方法会返回成功发送的字节数。最后通过连接对象的Close方法关闭连接,通信过程结束。
先启动服务器,再启动客户机,在客户机中输入一个字符串,观察客户机和服务器的日志输出,验证单次报文收发的流程。服务器启动监听——服务器等待连接——客户机请求连接——服务器接受连接——客户机发送请求——服务器接收请求——服务器业务处理——服务器发送响应——客户机接收响应——客户机服务器双双关闭连接。这个通信模型最大的问题是,客户机只能发送一个请求,接收一个响应,服务器也只能接收一个请求,发送一个响应。更理想的情况应该允许客户机发送多个请求,服务器一一处理并返回响应,直到客户机决定不再发送请求了,服务器再退出运行。下面我们将首先实现一个支持循环收发报文的客户机。
这里我们创建一个名为LoopClient的工程:
xxxxxxxxxx
591package main
2
3import (
4 "fmt"
5 "net"
6)
7
8func main() {
9 fmt.Println("请求连接")
10
11 conn, err := net.Dial("tcp", "127.0.0.1:8000")
12 if err != nil {
13 fmt.Println("net.Dial错误:", err)
14 return
15 }
16
17 for {
18 fmt.Printf("请求报文: ")
19 req := ""
20 fmt.Scan(&req)
21
22 if req == "!!!" {
23 break
24 }
25
26 fmt.Println("发送请求")
27
28 num, err := conn.Write([]byte(req))
29 if err != nil {
30 fmt.Println("Conn.Write错误:", err)
31 return
32 }
33
34 fmt.Printf("成功发送: %d字节\n", num)
35
36 fmt.Println("接收响应")
37
38 res := make([]byte, 1024)
39 num, err = conn.Read(res)
40 if err != nil {
41 fmt.Println("Conn.Read错误:", err)
42 return
43 }
44
45 fmt.Printf("成功接收: %d字节\n", num)
46 res = res[:num]
47 fmt.Println("响应报文:", string(res))
48 }
49
50 fmt.Println("关闭连接")
51
52 err = conn.Close()
53 if err != nil {
54 fmt.Println("Conn.Close错误:", err)
55 return
56 }
57
58 fmt.Println("成功结束")
59}
事实上,我们只需在SimpleClient的基础上略加修改。把从标准输入读取字符串,到打印响应报文这段代码放到一个无限循环中。同时增加一个对输入字符串的判断,如果用户输入连续的三个惊叹号,则退出循环。
下面我们再实现一个同样支持循环收发报文的服务器,在一个循环中处理来自客户机的多个请求,并在客户机关闭连接后退出运行。
这里我们创建一个名为LoopServer的工程:
xxxxxxxxxx
701package main
2
3import (
4 "fmt"
5 "io"
6 "net"
7)
8
9func main() {
10 fmt.Println("启动监听")
11
12 listener, err := net.Listen("tcp", "127.0.0.1:8000")
13 if err != nil {
14 fmt.Println("net.Listen错误:", err)
15 return
16 }
17
18 fmt.Println("等待连接")
19
20 conn, err := listener.Accept()
21 if err != nil {
22 fmt.Println("Listener.Accept错误:", err)
23 return
24 }
25
26 fmt.Println("接受连接")
27
28 for {
29 fmt.Println("接收请求")
30
31 req := make([]byte, 1024)
32 num, err := conn.Read(req)
33 if err != nil {
34 if err == io.EOF {
35 break
36 }
37
38 fmt.Println("Conn.Read错误:", err)
39 return
40 }
41
42 fmt.Printf("成功接收: %d字节\n", num)
43 req = req[:num]
44 fmt.Println("请求报文:", string(req))
45
46 fmt.Println("业务处理")
47 res := req
48 fmt.Println("响应报文:", string(res))
49
50 fmt.Println("发送响应")
51
52 num, err = conn.Write(res)
53 if err != nil {
54 fmt.Println("Conn.Write错误:", err)
55 return
56 }
57
58 fmt.Printf("成功发送: %d字节\n", num)
59 }
60
61 fmt.Println("关闭连接")
62
63 err = conn.Close()
64 if err != nil {
65 fmt.Println("Conn.Close错误:", err)
66 return
67 }
68
69 fmt.Println("成功结束")
70}
与客户机的情况类似,我们也可以在SimpleServer的基础上略加修改。把从接收请求到发送响应这段代码放到一个无限循环中。同时增加一个对接收请求失败的判断,如果客户机关闭了连接,服务器对连接对象Read方法的调用会返回一个错误,而错误的值为io包中的EOF,以此作为退出循环的条件。
我们的客户机和服务器截至目前已经臻于完美,但还有一个不容忽视的瑕疵。那就是我们的服务器还不能支持并发。假设我们的服务器正在处理和某个客户机的业务交互,从流程上看,它会一直执行无限循环体中的代码。如果这时又有另一个客户机请求与服务器建立连接,服务器是无暇兼顾的,因为它正忙于处理上一个客户机的业务,根本没有机会再次调用监听器对象的Accept方法,接受新客户机的连接请求。那么如何让一个服务器能够同时与多个客户机建立并保持连接,并同时处理它们的业务请求呢?这就要用到我们在前序课程中为大家介绍过的基于goroutine的并发编程。首先我们可以把对监听器对象Accept方法的调用放到一个循环中,只要该函数返回了一个连接对象,即成功建立起一个面向客户机的连接,我们就创建一个独立的goroutine,并在该goroutine中处理与客户机的业务交互,于此同时在循环中继续调用监听器对象的Accept方法,等待新的连接请求。
下面我们将创建一个名为TCPServer的工程,引入并发编程的思想,实现一个相对而言更具典型意义的TCP并发服务器:
xxxxxxxxxx
741package main
2
3import (
4 "fmt"
5 "io"
6 "net"
7)
8
9func main() {
10 fmt.Println("启动监听")
11
12 listener, err := net.Listen("tcp", "127.0.0.1:8000")
13 if err != nil {
14 fmt.Println("net.Listen错误:", err)
15 return
16 }
17
18 for {
19 fmt.Println("等待连接")
20
21 conn, err := listener.Accept()
22 if err != nil {
23 fmt.Println("Listener.Accept错误:", err)
24 return
25 }
26
27 fmt.Println("接受连接")
28
29 go func() {
30 for {
31 fmt.Println("接收请求")
32
33 req := make([]byte, 1024)
34 num, err := conn.Read(req)
35 if err != nil {
36 if err == io.EOF {
37 break
38 }
39
40 fmt.Println("Conn.Read错误:", err)
41 return
42 }
43
44 fmt.Printf("成功接收: %d字节\n", num)
45 req = req[:num]
46 fmt.Println("请求报文:", string(req))
47
48 fmt.Println("业务处理")
49 res := req
50 fmt.Println("响应报文:", string(res))
51
52 fmt.Println("发送响应")
53
54 num, err = conn.Write(res)
55 if err != nil {
56 fmt.Println("Conn.Write错误:", err)
57 return
58 }
59
60 fmt.Printf("成功发送: %d字节\n", num)
61 }
62
63 fmt.Println("关闭连接")
64
65 err = conn.Close()
66 if err != nil {
67 fmt.Println("Conn.Close错误:", err)
68 return
69 }
70 }()
71 }
72
73 fmt.Println("成功结束")
74}
我们还是在LoopServer的基础上进行修改。首先把从等待连接到关闭连接这段代码放到一个无限循环中,然后把包含接收请求到发送响应的无限循环和关闭连接的代码,以匿名函数的形式放到一个goroutine中。基于Go语言的TCP并发服务器就是这么简单!
TCP协议固然可以很好地解决网络数据的可靠传输问题,但面向互联网的应用却很少直接基于TCP协议开发。因为HTTP协议在TCP协议的基础上,对数据载荷的格式,会话建立的过程,乃至可能发生的错误等等都做了充分的标准化。而且HTTP协议完全是文本化的,与系统平台无关,与编程语言无关,具有更好的兼容性和异构性。
在学习基于HTTP协议的编程之前,我们有必要先简单了解一下HTTP协议本身。
HTTP协议是一种应用层协议,它工作在TCP协议之上,主要用于面向互联网的Web应用开发。HTTP协议还是一种无状态协议,每个HTTP请求都是独立的,每次发送请求都要重新建立连接。
除了HTTP以外,我们更常见到的可能是HTTPS,但HTTPS并不是一种标准化的协议,它是在HTTP的基础上增加了基于SSL的安全机制。通常被用于Web应用开发的编程语言主要有Java、PHP、Python、Go等,其中PHP目前已基本被Go取代。Java长期以来一直居于Web应用开发的领导地位,但近年来随着Go语言的日趋完善,基于Go语言的技术生态正在快速成长,大有和Java平分秋色之势。
HTTP请求是HTTP客户端,如浏览器,发送给HTTP服务器,如Web服务器,的消息文本。
一个完整的HTTP请求由请求行、请求头和请求体,三部分组成。其中请求行和请求头是必须有的,请求体因情况而异,可能有也可能没有。请求行中包括方法、路径和协议,三个字段,其间以空格隔开。请求头由多个键值对形式的文本行组成,键值间以冒号分隔,其中包含客户端类型、可接受媒体类型、可接受语言、可接受字符编码、连接模式、请求体内容类型、请求体内容长度、Cookie等信息。无论有无请求体,请求头后面都必须有一个空白行,表示请求头到此结束。请求体的形式可能会非常多样,最常见的是表单形式和JSON字符串形式。有关JSON,我们将在下节课为大家介绍。这里看到的其实是表单形式的请求体字符串。表单中包括多个键值对,键值间以等号分隔,键值对间以“&”字符分隔。
请求行中包括方法名、资源路径和协议及版本。常用的请求方法有,GET方法用于获取数据,POST方法用于上传数据,所上传的数据通常位于请求体中的表单或JSON字符串中,PUT方法用于修改数据,DELETE方法用于删除数据。
请求头中的每一行都是一个键值对,键值之间以冒号分隔。大多数键都是由HTTP协议预定义的,表达特定的语义,比如User-Agent表示浏览器信息、Accept表示可接受的数据格式、Accept-Encoding表示可接受的编码类型、Connection表示连接模式,短连接或长连接、Content-Type表示请求体中的内容类型,表单或JSON字符串、Cookie表示服务器设置的键值对,等等。此外还可以包含自定义的键,由特定服务解释并处理。
请求头的后面必须有一个空白行。即使没有请求体,这个空白行也必须有。它是请求头结束的标志。请求体不是每种请求方法都会有。通常只有POST和PUT方法带有请求体,其中包含需要上传到服务器的数据。常见的请求体有表单和JSON字符串两种。
当浏览器前端需要向服务器后端传递数据时,可以有三种选择将数据承载于HTTP请求中。第一种方法是将需要传递的数据格式化为类似“键=值&键=值&键=值”的形式,连缀在请求行中的资源路径之后,与资源路径间以问号隔开。第二种方法是以自定义键的形式放在请求头中。第三种方法是以表单或JSON字符串的形式放在请求体中。当所传递的数据比较少时,推荐使用第一种方法。而如果所要传递的数据比较多,则以JSON字符串的形式上传更为常见。
HTTP响应是HTTP服务器,如Web服务器,发送给HTTP客户端,如浏览器,的消息文本。
一个完整的HTTP响应由状态行、响应头和响应体,三部分组成。其中状态行和响应头是必须有的,响应体因情况而异,可能有也可能没有。状态行中包括协议、状态码和状态描述,三个字段,其间以空格隔开。响应头由多个键值对形式的文本行组成,键值间以冒号分隔,其中包含服务器类型、日期时间、连接模式、响应体内容类型、响应体内容长度等信息。无论有无响应体,响应头后面都必须有一个空白行,表示响应头到此结束。响应体的形式可能会非常多样,可以是纯文本、也可以是HTML页面、JSON字符串,甚至是二进制流,比如图片、音视频等。
状态行中包括协议及版本、状态码和状态描述。状态码由HTTP协议定义,比如200代表一切正常、401代表所请求的访问未被授权、404代表所请求的资源没有找到、501代表服务器内部出现错误,等等。
响应头中的每一行都是一个键值对,键值之间以冒号分隔。大多数键都是由HTTP协议预定义的,表达特定的语义,比如Server表示服务器描述、Content-Type表示响应体中的内容类型、Date表示响应产生的日期和时间,等等。响应头的后面必须有一个空白行。即使没有响应体,这个空白行也必须有。它是响应头结束的标志。
响应体并非每个响应都必须有,这取决于该响应是否需要承载数据给业务请求的发起方。而且响应体的形式也是多种多样的,既可能是纯文本形式的简单字符串,也可能是HTML格式的页面内容,或者JSON形式的对象描述,甚至可能是二进制形式的多媒体数据。它们都是合法的HTTP响应体。
下面我们将创建一个HTTPClient工程,向百度的服务器发起一个GET请求,获取其默认主页:
xxxxxxxxxx
191package main
2
3import (
4 "fmt"
5 "net/http"
6 "os"
7)
8
9func main() {
10 client := http.Client{}
11
12 resp, err := client.Get("http://www.baidu.com")
13 if err != nil {
14 fmt.Println("Client.Get错误:", err)
15 return
16 }
17
18 resp.Write(os.Stdout)
19}
在Go语言中实现一个HTTP客户机非常简单,只需通过http包的Client类,实例化一个代表客户机的对象,而后调用其Get方法,并在参数中给出完整的URL字符串,若成功即返回来自服务器的响应。这里我们将其简单地打印到标准输出。
Go语言的http包除了可以帮助我们构建HTTP客户机外,也提供了构建HTTP服务器的功能。
基于http包构建HTTP服务器只需两个步骤。第一步注册路由。调用http包的HandleFunc函数,提供两个参数,一个是URL中的请求路径,即路由字符串,一个是与该请求路径对应的路由处理函数。路由处理函数负责解析请求、执行业务处理、构造响应。第二步启动监听并等待连接。调用http包的ListenAndServe函数,传入所要监听的IP地址和端口号。该函数将始终阻塞,在建立连接后开启独立的goroutine,根据请求中的路由字符串,调用相应的路由处理函数。
下面我们创建一个HTTPServer工程,体验一下基于http包构建HTTP服务器的方法:
xxxxxxxxxx
531package main
2
3import (
4 "fmt"
5 "net/http"
6 "os"
7)
8
9func main() {
10 fmt.Println("注册/user路由")
11 http.HandleFunc("/user", onUser)
12
13 fmt.Println("注册/news路由")
14 http.HandleFunc("/news", onNews)
15
16 fmt.Println("注册/blog路由")
17 http.HandleFunc("/blog", func(writer http.ResponseWriter, request *http.Request) {
18 fmt.Println("解析请求")
19 request.Write(os.Stdout)
20
21 fmt.Println("业务处理")
22
23 fmt.Println("构造响应")
24 writer.Write([]byte("处理/blog路由"))
25 })
26
27 fmt.Println("启动监听并等待连接")
28
29 if err := http.ListenAndServe("127.0.0.1:8080", nil); err != nil {
30 fmt.Println("http.ListenAndServe错误:", err)
31 return
32 }
33}
34
35func onUser(writer http.ResponseWriter, request *http.Request) {
36 fmt.Println("解析请求")
37 request.Write(os.Stdout)
38
39 fmt.Println("业务处理")
40
41 fmt.Println("构造响应")
42 writer.Write([]byte("处理/user路由"))
43}
44
45func onNews(writer http.ResponseWriter, request *http.Request) {
46 fmt.Println("解析请求")
47 request.Write(os.Stdout)
48
49 fmt.Println("业务处理")
50
51 fmt.Println("构造响应")
52 writer.Write([]byte("处理/news路由"))
53}
在这段代码中,我们首先通过http包的HandleFunc函数注册了三个路由处理函数,处理“/user”路由的onUser函数,处理“/news”路由的onNews函数和处理“/blog”路由的匿名函数。接着我们调用了http包的ListenAndServe函数,启动针对127.0.0.1地址上8080端口的监听,并等待连接。在每个路由处理函数内部,我们收到两个参数,一个是http包ResponseWriter类型的响应输出器对象,一个是http包Request类型的请求对象。注意请求对象我们收到的是指向该对象的指针。在路由处理函数中,我们可以从请求对象中获得HTTP请求中的各项数据,并在完成业务处理后,通过响应输出器对象将处理结果写入到响应报文中,回传给客户机。运行该服务器,打开浏览器,分别在地址栏输入不同的URL,如“http://localhost:8080/user”、“http://localhost:8080/news”、“http://localhost:8080/blog”,观察浏览器显示的响应内容和服务器打印的日志信息,以验证路由处理函数的执行是否正确。
谢谢大家,我们下节课再见!