在大学的时候,曾经修过一门课《网络原理》,其中就花很大的篇幅讲过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服务应用。
使用socket实现Tcp客户端和服务端交互的流程如下:
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服务端,要满足两个条件:
- 服务端是被动的,所以服务端启动之后,需要监听客户端发起的连接
- 服务端要应付很多客户端发起的连接,所以要把各个连接区分开来,不能混淆
上述伪代码中,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编程》