首页 > 编程笔记 > Go语言笔记

Go语言Socket编程详解

大部分底层网络的编程都离不开 socket 编程,HTTP 编程、Web 开发、IM 通信、视频流传输的底层都是 socket 编程。

什么是 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 如何通信

网络中的进程之间如何通过 Socket 通信呢?首要解决的问题是如何唯一标识一个进程,否则通信无从谈起!在本地可以通过进程 PID 来唯一标识一个进程,但是在网络中这是行不通的。

其实 TCP/IP 协议族已经帮我们解决了这个问题,网络层的“ip 地址”可以唯一标识网络中的主机,而传输层的“协议+端口”可以唯一标识主机中的应用程序(进程)。这样利用三元组(ip 地址,协议,端口)就可以标识网络的进程了,网络中需要互相通信的进程,就可以利用这个标志在他们之间进行交互。请看下面这个 TCP/IP 协议结构图:

七层网络协议图
图:七层网络协议图

使用 TCP/IP 协议的应用程序通常采用应用编程接口:UNIX BSD 的套接字(socket)和 UNIX System V 的 TLI(已经被淘汰),来实现网络进程之间的通信。

就目前而言,几乎所有的应用程序都是采用 socket,而现在又是网络时代,网络中进程通信是无处不在,这就是为什么说“一切皆 Socket”。

网络应用的设计模式

1) C/S模式

传统的网络应用设计模式,客户机(Client)/服务器(Server)模式,需要在通讯两端各自部署客户机和服务器来完成数据通信。

C/S架构
图:C/S模式

2) B/S模式

浏览器(Browser)/服务器(Server)模式,只需在一端部署服务器,而另外一端使用每台 PC 都默认配置的浏览器即可完成数据的传输。

B/S 模式
图:B/S模式

优缺点

对于 C/S 模式来说,其优点明显。客户端位于目标主机上可以保证性能,将数据缓存至客户端本地,从而提高数据传输效率。一般来说客户端和服务器程序由一个开发团队创作,所以他们之间所采用的协议相对灵活。

可以在标准协议的基础上根据需求裁剪及定制,例如腾讯所采用的通信协议,即为 ftp 协议的修改剪裁版。

因此,传统的网络应用程序及较大型的网络应用程序都首选 C/S 模式进行开发。例如知名的网络游戏魔兽世界,3D 画面,数据量庞大,使用 C/S 模式可以提前在本地进行大量数据的缓存处理,从而提高观感。

C/S 模式的缺点也较突出。由于客户端和服务器都需要有一个开发团队来完成开发。工作量将成倍提升,开发周期较长。另外,从用户角度出发,需要将客户端安插至用户主机上,对用户主机的安全性构成威胁。这也是很多用户不愿使用 C/S 模式应用程序的重要原因。

B/S 模式相比 C/S 模式而言,由于 B/S 模式没有独立的客户端,使用标准浏览器作为客户端,其工作开发量较小,只需开发服务器端即可。另外由于其采用浏览器显示数据,因此移植性非常好,不受平台限制。例如早期的偷菜游戏,在各个平台上都可以完美运行。

B/S 模式的缺点也较明显。由于使用第三方浏览器,因此网络应用支持受限;另外没有客户端放到对方主机上,缓存数据不尽如人意,从而传输数据量受到限制,应用的观感大打折扣;第三,必须与浏览器一样,采用标准 http 协议进行通信,协议选择不灵活。

因此在开发过程中,模式的选择由上述各自的特点决定,根据实际需求选择应用程序设计模式。

TCP Socket

Go语言的 net 包中有一个 TCPConn 类型,可以用来建立 TCP 客户端和 TCP 服务器端间的通信通道,TCPConn 类型里有两个主要的函数:

func (c *TCPConn) Write(b []byte) (n int, err os.Error)
func (c *TCPConn) Read(b []byte) (n int, err os.Error)

TCPConn 可以用在客户端和服务器端来读写数据。

还有我们需要知道一个 TCPAddr 类型,它表示一个 TCP 的地址信息,其定义如下:
type TCPAddr struct {
    IP IP
    Port int
}
在Go语言中通过 ResolveTCPAddr 可以获取一个 TCPAddr 类型,ResolveTCPAddr 的函数定义如下:

