什么是 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,针对于面向连接的 TCP 服务应用;
- 数据报式 Socket 是一种无连接的 Socket,对应于无连接的 UDP 服务应用。
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模式
2) B/S模式
浏览器(Browser)/服务器(Server)模式,只需在一端部署服务器,而另外一端使用每台 PC 都默认配置的浏览器即可完成数据的传输。
图: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)
参数说明如下:
- net 参数是 "tcp4"、"tcp6"、"tcp" 中的任意一个,分别表示 TCP(IPv4-only),TCP(IPv6-only) 或者 TCP(IPv4,IPv6) 中的任意一个;
- addr 表示域名或者 IP 地址,例如 "c.biancheng.net:80" 或者 "127.0.0.1:22"。
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)
参数说明如下:
- net 参数是 "tcp4"、"tcp6"、"tcp" 中的任意一个,分别表示 TCP(IPv4-only)、TCP(IPv6-only) 或者 TCP(IPv4,IPv6) 的任意一个;
- laddr 表示本机地址,一般设置为 nil;
- raddr 表示远程的服务地址。
接下来通过一个简单的例子,模拟一个基于 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 应用。
原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/23561.html