访问网络服务
这篇开始讲网络编程。不过网络编程的内容过于庞大,这里主要讲socket。而socket可以讲的东西也太多了,因此,这里只围绕Go语言介绍一些它的基础知识。
IPC方法
所谓socket,是一种IPC(Inter-Process Communication)方法,可以被翻译为进程间通信。顾名思义,IPC这个概念(或者说规范)主要定义的是多个进程之间,相互通信的方法。这些方法主要包括:
- 系统信号(signal),os包和os/signal包有针对系统信号的API
- 管道(pipe),os.Pipe函数可以创建命名管道,os/exec包支持另一类管道:匿名管道
- 套接字(socket),net包中提供支持
- 文件锁(file lock)
- 消息队列(message queue)
- 信号灯(semaphore),也称为信号量
现存的主要操作系统大都对IPC提供了强有力的支持,尤其是socket。
socket
socket,常被称作套接字,它是网络编程世界中最为核心的知识之一。
毫不夸张的说,在众多IPC方法中,socket是最为通用和灵活的一种。与其他的IPC方法不同,利用socket进行通信的进程,可以不局限在同一台计算机当中。通信双方只要能够通过网络进行互联,就可以使用socket。
支持socket的操作系统一般都会对外提供一套API。跑在它们之上的应用程序,利用这套API就可以与互联网上的另一台计算机中的程序、同一台计算机中的其他程序,甚至同一个程序中的其他线程进行通信。例如,在Linux操作系统中,用于创建socket实例的API,就是由一个名为socket的系统调用代表的。这个系统调用是Linux内核的一部分。所谓的系统调用,你可以理解为特殊的C语言函数。它们是连接应用程序和操作系统内核的桥梁,也是应用程序使用操作系统功能的唯一渠道。
syscall包
在Go语言标准库的syscall包中,有一个与这个socket系统调用相对应的函数。这两者的函数签名是基本一致的,它们都会接受三个int类型的参数,并会返回一个可以代表文件描述符的结果。但不同的是,syscall包中的Socket函数本身是平台不相关的。在其底层,Go语言为它支持的每个操作系统都做了适配,这样这个函数无论在哪个平台上,总是有效的。
在syscall.Socket函数中的三个参数分别是:
- socket实例的通信域
- socket实例的类型
- socket实例的使用协议
下面,通过这3个参数来了解一下socket的基础知识。
通信域
Socket的通信域主要有3种,分别对应syscall包中的一个常量:
- AF_INET : IPv4域
- AF_INET6 : IPv6域
- AF_UNIX : Unix域
关于IPv4和IPv6就不讲了,Unix域简单提一下。
Unix域,指的是一种类Unix操作系统中特有的通信域。在装有此类操作系统的同一台计算机中,应用程序可以基于此域建立socket连接。
类型
Socket的类型一个有4种,在syscall包中有同名的常量对应:
- SOCK_DGRAM
- SOCK_STREAM
- SOCK_SEQPACKET
- SOCK_RAW
上面的4种类型,前两个更加常用。
UDP
SOCK_DGRA中的DGRAM就是datagram,即数据报文。它是一种有消息边界但没有逻辑连接的非可靠socket类型,UDP协议的网络通信就是这类。
有消息边界的意思是,与socket相关的操作系统内核中的程序,即内核程序,在发送或接收数据的时候是以消息为单位的。这里可以把消息理解为带有固定边界的一段数据。内核程序可以自动的识别和维护这种边界。在必要的时候,把数据切割成一个一个的消息,或者把多个消息串接成连续的数据。这样,应用程序值需要面向消息进行处理就可以了。
只要应用程序指定好对方的网络地址,内核程序就可以立即把数据报文发送出去。这有优势也有劣势。优势是,发送速度快,不长期占用网络资源,并且每次发送都可以指定不同的网络地址。最后一条既是优势也是劣势,因为这会使数据报文更长。其他劣势还有,无法保证传输的可靠性,不能实现数据的有序性,以及数据只能单向进行传输。
TCP
SOCK_STREAM类型,是没有消息边界但有逻辑连接,能够保证传输的可靠性和数据的有序性,同时还可以实现数据的双向传输。TCP协议的网络通信就是这类。
有逻辑连接是指,通信双方在收发数据之前必须先建立网络连接。等连接建立好之后,双方就可以一对一的进行数据传输了。
这样的网络通信传输数据的形式是字节流,而不是数据报文。字节流是以字节为单位的。内核程序无法感知一段字节流中包含了多少个消息,以及这些消息是否完整,这完全需要应用程序自己来把控。不过,此类网络通信中的一段,总会忠实的按照另一端发送数据是的字节排列顺序,接收和缓存它们。所以,应用程序需要根据双方的约定去数据中查找消息边界,并按照边界切割数据。
使用协议
通常只要明确指定了前两个参数值,就无需在去确定这里的使用协议了,一般把它置为0就可以了。这时,内核程序会自行选择最合适的协议。
不完整的示例
package main
import (
"fmt"
"os"
"syscall"
)
func main() {
fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, syscall.IPPROTO_TCP)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return
}
defer syscall.Close(fd)
fmt.Println("socket的文件描述符:", fd)
// 之后就省略了,要使用syscall包来建立网络连接,过程太繁琐
}
这个代码包的使用太底层,通常也不需要我们直接使用。Go语言的net包中的很多程序实体,都会直接或间接的使用到syscall.Socket函数,并且无需给定细致的参数。但是,在使用这些API的时候,现在我们就应该知道上面这些基础知识了。
net.Dial函数
net.Dial函数会接受两个参数,network和address,具体看下面:
func Dial(network, address string) (Conn, error) {
var d Dialer
return d.Dial(network, address)
}
network参数
参数network常用的可选值一共有9个,这些值分别代表了程序底层创建的socket实例可使用的不同通信协议:
- "tcp" : 代表TCP协议,其基于的IP协议的版本根据参数address的值自适应
- "tcp4" : 代表基于IPv4协议的TCP协议
- "tcp6" : 代表基于IPv6协议的TCP协议
- "udp" : 代表UDP协议,其基于的IP协议的版本根据address的值自适应
- "udp4" : 代表基于IPv4协议的UDP协议
- "udp6" : 代表基于IPv6协议的UDP协议
- "unix" : 代表Unix通信域下的一种内部socket协议,以SOCK_STREAM为socket类型
- "unixgram" : 代表Unix通信域下的一种内部socket协议,以SOCK_DGRAM为socket类型
- "unixpacket" : 代表Unix通信域下的一种内部socket协议,以SOCK_SEQPACKET为socket类型
net包发送http请求
对于http请求,在标准库里还有更高级的封装,不过http本质上也是socket,这里展示用net包发送请求的示例:
package main
import (
"fmt"
"io"
"net"
"os"
)
func main() {
conn, err := net.Dial("tcp", "baidu.com:80")
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return
}
defer conn.Close()
reqStr := "HEAD / HTTP/1.1\r\n" + // HEAD请求,只返回请求头
"Host: baidu.com\r\n" +
"Connection: close\r\n" + // 返回后,服务器会断开连接,默认是keep-alive
"\r\n" // 请求头结束
_, err = io.WriteString(conn, reqStr)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return
}
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
fmt.Println(string(buf[:n]))
if err != nil {
if err == io.EOF {
fmt.Println("END")
break
} else {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
}
}
}
}
如果是https的请求,还需要借助crypto/tls包,而调用起来基本是一样的:
package main
import (
"crypto/tls"
"fmt"
"io"
"os"
)
func main() {
tlsConf := &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
}
conn, err := tls.Dial("tcp", "gitee.com:443", tlsConf)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return
}
defer conn.Close()
reqStr := "HEAD / HTTP/1.1\r\n" + // HEAD请求,只返回请求头
"Host: gitee.com\r\n" +
"Connection: close\r\n" + // 返回后,服务器会断开连接,默认是keep-alive
"\r\n" // 请求头结束
_, err = io.WriteString(conn, reqStr)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return
}
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
fmt.Println(string(buf[:n]))
if err != nil {
if err == io.EOF {
fmt.Println("END")
break
} else {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
}
}
}
}
net.DialTimeout函数
net.DialTimeout函数和net.Dial函数相比,多接受了一个参数timeout。而底层实现可以看到是一样的,只是对Dialer结构体的Timeout字段进行了设置,而在net.Dial函数里结构体都是默认值:
func DialTimeout(network, address string, timeout time.Duration) (Conn, error) {
d := Dialer{Timeout: timeout}
return d.Dial(network, address)
}
超时时间
这里的超时时间,出函数为网络连接建立完成而等待的最长时间。
开始的时间点几乎是调用net.DialTimeout函数的那一刻。在这之后,时间会主要花费在解析参数的network值和address值,以及创建socket实例并建立网络连接这两件事情上。如果超时了而网络连接还没有建立完成,该函数就会返回一个I/O操作超时的错误值。
在解析address的值的时候,函数会确定网络服务的IP地址、端口号等必要信息,并在需要的时候访问DNS服务。另外,如果解析出的IP地址有多个,函数会串行或并行的尝试建立连接。无论用什么方式尝试,函数总会以最先建立成功的那个连接为准。同时还会根据超时时间的剩余时间去设定对每次连接尝试的超时时间。
找一个国外的网站,或者干脆找一个连不上的地址,看下超时时间的作用:
package main
import (
"fmt"
"net"
"os"
"time"
)
func main() {
tStart := time.Now()
conn, err := net.DialTimeout("tcp", "godoc.org:80", time.Second * 10)
tEnd := time.Now()
fmt.Println("连接持续时间:", time.Duration(tEnd.Sub(tStart)))
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return
}
defer conn.Close()
fmt.Println("本地连接地址:", conn.LocalAddr())
fmt.Println("对端连接地址:", conn.RemoteAddr())
}
有疑问加站长微信联系(非本文作者)