socket
常译为套接字,也是一种 IPC (Interprocess communication 进程间通信)
方法。但是与其他 IPC 方法不同的是,它可以通过网络连接让多个进程建立通信并相互传递数据,使通信端的位置透明化。
socket的基本特性
大多数操作系统都包含 socket接口的实现,主流以及新兴的编程语言也都支持socket,Go 当然也不例外。
下面从操作系统提供的socket接口开始讲起。在Linux系统中,存在一个名为 socket的系统调用,其声明如下:
int socket(int domain, int type, int protocol)
该系统调用的功能是创建一个 socket 实例。它接受3个参数,分别代表这个 socket 的通信域、类型 和 所用协议。
通信域 (domain)
每个 socket 都必须存在于一个通信域当中,而通信域又决定了该 socket 的地址格式和通信范围。
通信域 | 含义 | 地址格式 | 通信范围 |
---|---|---|---|
AF_INET | IPv4域 | IPv4地址(4个字节),端口号(2个字节) | 在基于IPv4协议的网络中任意两台计算机之上的两个应用程序 |
AF_ INET6 | IPv6域 | IPv6地址(16个字节),端口号(2个字节) | 在基于IPv6协议的网络中任意两台计算机之上的两个应用程序 |
AF_UNIX | Unix域 | 路径名称 | 在同一台计算机上的两个应用程序 |
*这里解释一下通信域 AF_ 前缀,其实就是 address family 的缩写,也就是地址族
类型 (type)
socket 的类型有很多,包括 SOCK_STREAM、SOCK_DGRAM、 更底层的 SOCKRAW,以及SOCK_SEQPACKET。
特性 | socket 类型 | |||
SOCK_DGRAM | SOCK_RAW | SOCK_SEQPACKET | SOCK_STREAM | |
数据形式 | 数据报 | 数据报 | 字节流 | 字节流 |
数据边界 | 有 | 有 | 有 | 没有 |
逻辑连接 | 没有 | 没有 | 有 | 有 |
数据有序性 | 不能保证 | 不能保证 | 能够保证 | 能够保证 |
传输可靠性 | 不具备 | 不具备 | 具备 | 具备 |
- 数据报:为了让socket 接收方可以意识到数据的边界,并会对他们进行拆分。
- 字节流:实际是传输的一个字节接一个字节的串,可以看做是一个字节数组。
- 数据边界:应用程序每次发送的字节流片段之间的分界点。
根据上表可以看出socket的逻辑连接与否,跟socket的有序性和可靠性,有很大关系。
所用协议
在调用系统调用 socket的时候,一般会把 0 作为它的第三个参数值,其含义是让操作系统内核根据第一个参数和第二个参数的值自行决定 socket 所使用的协议,这也意味者socket 的通信域和类型与所用协议之间是存在对应关系的。
决定因素 | SOCK_DGRAM | SOCK_RAW | SOCK_SEQPACKET | SOCK_STREAM |
---|---|---|---|---|
AF_INET | UDP | IPv4 | SCTP | TCP或SCTP |
AF_INET6 | UDP | IPv6 | SCTP | TCP或SCTP |
AF_INET | 有效 | 无效 | 有效 | 有效 |
* “有效”表示该通信域和类型的组合会使内核选择某个内部的 socket协议。“无效”则表示该通信域和类型的组合是不合法的。在Go提供的 socket 编程 API 中,也会涉及这些组合,并有一些专用的字符串字面量来表示它们。
返回值
系统调用 socket 会返回一个 int 类型的值,该值是socket 实例唯一标识符的文件描述符。用于调用其他系统调用来进行各种相关操作,比如 绑定、监听端口、发送和接收数据、关闭socket实例,等等。
GO 基于 TCP/IP 协议栈的 socket 通信
net.Listen
我们以TCP 协议为例,Go 为我们提供了 socket 编程 API。下面我们会用到 net 包中的 API。
func listen(net, laddr string) (Listenner, error)
函数 net.Listen 用于获取监听器,它接受两个 string类型的参数。第一个参数的含义是以何种协议监听给定的地址。在Go中,这些协议由一些字符串字面量来表示:
字面量 | scoket协议 | 备注 |
---|---|---|
“tcp” | TCP | 无 |
“tcp4” | TCP | 网络互联层协议仅支持IPv4 |
“tcp6” | TCP | 网络互联层协议仅支持IPv6 |
“udp” | UDP | 无 |
“udp4” | UDP | 网络互联层协议仅支持IPv4 |
“udp6” | UDP | 网络互联层协议仅支持IPv6 |
“unix” | 有效 | 可看作通信域为AF_UNIX且类型为 SOCK_STREAM 时内核采用的默认协议 |
“unixgram” | 有效 | 可看作通信域为AF_UNIX且类型为 SOCK_DGRAM 时内核采用的默认协议 |
“unixpacket” | 有效 | 可看作通信域为AF_UNIX且类型为 SOCK_SEQPACKET 时内核采用的默认协议 |
需要说明的是,这个参数所代表的必须是面向流的协议。TCP 和 SCTP 都属于面向流的传输层协议,但不同的是,TCP 协议实现程序无法记录和感知任何消息边界,也无法从字节流分离出消息,而SCTP 协议实现程序却可以做到这一点。综上所述,net.Listen 函数的第一个参数的值必须是tcp、tcp4、tcp6、unix和 unixpacket中的一个。
unix和 unixpacket分别代表两个通信域为 Unix 域的内部socket协议,遵循它们的 socket实例仅用于本地计算机上不同应用程序之间通信。
net.Listen 函数的第二个参数laddr 的值表示当前程序在网络中的标识。laddr是Local Address 的简写形式,它的格式是 host:port,其中host代表IP 地址或主机名,而port则代表当前程序欲监听的端口号,例如127.0.0.1:8087。
注意,host处的内容必须是与当前计算机对应的 IP地址或主机名,否则调用该函数时会出错。另外,如果 host处的是主机名,那么该 API 中的程序(以下简称 API 程序)会先通过DNS(Domain Name System,域名系统)找到与该主机名对应的IP 地址。若host处的主机名没有在 DNS 中注册,那么同样也会出错
net.Listen 函数被调用之后,会返回两个结果值:第一个结果值是 net.Listener 类型的,它代表的就是监听器;第二个结果值是一个 error 类型的值。那么现在就可以尝试一下创建实例啦:
listenner, err := net.Listen("tpc", "127.0.0.1:8087")
if err != nil {
// 错误处理
//fmt.Println("...")
return
}
// 等待客户端连接
conn, err := listenner.Accept()
if err != nil {
// 错误处理
// ...
}
当调用监听器的 Accept 方法时,流程会被阻塞,直到某个客户端程序与当前程序建立 TCP连接。此时,Accept 方法会返回两个结果值:第一个结果值代表了当前 TCP 连接的 net.Conn类型值,而第二个结果值依然是一个 error 类型的值。
net.Dial
直到这里都只是在说服务端的创建,而客户端的创建就要调用 net 包的 Dial 函数。
conn, err := net.Dial(network, address string) (Coun, error)
该函数也接收两个参数。其中第一个参数 network 与 net.Listen 的第一个参数很像,但前者可以有跟多选择,因为客户端在发送数据之前不一定要先建立连接,就像 UDP协议 和 IP 协议 都是面向无连接型的协议,因此 udp、udp4、udp6、ip、ip4、ip6 都可以作为参数 network 的值。
第二个参数 adtress 的含义与 net. Listen 的数的第二个参数 Laddr完全一致。如果想与前面刚刚开始监听的服务端程序连接的话,那么这个参数的值就是该服务端的地址,即为 127.0.0.1:8087。那么代码如下:
conn, err := net.Dial("tcp", "127.0.0.1:8087")
if err != nil {
// 错误处理
//...
}
在实际开发中,网络请求肯定会出现延迟现象,那么就需要给请求设置一个请求超时时间;默认情况下系统会一个超时时间,比如Linux,会把基于 TCP 协议的链接请求链接超时时间设定位 75 秒。当然我们 Go 中也是提供了相应的API的,net.DialTimeout
函数的声明如下:
func DialTimeout(network, address string, timeout time.Duration) (Conn, error)
比前者多了个 timeout 参数,它的类型是 time.Duration
,单位是纳秒。一般情况我们设置的时间都比纳秒高好几级,不过 time 包给我们预先声明了与常用时间单位的相对应的 time.Duration
类型的常量。比如说我们要设置 2 秒,只需要 2*time.Second
,结合上面的例子:
conn, err := net.DialTimeout("tcp", "127.0.0.1:8087", 2*time.Second)
if err != nil {
// 错误处理
}
至此,我讲述的API 足以在服务端程序和客户端程序之间建立 TCP 连接。如果去查看 Go 源码会发现,最终go还是会去调用系统内核的API来创建 socket 实例的。
net.Conn
net.Conn
类型,它是一个接口类型,在它的方法集合中包含了8个方法,它们定义了可以在一个连接上做的所有事情。
Read 方法
用于从 socket 的接收缓冲区中读取数据,声明如下:
// Read reads data from the connection.
// Read can be made to time out and return an error after a fixed
// time limit; see SetDeadline and SetReadDeadline.
Read(b []byte) (n int, err error)
该方法接受一个 []byte
类型的参数,该参数的值相当于一个用来存放从连接上接收到的数据的容器,它的长度完全由应用程序决定。Read 方法会把它当成空的容器并试图填满,该容器中相应位置上的原元素值将会被替换。为了避免混乱,我们应该总是让这个容器在填充之前保持绝对干净。即传递给 Read 方法的参数应该是一个不包含任何非零值元素的切片值。
结果n代表本次操作实际读取到的字节的个数,也可以把它理解为 Read方法向参数值中填充的字节的个数。你可以这样使用它:
b := make([]byte, 10)
n,err := conn. Read (b)
content := string (b[:n])
通过依据结果n 对参数b做切片操作可以抽取出接收到的数据。另外,这里仍然需要通过检查第二个结果值来判断函数的执行是否正常。不过,这里的错误检查会稍微复杂一些。直接看代码:
var dataBuffer bytes.Buffer
b := make([]byte, 10)
for {
n, err := conn.Read(b)
if err != nil {
if err == io.EOF {
fmt.Println("The connection is closed.")
conn.Close()
} else {
fmt.Println("Read Error: %s\n", err)
}
break
}
dataBuffer.Write(b[:n])
}
如果 socket编程API程序在从socket的接收缓冲区中读取数据时发现TCP连接已经被另一端关闭了,就会立即返回一个 error 类型值。这个 error 类型值与io.EOF
变量的值是相等的,其中 io.EOF
象征文件内容的完结。若该值 io.EOF
,则意味着在此 TCP连接之上再无可读取的数据。也可以说,该 TCP 连接已经无用,可以关闭了。因此,如果 Read
方法的第二个结果值与io.EOF
变量的值相等,就中止后续的数据读取操作,并关闭该 TCP连接。
如果我们去看源码 net.Conn
接口类型的声明的话,会发现该类型重写了 Read
方法,即实现了 io.Reader
接口,因此我们可以使用 bufio.NewReader
函数来包装变量 conn:
reader := bufio.NewReader(conn)
// 假设消息的拆分变量约定好是 '\n'
line, err := reader.ReadBytes('\n')
Write 方法
用于向 socket 的发送缓存冲去写入数据,声明如下:
Write(b []byte) (n int, err error)
同样我们可以使用 bufio
中的 API 来使这里的写操作更加灵活。原理同上:
writer := bufio.NewWriter(conn)
Close 方法
关闭当前链接。声明如下
Close() error
// error message: use of closed network connection
LocalAddr 和 RemoteAddr 方法
他们都不接收参数并返回一个 net.Addr 类型的结果。其结果值代表了参与当前通信的某一端程序在网络中的地址。LocalAddr
方法返回本地地址,而 RemoteAddr
方法则返回远程地址。net.Addr
声明了两个内置方法,Network
方法表示获取当前使用的协议名称,String
方法则返回对应的地址。使用代码如下:
// 获取本地值
conn.LocalAddr().Network()
conn.LocalAddr().String()
// 获取远程值
conn.RemoteAddr().NetWrok()
conn.RemoteAddr().String()
SetDeadline、SetReadDeadline、SetWriteDeadline 方法
这三个方法只接收一个 time.Time
类型值作为参数,并返回一个 error 类型值作为结果。声明:
// SetDeadline sets the read and write deadlines associated
// with the connection. It is equivalent to calling both
// SetReadDeadline and SetWriteDeadline.
//
// A deadline is an absolute time after which I/O operations
// fail instead of blocking. The deadline applies to all future
// and pending I/O, not just the immediately following call to
// Read or Write. After a deadline has been exceeded, the
// connection can be refreshed by setting a deadline in the future.
//
// If the deadline is exceeded a call to Read or Write or to other
// I/O methods will return an error that wraps os.ErrDeadlineExceeded.
// This can be tested using errors.Is(err, os.ErrDeadlineExceeded).
// The error's Timeout method will return true, but note that there
// are other possible errors for which the Timeout method will
// return true even if the deadline has not been exceeded.
//
// An idle timeout can be implemented by repeatedly extending
// the deadline after successful Read or Write calls.
//
// A zero value for t means I/O operations will not time out.
SetDeadline(t time.Time) error
// SetReadDeadline sets the deadline for future Read calls
// and any currently-blocked Read call.
// A zero value for t means Read will not time out.
SetReadDeadline(t time.Time) error
// SetWriteDeadline sets the deadline for future Write calls
// and any currently-blocked Write call.
// Even if write times out, it may return n > 0, indicating that
// some of the data was successfully written.
// A zero value for t means Write will not time out.
SetWriteDeadline(t time.Time) error
其实官方的注释已经写得很明确了,这里还是用白话文翻译一下吧,哈哈。SetDeadline
方法会设置在当前链接上的I/O 操作的超时时间,这里的超时时间是绝对时间,有别于上面说的连接超时设置方法 DailTimeout
。其返回的err的提示信息为 “i/o time” (os.ErrDeadlineExceeded
)。直接来段代码演示:
b := make([]byte, 10)
conn.SetDeadline(time.Now().Add(2 * time.Second))
for {
n, err := conn.Read(b)
// 省略若干语句。。
}
上述代码表示,在2秒后,当前链接后面的io操作都会超时,这样的操作在现实中肯定是不太合理的,我们改一下:
b := make([]byte, 10)
for {
conn.SetDeadline(time.Now().Add(2 * time.Second))
n, err := conn.Read(b)
// 省略若干语句。。
}
这样,在每次读取时,都重新设置超时时间为2秒,这样就相对合理很多。
另外就是,如果你不再想设置超时时间的时候,可以给 SetDeadline
方法传入一个 time.Time 类型的零值,代码如下:
conn.SetDeadline(time.Time{})
而另外两个方法 SetReadDeadline
和 SetWriteDeadline
,想必仔细看方法名都能猜到它们的功能了,就是分别设置 io
读与写的超时时间。
对于写操作的超市,有一个问题需要明确,那就是即使一个写操作超时了,也不一定表示写操作完全没有成功。因为在超时之前,Write 方法背后的程序可能已经将一部分数据写入到 socket 的发送缓冲区了。也就是说,即使 Write 方法因操作超时耳被迫结束时,它的第一个结果值也可能大于0,这时,第一个结果值就表示在操作超时之前被真正写入的数据的字节数量。