golang socket编程

在大学的时候,曾经修过一门课《网络原理》,其中就花很大的篇幅讲过TCP/IP四层网络协议(OSI的七层网络协议可以映射到这个四层协议上来),也讲过HTTP协议、socket编程。但是讲的东西多了,总有一种云里雾里的感觉,而且也没能很好的了解其中的关联。这里就和大家一起梳理一下,上述几个概念之间的关系,并通过golang实现socket编程。
1. 基础概念 1.1 TCP/IP协议
TCP/IP协议(传输控制协议/互联网协议)不是简单的一个协议,而是一组特别的协议,包括:TCP,IP,UDP,ARP等,这些被称为子协议。在这些协议中,最重要、最著名的就是TCP和IP。因此,大部分网络管理员称整个协议族为“TCP/IP”。
上面就是百度百科给出的概念,我们需要了解的就是TCP/IP 协议栈是一系列网络协议的总和,是构成网络通信的核心骨架,它定义了电子设备如何连入因特网,以及数据如何在它们之间进行传输。通俗一点讲就是,一个主机的数据要经过哪些过程才能发送到对方的主机上。
所谓IP就是网络层IP协议,负责唯一标识网络中的主机,将数据分组从一台主机传送到另一台主机,并且这个传动过程并不是可靠的,会发生丢包、重复、失序等问题,只能说是“尽力而为”。
TCP就是指传输层TCP协议,保证两台主机进程之间的可靠通信。
1.2 HTTP协议
HTTP协议(超文本传输协议),通俗的讲就是一个定义了超文本(HTML)传输规则的一个协议,可以保证超文本的可靠传输,是TCP/IP四层模型的应用层协议。HTTP协议其实就是基于传输层TCP协议和网络层IP协议实现的一个协议,所以它可以保证超文本的可靠传输。
1.3 Socket
从我们之前学习的一些概念,可以知道TCP要保证可靠传输,要通过三次握手建立连接,传输数据时,要有滑动窗口、累积确认、分组缓存、流量控制等约束,断开连接时要通过四次挥手断开连接,是一个非常麻烦的协议。如果有个需求,使用TCP协议编程,设计一个客户端和服务端的通信系统,代价将是特别大的。这时候我们肯定想到一个概念——抽象。因为TCP协议非常复杂,不能要求每个程序员都去实现建立连接的三次握手、累计确认、分组缓存,这些应该属于操作系统内核部分,没必要重复开发。但是对于程序员来讲,操作系统需要抽象出一个概念,让上层应用可以使用抽象概念去编程,而这个抽象的概念就是Socket(Socket是操作系统抽象出来出我们更方便使用)。
从web开发者的角度而言,一切编程都是Socket,只不过因为我们日常开发都是基于应用层开发,所以掩盖了Socket的细节。考虑一些场景,我们每天打开浏览器浏览网页时,浏览器进程怎么和 Web 服务器进行通信的呢?使用QQ聊天时,QQ进程怎么和服务器或者是你的好友所在的 QQ 进程进行通信的呢?如此种种,底层都是靠 Socket来进行通信的。
常用的Socket类型有两种:流式Socket(SOCK_STREAM)和数据报式Socket(SOCK_DGRAM)。流式是一种面向连接的Socket,针对于面向连接的 TCP 服务应用;数据报式 Socket 是一种无连接的Socket,对应于无连接的UDP服务应用。
golang socket编程
文章图片

使用socket实现Tcp客户端和服务端交互的流程如下:
golang socket编程
文章图片

2. Socket编程 2.1 Socket编程简述
通过上面的基础概念,我们知道其实Socket就是操作系统抽象出来的,用于方便我们使用TCP/UDP协议进行网络中多个主机进程之间进行通讯的一种方式。既然要实现多个主机进程之间通信,肯定要能唯一标志每个进程。而Socket是通过IP:Port来唯一标志进程的。通俗的讲,每一个IP:Port组合都是一个Socket。比如常见的80、443端口,就是这里讲的用来进行Socket连接的port,也可讲是服务端对外提供服务的端口号。
有了抽象的Socket后,当要使用TCP(或UDP)协议进行web编程时,就可以通过如下方式进行:

  • client端伪代码:
clientfd = socket(……) connect(clientfd, serverIp:Port, ……) send(clientfd, data) receive(clientfd, ……) close(clientfd)

使用socket后,就不用理会TCP/UDP那些烦人的细节了,只剩下一些概念性的东西。比如connect方法,就是在和服务端进行三次握手建立连接。
  • server端伪代码:
listenfd = socket(……) bind(listenfd, ServerIp:Port, ……) listen(listenfd, ……) while(true) { conn = accept(listenfd, ……) receive(conn, ……) send(conn, ……) }

作为Socket服务端,要满足两个条件:
  1. 服务端是被动的,所以服务端启动之后,需要监听客户端发起的连接
  2. 服务端要应付很多客户端发起的连接,所以要把各个连接区分开来,不能混淆
