目录
【TCP Socket 编程模型】
Socket读操作
【HTTP网络服务】
HTTP客户端
HTTP服务端
TCP/IP 网络模型实现了两种传输层协议:TCP 和 UDP,其中TCP 是面向连接的流协议,为通信的两端提供稳定可靠的数据传输服务;UDP 提供了一种无需建立连接就可以发送数据包的方法。实现网络编程,不仅可以基于应用层协议的HTTP,也可以直接基于传输层暴露给用户的网络编程接口:Socket(套接字)。socket是一种 IPC 方法。
IPC 是 Inter-Process Communication 的缩写,可以被翻译为进程间通信,主要定义的是多个进程之间相互通信的方法。这些方法主要包括:系统信号(signal)、管道(pipe)、套接字 (socket)、文件锁(file lock)、消息队列(message queue)、信号量(semaphore)等。
关于TCP和UDP以及HTTP的相关术语可以参考:HTTP协议中的响应码和实体数据
【TCP Socket 编程模型】
网络 I/O 模型指的是 应用线程与操作系统内核之间的交互模式,常用的有 阻塞 I/O(Blocking)、非阻塞 I/O(Non-Blocking)、I/O 多路复用(I/O Multiplexing)。
- 阻塞I/O模型:内核一直等到全部数据就绪才返回给发起系统调用的应用线程。一个线程仅能处理一个网络连接上的数据通信,即便连接上没有数据,线程也只能阻塞在对 Socket 的读操作上。虽然这个模型对应用整体来说是低效的,但却是最容易实现和使用的,所以各大平台在默认情况下都将 Socket 设置为阻塞的。
- 非阻塞I/O模型:内核查看数据就绪状态后,即便没有就绪也立即返回错误给发起系统调用的应用线程。位于用户空间的 I/O 请求发起者会通过轮询的方式,去一次次发起 I/O请求,直到读到所需的数据为止。不过这样的轮询会浪费很多CPU计算资源,因此非阻塞 I/O 模型单独应用于实际生产的比例并不高。
- I/O多路复用模型:一个应用线程可以同时处理多个 Socket,并且由内核实现可读/可写事件的通知,避免了非阻塞模型中轮询导致的CPU计算资源浪费的问题。
Go语言的socket服务端程序通常采用一个 Goroutine 处理一个连接,主要关键词是Listen和Accept,实现方式如下:
package main
import (
"fmt"
"net"
)
func handleConn(c net.Conn) {
defer c.Close()
for {
//...
}
}
func main() {
//使用net包的Listen函数绑定服务器端口8888,并将它转换为监听状态
l, err := net.Listen("tcp", ":8888")
if err != nil {
fmt.Println("listen error:", err)
return
}
for {
//Listen返回成功后,这个服务会进入一个循环,调用net.Listener 的 Accept 方法接收新客户端连接
//在没有新连接的时候,这个服务会阻塞在 Accept 调用上,直到有客户端连接上来,Accept 方法将返回一个 net.Conn 实例,用于和新连上的客户端进行通信。
c, err := l.Accept()
if err != nil {
fmt.Println("accept error:", err)
break
}
//这个服务程序启动了一个新 Goroutine,并将 net.Conn 传给这个 Goroutine,这样这个Goroutine 就专门负责处理与这个客户端的通信了。
go handleConn(c)
}
}
运行上面的代码后,使用 netstat -an|grep 8888 可以查看端口的监听情况:
当服务端按照上面的Listen + Accept结构成功启动后,客户端就可以使用 net.Dial 或 net.DialTimeout 向服务端发起建立连接的请求。net.Dial函数会接受两个参数,分别名为network和address,都是string类型的。
参数network常用的可选值一共有 9 个,分别代表了程序底层创建的 socket 实例可使用的不同通信协议:
- "tcp":代表 TCP 协议,其基于的 IP 协议的版本根据参数address的值自适应。
- "tcp4":代表基于 IP 协议第四版的 TCP 协议。
- "tcp6":代表基于 IP 协议第六版的 TCP 协议。
- "udp":代表 UDP 协议,其基于的 IP 协议的版本根据参数address的值自适应。
- "udp4":代表基于 IP 协议第四版的 UDP 协议。
- "udp6":代表基于 IP 协议第六版的 UDP 协议。
- "unix":代表 Unix 通信域下的一种内部 socket 协议,以 SOCK_STREAM 为 socket 类型。
- "unixgram":代表 Unix 通信域下的一种内部 socket 协议,以 SOCK_DGRAM 为 socket 类型。
- "unixpacket":代表 Unix 通信域下的一种内部 socket 协议,以 SOCK_SEQPACKET 为 socket 类型。
//Dial 函数向服务端发起 TCP 连接,这个函数会一直阻塞,直到连接成功或失败后,才会返回。
conn, err := net.Dial("tcp", "localhost:8888")
//DialTimeout 带有超时机制,如果连接耗时大于超时时间,这个函数会返回超时错误。
conn, err := net.DialTimeout("tcp", "localhost:8888", 2 * time.Second)
客户端建立连接的几种特殊情况:
- 如果传给Dial的服务端地址是网络不可达,或者服务地址中端口对应的服务并没有启动,端口未被监听(Listen),Dial会立即返回这样的错误:dial error: dial tcp :8888: getsockopt: connection refused
- 当对方服务器很忙,瞬间有大量客户端尝试向服务端建立连接时,服务端可能会出现 listen backlog 队列满,接收连接(accept)不及时的情况,这就会导致客户端的Dial调用阻塞,直
到服务端进行一次 accept,从 backlog 队列中腾出一个槽位,客户端的 Dial 才会返回成功。如果服务端一直不执行accept操作,那么客户端会阻塞大约1分钟左右才会返回超时错误:dial error: dial tcp :8888: getsockopt: operation timed out- 如果网络延迟较大,TCP的三次握手过程会经历各种丢包,时间消耗也会更长,这种情况下Dial函数会阻塞。如果经过长时间阻塞后依旧无法建立连接,那么Dial也会返回 getsockopt: operation timed out的错误。
当客户端调用 Dial 成功,就会在客户端与服务端之间建立起一条通信通道,双方通过各自获得的 Socket可以向对方发送数据包,也可以接收来自对方的数据包,双方都会为已建立的连接分配一个发送缓冲区和一个接收缓冲区。
Socket读操作
开发人员只需要采用 Goroutine + 阻塞 I/O 模型,就可以满足大部分场景需求。Dial 连接成功后,会返回一个 net.Conn 接口类型的变量值,这个接口变量的底层类型为一个 *TCPConn,TCPConn“继承”了conn类型的Read和Write方法,后续通过Dial函数返回值调用的Read和Write方法都是net.conn 的方法,它们分别代表了对 socket 的读和写。
//$GOROOT/src/net/tcpsock.go
type TCPConn struct {
conn //这里的conn是一个非导出类型,封装了底层的 socket
}
Go 从 socket 读取数据的几种场景:
- Socket 中无数据:连接建立后,如果客户端未发送数据,服务端会阻塞在 Socket 的读操作上,执行这个操作的 Goroutine 也会被挂起。Go 运行时会监视这个 Socket,直到它有数据读事件才会重新调度这个 Socket 对应的 Goroutine 完成读操作。
- Socket 中有部分数据:如果 Socket 中有部分数据就绪,且数据数量小于一次读操作期望读出的数据长度,那么读操作将会成功读出这部分数据并返回,而不是等待期望长度数据全部读取后再返回。
- Socket 中有足够数据:如果连接上有数据,且数据长度大于等于一次Read操作期望读出的数据长度,那么Read将会成功读出这部分数据并返回。
- 设置读操作超时:有些场合对 socket 的读操作的阻塞时间有严格限制的,但由于 Go 使用的是阻塞 I/O 模型,如果没有可读数据,Read 操作会一直阻塞在对 Socket 的读操作上。这时可以通过 net.Conn 提供的 SetReadDeadline 方法,设置读操作的超时时间,当超时后仍然没有数据可读的情况下,Read 操作会解除阻塞并返回超时错误,这就给 Read 方法的调用者提供了处理其他业务逻辑的机会。
下面代码是结合 SetReadDeadline 设置的服务端一般处理逻辑:
func handleConn(c net.Conn) {
defer c.Close()
for {
//从连接中读取数据
var buf = make([]byte, 128)
//SetReadDeadline 方法接收一个绝对时间作为超时的 deadline,一旦设置了,
//那么无论后续的Read操作是否超时,后面与这个socket有关的所有读操作都会返回超时失败错误。
//如果要取消超时设置,可以使用 SetReadDeadline(time.Time{})
c.SetReadDeadline(time.Now().Add(time.Second))
n, err := c.Read(buf)
if err != nil {
fmt.Printf("conn read %d bytes, error: %s", n, err)
if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
// 进行其他业务逻辑的处理
continue
}
return
}
fmt.Printf("read %d bytes, content is %s\n", n, string(buf[:n]))
}
}
【HTTP网络服务】
HTTP客户端
HTTP 协议是基于 TCP/IP 协议的,如果只是访问基于 HTTP 协议的网络服务,那么使用net/http代码包中的程序实体会更加便捷。比如使用 http.Get函数获取一个HTTP请求的返回信息,在调用它的时候只需要传给它一个 URL 就可以了:
func testGet() {
url1 := "https://www.baidu.com"
//http.Get函数会返回两个结果值:第一个结果值的类型是*http.Response,表示网络服务传回来的响应内容的结构化表示;
//第二个结果值是error类型的,表示在创建和发送HTTP请求以及接收和解析HTTP响应的过程中可能发生的错误。
resp1, err := http.Get(url1)
if err != nil {
fmt.Printf("请求发送失败: %v\n", err)
}
defer resp1.Body.Close()
line1 := resp1.Proto + " " + resp1.Status
fmt.Printf("返回内容:%s\n", line1) //HTTP/1.1 200 OK
}
func main() {
testGet()
}
HTTP服务端
http.Server类型是基于 HTTP 协议的服务端,其中ListenAndServe方法的功能是:监听一个基于 TCP 协议的网络地址,并对接收到的 HTTP 请求进行处理。该方法会一直执行,直到有严重的错误发生或者被外界关掉。
func httpServer1() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "用户名: %s", r.URL.Path[1:])
})
http.ListenAndServe(":8080", nil)
}
func main() {
httpServer1()
}
启动后,浏览器输入 http://localhost:8080/zhangsan
源代码:https://gitee.com/rxbook/go-demo-2023/tree/master/basic/go04/net