func ResolveTCPAddr(net, addr string) (*TCPAddr, os.Error)

参数说明如下:

TCP server

我们可以通过 net 包来创建一个服务器端程序,在服务器端我们需要绑定服务到指定的非激活端口,并监听此端口,当有客户端请求到达的时候可以接收到来自客户端连接的请求。

net 包中有相应功能的函数,函数定义如下:

func ListenTCP(net string, laddr *TCPAddr) (l *TCPListener, err os.Error)
func (l *TCPListener) Accept() (c Conn, err os.Error)

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)
    }
}
上面的服务端程序运行起来之后,它将会一直在那里等待,直到有客户端请求到达。

TCP client

Go语言可以通过 net 包中的 DialTCP 函数来建立一个 TCP 连接,并返回一个 TCPConn 类型的对象,当连接建立时服务器端也会同时创建一个同类型的对象,此时客户端和服务器段通过各自拥有的 TCPConn 对象来进行数据交换。

一般而言,客户端通过 TCPConn 对象将请求信息发送到服务器端,读取服务器端响应的信息;服务器端读取并解析来自客户端的请求,并返回应答信息。这个连接会在客户端或服务端任何一端关闭之后失效,不然这连接可以一直使用。

建立连接的函数定义如下:

func DialTCP(net string, laddr, raddr *TCPAddr) (c *TCPConn, err os.Error)

参数说明如下:

接下来通过一个简单的例子,模拟一个基于 HTTP 协议的客户端请求去连接一个 Web 服务端,要写一个简单的 http 请求头,格式类似如下:

"HEAD / HTTP/1.0\r\n\r\n"

客户端代码如下所示:
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 窗口中运行前面的服务端程序,正如前面所说的,服务端程序只是占用当前的窗口并没有任何输出内容。

go run main.go

重新打开一个 CMD 窗口运行上面的客户端程序,运行结果如下所示:

go run client.go 127.0.0.1:8000
2019/12/31 10:49:10 19
exit status 1

控制 TCP 连接

TCP 有很多连接控制函数,我们平常用到比较多的有如下几个函数:

func DialTimeout(net, addr string, timeout time.Duration) (Conn, error)

设置建立连接的超时时间,客户端和服务器端都适用,当超过设置时间时,连接自动关闭。

func (c *TCPConn) SetReadDeadline(t time.Time) error
func (c *TCPConn) SetWriteDeadline(t time.Time) error

用来设置写入/读取一个连接的超时时间,当超过设置时间时,连接自动关闭。

func (c *TCPConn) SetKeepAlive(keepalive bool) os.Error

设置客户端是否和服务器端保持长连接,可以降低建立 TCP 连接时的握手开销,对于一些需要频繁交换数据的应用场景比较适用。

UDP Socket

Go语言包中处理 UDP Socket 和 TCP Socket 不同的地方就是在服务器端处理多个客户端请求数据包的方式不同,UDP 缺少了对客户端连接请求的 Accept 函数,其他基本几乎一模一样,只有 TCP 换成了 UDP 而已。

UDP 的几个主要函数如下所示:

func ResolveUDPAddr(net, addr string) (*UDPAddr, os.Error)
func DialUDP(net string, laddr, raddr *UDPAddr) (c *UDPConn, err os.Error)
func ListenUDP(net string, laddr *UDPAddr) (c *UDPConn, err os.Error)
func (c *UDPConn) ReadFromUDP(b []byte) (n int, addr *UDPAddr, err os.Error
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (n int, err os.Error)

一个 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)
    }
}
运行结果如下:

go run client.go 127.0.0.1:1200
2019-12-31 10:59:32.4184669 +0800 CST m=+13.865767901

总结

通过对 TCP 和 UDP Socket 编程的描述和实现,可见Go语言已经完备地支持了 Socket 编程,而且使用起来相当的方便。Go语言提供了很多函数,通过这些函数可以很容易就编写出高性能的 Socket 应用。

所有教程

优秀文章