上述伪代码中,listenfd就是为了实现服务端监听创建的Socket描述符,而bind方法就是服务端进程占用端口,避免其它端口被其它进程使用,listen方法开始对端口进行监听。下面的while循环用来处理客户端源源不断的请求,accept方法返回一个conn,其实就是用来区分各个客户端的连接的,之后的接受和发送动作都是基于这个conn来实现的。其实accept就是和客户端的connect一起完成了TCP的三次握手。
2.2 golang实现Socket编程
2.2.1 TCP Socket
这里我们通过TCP协议,实现一个功能,客户端向服务端发送一个字符串,服务端获取客户端发送的字符串后,并在字符串后添加一个当前的时间戳返回给客户端。这里我们使用golang中内置的net包来实现。
在Go语言的net包中有一个类型TCPConn,这个类型可以用来作为客户端和服务器端交互的通道,他有两个主要的函数:
func (c *TCPConn) Write(b []byte) (int, error) func (c *TCPConn) Read(b []byte) (int, error)

TcpConn可以用在客户端和服务器端来读写数据
还需要了解一个TCPAddr 类型,他表示一个TCP的地址信息,定义如下:
type TCPAddr struct { IP IP Port int Zone string // IPv6 scoped addressing zone }

在Go语言中通过ResolveTCPAddr可以获取一个TCPAddr,如下:
func ResolveTCPAddr(net, addr string) (*TCPAddr, os.Error)

  • net 参数是 “tcp4″、”tcp6″、”tcp” 中的任意一个,分别表示TCP (IPv4-only),TCP (IPv6-only) 或者TCP (IPv4, IPv6 的任意一个)
  • addr 表示域名或者 IP 地址,例如 “www.baidu.com:80” 或者 “127.0.0.1:7777”
2.1.1.1 client端
Go语言中通过net包中的DialTCP函数来建立一个TCP连接,并返回一个TCPConn类型的对象,当连接建立时服务器端也创建一个同类型的对象,此时客户端和服务器段通过各自拥有的TCPConn对象来进行数据交换。一般而言,客户端通过TCPConn对象将请求信息发送到服务器端,读取服务器端响应的信息。服务器端读取并解析来自客户端的请求,并返回应答信息,这个连接只有当任一端关闭了连接之后才失效,不然这连接可以一直在使用。建立连接的函数定义如下:
func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error)

  • net 参数是 “tcp4″、”tcp6″、”tcp” 中的任意一个,分别表示TCP (IPv4-only)、TCP (IPv6-only) 或者TCP (IPv4, IPv6 的任意一个)
  • laddr 表示本机地址,一般设置为 nil
  • raddr 表示远程的服务地址
接下来我们来实现功能,client端代码如下:
package mainimport ( "fmt" "io/ioutil" "net" "os" )func main() { if len(os.Args) != 3 { fmt.Fprintf(os.Stderr, "Usage: %s host:port ", os.Args[0]) os.Exit(1) } service := os.Args[1] tcpAddr, err := net.ResolveTCPAddr("tcp4", service) checkError(err) conn, err := net.DialTCP("tcp", nil, tcpAddr) checkError(err) _, err = conn.Write([]byte(os.Args[2])) checkError(err) result, err := ioutil.ReadAll(conn) checkError(err) fmt.Println(string(result)) }func checkError(err error) { if err != nil { fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error()) os.Exit(1) } }

  • Go语言中,os.Args可以用于获取程序运行时的参数,其中os.Args[0]默认是可执行文件地址,其他运行参数从os.Args[1]开始
  • 客户端程序将用户的第一个输入参数作为参数传入net.ResolveTCPAddr获取一个tcpAddr
  • tcpAddr传入DialTCP后创建了一个TCP连接conn
  • 通过conn来发送请求信息,最后通过ioutil.ReadAll从conn中读取全部的文本
2.1.1.2 server端
上面我们编写了一个TCP的客户端程序,也可以通过net包来创建一个服务器端程序,在服务器端我们需要绑定服务到指定的非激活端口,并监听此端口,当有客户端请求到达的时候可以接收到来自客户端连接的请求。net包中有相应功能的函数,函数定义如下:
func ListenTCP(network string, laddr *TCPAddr) (*TCPListener, error) func (l *TCPListener) Accept() (Conn, error)

上述参数跟客户端相同,下面我们来实现服务端功能:
package mainimport ( "fmt" "net" "os" "strconv" "time" )func main() { service := ":7777" tcpAddr, err := net.ResolveTCPAddr("tcp4", service) checkError1(err) listener, err := net.ListenTCP("tcp", tcpAddr) checkError1(err) for { conn, err := listener.Accept() if err != nil { continue } b := make([]byte, 1024) conn.Read(b) conn.Write([]byte(string(b) + ":" + strconv.FormatInt(time.Now().Unix(), 10))) conn.Close() } }func checkError1(err error) { if err != nil { fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error()) os.Exit(1) } }

