大部分底层网络的编程都离不开 socket 编程,HTTP 编程、Web 开发、IM 通信、视频流传输的底层都是 socket 编程。
网络上的两个程序通过一个双向的通信连接实现数据的交换,这个连接的一端称为一个 socket。
建立网络通信连接至少要一对端口号(socket),socket 本质是编程接口(API),对 TCP/IP 的封装,TCP/IP 也要提供可供程序员做网络开发所用的接口,这就是 Socket 编程接口。
可以将 HTTP 比作轿车,它提供了封装或者显示数据的具体形式;那么 Socket 就是发动机,它提供了网络通信的能力。
Socket 的英文意思是“孔”或“插座”,作为 BSD UNIX 的进程通信机制,取后一种意思,通常也称作“套接字”,用于描述 IP 地址和端口,是一个通信链的句柄,可以用来实现不同虚拟机或不同计算机之间的通信。
每种服务都打开一个 Socket,并绑定到一个端口上,不同的端口对应于不同的服务。Socket 正如其英文意思那样,像一个多孔插座。插座是用来给插头提供一个接口让其通电的,此时我们就可以将插座当做一个服务端,不同的插头当做客户端。
常用的 Socket 类型有两种,分别是流式 Socket(SOCK_STREAM)和数据报式 Socket(SOCK_DGRAM):
网络中的进程之间如何通过 Socket 通信呢?首要解决的问题是如何唯一标识一个进程,否则通信无从谈起!在本地可以通过进程 PID 来唯一标识一个进程,但是在网络中这是行不通的。
其实 TCP/IP 协议族已经帮我们解决了这个问题,网络层的“ip 地址”可以唯一标识网络中的主机,而传输层的“协议+端口”可以唯一标识主机中的应用程序(进程)。这样利用三元组(ip 地址,协议,端口)就可以标识网络的进程了,网络中需要互相通信的进程,就可以利用这个标志在他们之间进行交互。请看下面这个 TCP/IP 协议结构图:
使用 TCP/IP 协议的应用程序通常采用应用编程接口:UNIX BSD 的套接字(socket)和 UNIX System V 的 TLI(已经被淘汰),来实现网络进程之间的通信。
就目前而言,几乎所有的应用程序都是采用 socket,而现在又是网络时代,网络中进程通信是无处不在,这就是为什么说“一切皆 Socket”。
传统的网络应用设计模式,客户机(Client)/服务器(Server)模式,需要在通讯两端各自部署客户机和服务器来完成数据通信。
浏览器(Browser)/服务器(Server)模式,只需在一端部署服务器,而另外一端使用每台 PC 都默认配置的浏览器即可完成数据的传输。
对于 C/S 模式来说,其优点明显。客户端位于目标主机上可以保证性能,将数据缓存至客户端本地,从而提高数据传输效率。一般来说客户端和服务器程序由一个开发团队创作,所以他们之间所采用的协议相对灵活。
可以在标准协议的基础上根据需求裁剪及定制,例如腾讯所采用的通信协议,即为 ftp 协议的修改剪裁版。
因此,传统的网络应用程序及较大型的网络应用程序都首选 C/S 模式进行开发。例如知名的网络游戏魔兽世界,3D 画面,数据量庞大,使用 C/S 模式可以提前在本地进行大量数据的缓存处理,从而提高观感。
C/S 模式的缺点也较突出。由于客户端和服务器都需要有一个开发团队来完成开发。工作量将成倍提升,开发周期较长。另外,从用户角度出发,需要将客户端安插至用户主机上,对用户主机的安全性构成威胁。这也是很多用户不愿使用 C/S 模式应用程序的重要原因。
B/S 模式相比 C/S 模式而言,由于 B/S 模式没有独立的客户端,使用标准浏览器作为客户端,其工作开发量较小,只需开发服务器端即可。另外由于其采用浏览器显示数据,因此移植性非常好,不受平台限制。例如早期的偷菜游戏,在各个平台上都可以完美运行。
B/S 模式的缺点也较明显。由于使用第三方浏览器,因此网络应用支持受限;另外没有客户端放到对方主机上,缓存数据不尽如人意,从而传输数据量受到限制,应用的观感大打折扣;第三,必须与浏览器一样,采用标准 http 协议进行通信,协议选择不灵活。
因此在开发过程中,模式的选择由上述各自的特点决定,根据实际需求选择应用程序设计模式。
Go语言的 net 包中有一个 TCPConn 类型,可以用来建立 TCP 客户端和 TCP 服务器端间的通信通道,TCPConn 类型里有两个主要的函数:
TCPConn 可以用在客户端和服务器端来读写数据。
还有我们需要知道一个 TCPAddr 类型,它表示一个 TCP 的地址信息,其定义如下:
type TCPAddr struct {
IP IP
Port int
}
在Go语言中通过 ResolveTCPAddr 可以获取一个 TCPAddr 类型,ResolveTCPAddr 的函数定义如下:
参数说明如下:
我们可以通过 net 包来创建一个服务器端程序,在服务器端我们需要绑定服务到指定的非激活端口,并监听此端口,当有客户端请求到达的时候可以接收到来自客户端连接的请求。
net 包中有相应功能的函数,函数定义如下:
ListenTCP 函数会在本地 TCP 地址 laddr 上声明并返回一个 *TCPListener,net 参数必须是 "tcp"、"tcp4"、"tcp6",如果 laddr 的端口字段为 0,函数将选择一个当前可用的端口,可以用 Listener 的 Addr 方法获得该端口。
下面我们实现一个简单的时间同步服务:
package main
import (
"fmt"
"log"
"net"
"time"
)
func echo(conn *net.TCPConn) {
tick := time.Tick(5 * time.Second) // 五秒的心跳间隔
for now := range tick {
n, err := conn.Write([]byte(now.String()))
if err != nil {
log.Println(err)
conn.Close()
return
}
fmt.Printf("send %d bytes to %s\n", n, conn.RemoteAddr())
}
}
func main() {
address := net.TCPAddr{
IP: net.ParseIP("127.0.0.1"), // 把字符串IP地址转换为net.IP类型
Port: 8000,
}
listener, err := net.ListenTCP("tcp4", &address) // 创建TCP4服务器端监听器
if err != nil {
log.Fatal(err) // Println + os.Exit(1)
}
for {
conn, err := listener.AcceptTCP()
if err != nil {
log.Fatal(err) // 错误直接退出
}
fmt.Println("remote address:", conn.RemoteAddr())
go echo(conn)
}
}
上面的服务端程序运行起来之后,它将会一直在那里等待,直到有客户端请求到达。
Go语言可以通过 net 包中的 DialTCP 函数来建立一个 TCP 连接,并返回一个 TCPConn 类型的对象,当连接建立时服务器端也会同时创建一个同类型的对象,此时客户端和服务器段通过各自拥有的 TCPConn 对象来进行数据交换。
一般而言,客户端通过 TCPConn 对象将请求信息发送到服务器端,读取服务器端响应的信息;服务器端读取并解析来自客户端的请求,并返回应答信息。这个连接会在客户端或服务端任何一端关闭之后失效,不然这连接可以一直使用。
建立连接的函数定义如下:
参数说明如下:
接下来通过一个简单的例子,模拟一个基于 HTTP 协议的客户端请求去连接一个 Web 服务端,要写一个简单的 http 请求头,格式类似如下:
客户端代码如下所示:
package main
import (
"log"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
log.Fatalf("Usage: %s host:port", os.Args[0])
}
service := os.Args[1]
tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
if err != nil {
log.Fatal(err)
}
conn, err := net.DialTCP("tcp4", nil, tcpAddr)
if err != nil {
log.Fatal(err)
}
n, err := conn.Write([]byte("HEAD / HTTP/1.1\r\n\r\n"))
if err != nil {
log.Fatal(err)
}
log.Fatal(n)
}
在 CMD 窗口中运行前面的服务端程序,正如前面所说的,服务端程序只是占用当前的窗口并没有任何输出内容。
重新打开一个 CMD 窗口运行上面的客户端程序,运行结果如下所示:
TCP 有很多连接控制函数,我们平常用到比较多的有如下几个函数:
设置建立连接的超时时间,客户端和服务器端都适用,当超过设置时间时,连接自动关闭。
用来设置写入/读取一个连接的超时时间,当超过设置时间时,连接自动关闭。
设置客户端是否和服务器端保持长连接,可以降低建立 TCP 连接时的握手开销,对于一些需要频繁交换数据的应用场景比较适用。
Go语言包中处理 UDP Socket 和 TCP Socket 不同的地方就是在服务器端处理多个客户端请求数据包的方式不同,UDP 缺少了对客户端连接请求的 Accept 函数,其他基本几乎一模一样,只有 TCP 换成了 UDP 而已。
UDP 的几个主要函数如下所示:
一个 UDP 的客户端代码如下所示,我们可以看到不同的就是 TCP 换成了 UDP 而已:
package main
import (
"fmt"
"net"
"os"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
os.Exit(1)
}
service := os.Args[1]
udpAddr, err := net.ResolveUDPAddr("udp4", service)
checkError(err)
conn, err := net.DialUDP("udp", nil, udpAddr)
checkError(err)
_, err = conn.Write([]byte("anything"))
checkError(err)
var buf [512]byte
n, err := conn.Read(buf[0:])
checkError(err)
fmt.Println(string(buf[0:n]))
os.Exit(0)
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error ", err.Error())
os.Exit(1)
}
}
我们再来看一下 UDP 服务器端如何来处理:
package main
import (
"fmt"
"net"
"os"
"time"
)
func main() {
service := ":1200"
udpAddr, err := net.ResolveUDPAddr("udp4", service)
checkError(err)
conn, err := net.ListenUDP("udp", udpAddr)
checkError(err)
for {
handleClient(conn)
}
}
func handleClient(conn * net.UDPConn) {
var buf [512]byte
_, addr, err := conn.ReadFromUDP(buf[0:])
if err != nil {
return
}
daytime := time.Now().String()
conn.WriteToUDP([]byte(daytime), addr)
}
func checkError(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "Fatal error ", err.Error())
os.Exit(1)
}
}
运行结果如下:
通过对 TCP 和 UDP Socket 编程的描述和实现,可见Go语言已经完备地支持了 Socket 编程,而且使用起来相当的方便。Go语言提供了很多函数,通过这些函数可以很容易就编写出高性能的 Socket 应用。