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编程_第1张图片

使用socket实现Tcp客户端和服务端交互的流程如下:

golang socket编程_第2张图片

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 main

import (
	"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 main

import (
	"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 main

import (
	"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 main

import (
	"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到原内容之后

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 main

import (
	"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 main

import (
	"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编程》

你可能感兴趣的:(Go)