上面的服务跑起来之后,它将会一直在那里等待,直到有新的客户端请求到达。当有新的客户端请求到达并同意接受Accept该请求的时候服务端也会创建一个TcpConn对象来与服务端进行交互,读取客户端请求内容,并在请求内容后面拼一个当前的时间戳。
上面的代码有个缺点,执行的时候是单任务的,不能同时接收多个请求,那么该如何改造以使它支持多并发呢?Go里面有一个goroutine机制,请看下面改造后的代码:
package mainimport ( "fmt" "net" "os" "strconv" "time" )func main() { service := ":7777" tcpAddr, err := net.ResolveTCPAddr("tcp4", service) checkError1(err) listener, err := net.ListenTCP("tcp", tcpAddr) checkError1(err) for { conn, err := listener.Accept() if err != nil { continue } go handleClient(conn) } }func handleClient(conn net.Conn) { defer conn.Close() b := make([]byte, 1024) conn.Read(b) conn.Write([]byte(string(b) + ":" + strconv.FormatInt(time.Now().Unix(), 10)))}func checkError1(err error) { if err != nil { fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error()) os.Exit(1) } }

2.1.1.3 运行结果
将服务端程序运行起来,执行客户端代码如下:
./tcp_socket_client localhost:7777 hello

运行结果:
hello:1562116434

2.1.1.4 实现TCP长连接
上述代码,在客户端请求一次,服务端处理之后,就把conn关闭了,如何来实现Tcp长连接?
package mainimport ( "fmt" "net" "os" "strconv" "time" )func main() { service := ":7777" tcpAddr, err := net.ResolveTCPAddr("tcp4", service) checkError1(err) listener, err := net.ListenTCP("tcp", tcpAddr) checkError1(err) for { conn, err := listener.Accept() if err != nil { continue }go handleClient(conn) } }func handleClient(conn net.Conn) { conn.SetReadDeadline(time.Now().Add(2 * time.Minute)) // set 2 minutes timeout request := make([]byte, 1024) defer conn.Close()// close connection before exit for { read_len, err := conn.Read(request)if err != nil { fmt.Println(err) break }if read_len == 0 { break // connection already closed by client }conn.Write([]byte(string(request) + ":" + strconv.FormatInt(time.Now().Unix(), 10)))request = make([]byte, 128) // clear last read content }}func checkError1(err error) { if err != nil { fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error()) os.Exit(1) } }

  • 使用conn.Read()不断读取客户端发来的请求
  • 由于需要保持与客户端的长连接,所以不能在读取完一次请求后就关闭连接
  • conn.SetReadDeadline()设置了超时,当一定时间内客户端无请求发送,conn 便会自动关闭,下面的 for 循环即会因为连接已关闭而跳出
  • request在创建时需要指定一个最大长度以防止flood attack
  • 每次读取到请求处理完毕后,需要清理request,因为conn.Read() 会将新读取到的内容append到原内容之后
【golang socket编程】2.1.1.2 控制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

设置keepAlive属性,是操作系统层在tcp上没有数据和ACK的时候,会间隔性的发送keepalive包,操作系统可以通过该包来判断一个tcp连接是否已经断开,在 windows上默认2个小时没有收到数据和keepalive包的时候人为tcp连接已经断开,这个功能和我们通常在应用层加的心跳包的功能类似。
2.2.2 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)

2.2.2.1 client端
package mainimport ( "fmt" "io/ioutil" "net" "os" )func main() { if len(os.Args) != 3 { 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(os.Args[2])) checkError(err) response, err :=ioutil.ReadAll(conn) fmt.Println(string(response)) os.Exit(0) } func checkError(err error) { if err != nil { fmt.Fprintf(os.Stderr, "Fatal error ", err.Error()) os.Exit(1) } }

2.2.2.2 server端
package mainimport ( "fmt" "net" "os" "strconv" "time" )func main() { service := ":8888" udpAddr, err := net.ResolveUDPAddr("udp4", service) checkError(err) conn, err := net.ListenUDP("udp", udpAddr) checkError(err) for { handleClient(conn) } }func handleClient(conn *net.UDPConn) { request := make([]byte, 1024) _, addr, err := conn.ReadFromUDP(request) if err != nil { return } conn.WriteToUDP([]byte(string(request) + ":" + strconv.FormatInt(time.Now().Unix(), 10)), addr) }func checkError(err error) { if err != nil { fmt.Fprintf(os.Stderr, "Fatal error ", err.Error()) os.Exit(1) } }

以上就是使用golang实现Socket编程的简单示例,比之前的文章 Netty是什么中介绍的使用Java的实现要简单。另外go提供的goroutine机制,跟Java的线程相比,更轻量(协程),所以处理效率上也会比Java高一些。
参考链接:
1. 《码农翻身——搞清楚socket》
2. 《Go Web编程》

    推荐阅读