天天看点

十三、Go网络编程

网络编程:使用编程语言实现多台计算机的通信。

13.1、网络三要素

网络编程三要素:

(1)IP地址:网络中每一台计算机的唯一标识,通过IP地址找到指定的计算机。
(2)端口:用于标识进程的逻辑地址,通过端口找到指定进程。
(3)协议:定义通信规则,符合协议则可以通信,不符合不能通信。一般有TCP协议和UDP协议。
           

(1)IP地址

计算机分布在世界各地,要想和它们通信,必须要知道确切的位置。确定计算机位置的方式有多种,IP 地址是最常用的,例如,114.114.114.114 是国内第一个、全球第三个开放的 DNS 服务地址,127.0.0.1 是本机地址。

其实,我们的计算机并不知道 IP 地址对应的地理位置,当要通信时,只是将 IP 地址封装到要发送的数据包中,交给路由器去处理。路由器有非常智能和高效的算法,很快就会找到目标计算机,并将数据包传递给它,完成一次单向通信。

目前大部分软件使用 IPv4 地址,但 IPv6 也正在被人们接受,尤其是在教育网中,已经大量使用。

(2)端口

有了 IP 地址,虽然可以找到目标计算机,但仍然不能进行通信。一台计算机可以同时提供多种网络服务,例如Web服务、FTP服务(文件传输服务)、SMTP服务(邮箱服务)等,仅有 IP 地址,计算机虽然可以正确接收到数据包,但是却不知道要将数据包交给哪个网络程序来处理,所以通信失败。

为了区分不同的网络程序,计算机会为每个网络程序分配一个独一无二的端口号(Port Number),例如,Web服务的端口号是 80,FTP 服务的端口号是 21,SMTP 服务的端口号是 25。

端口(Port)是一个虚拟的、逻辑上的概念。可以将端口理解为一道门,数据通过这道门流入流出,每道门有不同的编号,就是端口号。如下图所示:

十三、Go网络编程

(3)协议

协议(Protocol)就是网络通信的约定,通信的双方必须都遵守才能正常收发数据。协议有很多种,例如 TCP、UDP、IP 等,通信的双方必须使用同一协议才能通信。协议是一种规范,由计算机组织制定,规定了很多细节,例如,如何建立连接,如何相互识别等。

协议仅仅是一种规范,必须由计算机软件来实现。例如 IP 协议规定了如何找到目标计算机,那么各个开发商在开发自己的软件时就必须遵守该协议,不能另起炉灶。

所谓协议族(Protocol Family),就是一组协议(多个协议)的统称。最常用的是 TCP/IP 协议族,它包含了 TCP、IP、UDP、Telnet、FTP、SMTP 等上百个互为关联的协议,由于 TCP、IP 是两种常用的底层协议,所以把它们统称为 TCP/IP 协议族。

(4)数据传输方式

计算机之间有很多数据传输方式,各有优缺点,常用的有两种:SOCK_STREAM 和 SOCK_DGRAM。

  • SOCK_STREAM 表示面向连接的数据传输方式。数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送,但效率相对较慢。常见的 http 协议就使用 SOCK_STREAM 传输数据,因为要确保数据的正确性,否则网页不能正常解析。
  • SOCK_DGRAM 表示无连接的数据传输方式。计算机只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。因为 SOCK_DGRAM 所做的校验工作少,所以效率比 SOCK_STREAM 高。

QQ 视频聊天和语音聊天就使用 SOCK_DGRAM 传输数据,因为首先要保证通信的效率,尽量减小延迟,而数据的正确性是次要的,即使丢失很小的一部分数据,视频和音频也可以正常解析,最多出现噪点或杂音,不会对通信质量有实质的影响。

注意:SOCK_DGRAM 没有想象中的糟糕,不会频繁的丢失数据,数据错误只是小概率事件。

有可能多种协议使用同一种数据传输方式,所以在 socket 编程中,需要同时指明数据传输方式和协议。

综上所述:IP地址和端口能够在广袤的互联网中定位到要通信的程序,协议和数据传输方式规定了如何传输数据,有了这些,两台计算机就可以通信了。

13.2、TCP协议

(1)OSI模型

如果你读过计算机专业,或者学习过网络通信,那你一定听说过 OSI 模型,它曾无数次让你头大。OSI 是 Open System Interconnection 的缩写,译为“开放式系统互联”。 OSI 模型把网络通信的工作分为 7 层,从下到上分别是物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。

这个网络模型究竟是干什么呢?简而言之就是进行数据封装的。

当另一台计算机接收到数据包时,会从网络接口层再一层一层往上传输,每传输一层就拆开一层包装,直到最后的应用层,就得到了最原始的数据,这才是程序要使用的数据。

十三、Go网络编程

(2)TCP报文格式

TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的通信协议,数据在传输前要建立连接,传输完毕后还要断开连接。

客户端在收发数据前要使用 connect() 函数和服务器建立连接。建立连接的目的是保证IP地址、端口、物理链路等正确无误,为数据的传输开辟通道。

TCP建立连接时要传输三个数据包,俗称三次握手(Three-way Handshaking)。可以形象的比喻为下面的对话:

[Shake 1] 套接字A:“你好,套接字B,我这里有数据要传送给你,建立连接吧。”

[Shake 2] 套接字B:“好的,我这边已准备就绪。”

[Shake 3] 套接字A:“谢谢你受理我的请求。”

十三、Go网络编程

序号:Seq(Sequence Number)序号占32位,用来标识从计算机A发送到计算机B的数据包的序号,计算机发送数据时对此进行标记。

确认号:Ack(Acknowledge Number)确认号占32位,客户端和服务器端都可以发送,Ack = Seq + 1。

标志位:每个标志位占用1Bit,共有6个,分别为 URG、ACK、PSH、RST、SYN、FIN,具体含义如下:

// URG:紧急指针(urgent pointer)有效。

// ACK:确认序号有效。

// PSH:接收方应该尽快将这个报文交给应用层。

// RST:重置连接。

// SYN:建立一个新连接。

// FIN:断开一个连接。

(3)TCP/IP三次握手

使用 connect() 建立连接时,客户端和服务器端会相互发送三个数据包,请看下图:

十三、Go网络编程

客户端调用 socket() 创建套接字后,因为没有建立连接,所以套接字处于CLOSED状态;服务器端调用 listen() 函数后,套接字进入LISTEN状态,开始监听客户端请求。这个时候,客户端开始发起请求:

当客户端调用 connect() 函数后,TCP协议会组建一个数据包,并设置 SYN 标志位,表示该数据包是用来建立同步连接的。同时生成一个随机数字 1000,填充“序号(Seq)”字段,表示该数据包的序号。完成这些工作,开始向服务器端发送数据包,客户端就进入了SYN-SEND状态。

服务器端收到数据包,检测到已经设置了 SYN 标志位,就知道这是客户端发来的建立连接的“请求包”。服务器端也会组建一个数据包,并设置 SYN 和 ACK 标志位,SYN 表示该数据包用来建立连接,ACK 用来确认收到了刚才客户端发送的数据包。 服务器生成一个随机数 2000,填充“序号(Seq)”字段。2000 和客户端数据包没有关系。服务器将客户端数据包序号(1000)加1,得到1001,并用这个数字填充“确认号(Ack)”字段。服务器将数据包发出,进入SYN-RECV状态。

客户端收到数据包,检测到已经设置了 SYN 和 ACK 标志位,就知道这是服务器发来的“确认包”。客户端会检测“确认号(Ack)”字段,看它的值是否为 1000+1,如果是就说明连接建立成功。接下来,客户端会继续组建数据包,并设置 ACK 标志位,表示客户端正确接收了服务器发来的“确认包”。同时,将刚才服务器发来的数据包序号(2000)加1,得到 2001,并用这个数字来填充“确认(Ack)”字段。客户端将数据包发出,进入ESTABLISED状态,表示连接已经成功建立。

服务器端收到数据包,检测到已经设置了 ACK 标志位,就知道这是客户端发来的“确认包”。服务器会检测“确认号(Ack)”字段,看它的值是否为 2000+1,如果是就说明连接建立成功,服务器进入ESTABLISED状态。至此,客户端和服务器都进入了ESTABLISED状态,连接建立成功,接下来就可以收发数据了。

注意:三次握手的关键是要确认对方收到了自己的数据包,这个目标就是通过“确认号(Ack)”字段实现的。计算机会记录下自己发送的数据包序号 Seq,待收到对方的数据包后,检测“确认号(Ack)”字段,看Ack = Seq + 1是否成立,如果成立说明对方正确收到了自己的数据包

(4)TCP/IP四次挥手

建立连接非常重要,它是数据正确传输的前提;断开连接同样重要,它让计算机释放不再使用的资源。如果连接不能正常断开,不仅会造成数据传输错误,还会导致套接字不能关闭,持续占用资源,如果并发量高,服务器压力堪忧。

建立连接需要三次握手,断开连接需要四次握手,可以形象的比喻为下面的对话:

[Shake 1] 套接字A:“任务处理完毕,我希望断开连接。”

[Shake 2] 套接字B:“哦,是吗?请稍等,我准备一下。”

等待片刻后……

[Shake 3] 套接字B:“我准备好了,可以断开连接了。”

[Shake 4] 套接字A:“好的,谢谢合作。”

下图演示了客户端主动断开连接的场景:

十三、Go网络编程

建立连接后,客户端和服务器都处于ESTABLISED状态。这时,客户端发起断开连接的请求:

客户端调用 close() 函数后,向服务器发送 FIN 数据包,进入FIN_WAIT_1状态。FIN 是 Finish 的缩写,表示完成任务需要断开连接。

服务器收到数据包后,检测到设置了 FIN 标志位,知道要断开连接,于是向客户端发送“确认包”,进入CLOSE_WAIT状态。注意:服务器收到请求后并不是立即断开连接,而是先向客户端发送“确认包”,告诉它我知道了,我需要准备一下才能断开连接。

客户端收到“确认包”后进入FIN_WAIT_2状态,等待服务器准备完毕后再次发送数据包。

等待片刻后,服务器准备完毕,可以断开连接,于是再主动向客户端发送 FIN 包,告诉它我准备好了,断开连接吧。然后进入LAST_ACK状态。

客户端收到服务器的 FIN 包后,再向服务器发送 ACK 包,告诉它你断开连接吧。然后进入TIME_WAIT状态。

服务器收到客户端的 ACK 包后,就断开连接,关闭套接字,进入CLOSED状态。

​ 注意:关于 TIME_WAIT 状态的说明

客户端最后一次发送 ACK包后进入 TIME_WAIT 状态,而不是直接进入 CLOSED 状态关闭连接,这是为什么呢?

/*
TCP 是面向连接的传输方式,必须保证数据能够正确到达目标机器,不能丢失或出错,而网络是不稳定的,随时可能会毁坏数据,所以机器A每次向机器B发送数据包后,都要求机器B”确认“,回传ACK包,告诉机器A我收到了,这样机器A才能知道数据传送成功了。
如果机器B没有回传ACK包,机器A会重新发送,直到机器B回传ACK包。

客户端最后一次向服务器回传ACK包时,有可能会因为网络问题导致服务器收不到,服务器会再次发送 FIN 包,如果这时客户端完全关闭了连接,那么服务器无论如何也收不到ACK包了,所以客户端需要等待片刻、确认对方收到ACK包后才能进入CLOSED状态。
那么,要等待多久呢?数据包在网络中是有生存时间的,超过这个时间还未到达目标主机就会被丢弃,并通知源主机。

这称为报文最大生存时间(MSL,Maximum Segment Lifetime)。

TIME_WAIT 要等待 2MSL 才会进入 CLOSED 状态。ACK 包到达服务器需要 MSL 时间,服务器重传 FIN 包也需要 MSL 时间,2MSL 是数据包往返的最大时间,如果 2MSL 后还未收到服务器重传的 FIN 包,就说明服务器已经收到了 ACK 包。
*/
           

13.3、socket介绍

13.3.1、什么是 socket?

socket 的原意是“插座”,在计算机通信领域,socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。通过 socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。 我们把插头插到插座上就能从电网获得电力供应,同样,为了与远程计算机进行数据传输,需要连接到因特网,而 socket 就是用来连接到因特网的工具。

socket是什么?

socket

13.3.2、socket缓冲区与阻塞

1、socket缓冲区

每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。

TCP协议独立于 write()/send() 函数,数据有可能刚被写入缓冲区就发送到网络,也可能在缓冲区中不断积压,多次写入的数据被一次性发送到网络,这取决于当时的网络情况、当前线程是否空闲等诸多因素,不由程序员控制。read()/recv() 函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取。

十三、Go网络编程

这些I/O缓冲区特性可整理如下:

I/O缓冲区在每个TCP套接字中单独存在;

I/O缓冲区在创建套接字时自动生成;

即使关闭套接字也会继续传送输出缓冲区中遗留的数据;

关闭套接字将丢失输入缓冲区中的数据。

输入输出缓冲区的默认大小一般都是 8K!

2、阻塞模式

对于TCP套接字(默认情况下),当使用send() 发送数据时:

(1) 首先会检查缓冲区,如果缓冲区的可用空间长度小于要发送的数据,那么 send() 会被阻塞(暂停执行),直到缓冲区中的数据被发 送到目标机器,腾出足够的空间,才唤醒 send() 函数继续写入数据。

(2) 如果TCP协议正在向网络发送数据,那么输出缓冲区会被锁定,不允许写入,send() 也会被阻塞,直到数据发送完毕缓冲区解锁, send() 才会被唤醒。

(3) 如果要写入的数据大于缓冲区的最大长度,那么将分批写入。

(4) 直到所有数据被写入缓冲区 send() 才能返回。

当使用recv() 读取数据时:

(1) 首先会检查缓冲区,如果缓冲区中有数据,那么就读取,否则函数会被阻塞,直到网络上有数据到来。

(2) 如果要读取的数据长度小于缓冲区中的数据长度,那么就不能一次性将缓冲区中的所有数据读出,剩余数据将不断积压,直到有 recv() 函数再次读取。

(3) 直到读取到数据后 recv() 函数才会返回,否则就一直被阻塞。

TCP套接字默认情况下是阻塞模式,也是最常用的。当然你也可以更改为非阻塞模式,后续我们会讲解。

13.3.3、TCP的粘包问题

上节我们讲到了socket缓冲区和数据的传递过程,可以看到数据的接收和发送是无关的,read()/recv() 函数不管数据发送了多少次,都会尽可能多的接收数据。也就是说,read()/recv() 和 write()/send() 的执行次数可能不同。

例如,write()/send() 重复执行三次,每次都发送字符串"abc",那么目标机器上的 read()/recv() 可能分三次接收,每次都接收"abc";也可能分两次接收,第一次接收"abcab",第二次接收"cabc";也可能一次就接收到字符串"abcabcabc"。

这就是数据的“粘包”问题,客户端发送的多个数据包被当做一个数据包接收。也称数据的无边界性,read()/recv() 函数不知道数据包的开始或结束标志(实际上也没有任何开始或结束标志),只把它们当做连续的数据流来处理。

13.4、基于Go的socket代码实现

13.4.1、 聊天案例

服务端:

package main

import (
    "fmt"
    "net"
    "strings"
)

func main() {
    // 1.创建TCP服务端监听
    listenner, err := net.Listen("tcp", "0.0.0.0:8888")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer listenner.Close()
    // 2.服务端不断等待请求处理
    for {
        // 阻塞等待客户端连接
        conn, err := listenner.Accept()
        if err != nil {
            fmt.Println(err)
            continue
        }
        go ClientConn(conn)
    }
}

// 处理服务端逻辑
func ClientConn(conn net.Conn) {
    defer conn.Close()
    // 获取客户端地址
    ipAddr := conn.RemoteAddr().String()
    fmt.Println(ipAddr, "连接成功")
    // 缓冲区
    buf := make([]byte, 1024)
    for {
        // n是读取的长度
        // time.Sleep(time.Second*10)  // 粘包
        n, err := conn.Read(buf)
        if err != nil {
            fmt.Println(err)
            return
        }
        // 切出有效数据
        result := buf[:n]
        fmt.Printf("接收到数据,来自[%s]    [%d]:%s\n", ipAddr, n, string(result))
        // 接收到exit,退出连接
        if string(result) == "exit" {
            fmt.Println(ipAddr, "退出连接")
            return
        }
        // 回复客户端
        conn.Write([]byte(strings.ToUpper(string(result))))
    }
}
           

聊天案例客户端:

package main

import (
    "fmt"
    "net"
)

func main() {
    // 1.连接服务端
    conn, err := net.Dial("tcp", "127.0.0.1:8888")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer conn.Close()
    // 缓冲区

    for {
        buf := make([]byte, 1024)
        fmt.Printf("请输入发送的内容:")
        fmt.Scan(&buf)
        fmt.Printf("发送的内容:%s\n", string(buf))
        // 发送数据
        conn.Write(buf)
        //conn.Write(buf)   // 粘包
        //conn.Write(buf)   // 粘包
        // 接收服务端返回信息
        res := make([]byte, 1024)
        n, err := conn.Read(res)
        if err != nil {
            fmt.Println(err)
            return
        }
        result := res[:n]
        fmt.Printf("接收到数据:%s\n", string(result))
    }
}
           

13.4.2、 ssh案例

服务端:

package main

import (
    "fmt"
    "github.com/axgle/mahonia"
    "net"
    "os/exec"
)

func main() {
    // 1.创建TCP服务端监听
    listenner, err := net.Listen("tcp", "0.0.0.0:8888")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer listenner.Close()

    for true {
        // 阻塞等待客户端连接
        conn, err := listenner.Accept()
        if err != nil {
            fmt.Println(err)
            return
        }
        go ClientConn(conn)
    }

}

// 处理服务端逻辑
func ClientConn(conn net.Conn) {
    defer conn.Close()
    // 获取客户端地址
    ipAddr := conn.RemoteAddr().String()
    fmt.Println(ipAddr, "连接成功")
    for true {
        // 缓冲区
        data := make([]byte, 1024)
        // n是读取的长度
        n, err := conn.Read(data)
        fmt.Println("命令字节数",n)
        if err != nil {
            fmt.Println(err)
            return
        }
        // 切出有效数据
        data = data[:n]
        fmt.Printf("接收到命令,来自[%s]    [%d]:%s\n", ipAddr, n, string(data))
        // 接收到exit,退出连接
        if string(data) == "exit" {
            fmt.Println(ipAddr, "退出连接")
            return
        }
        // 回复客户端
        cmd := exec.Command("cmd","/C",string(data))
        // 执行命令,并返回结果
        output,err := cmd.Output()
        if err != nil {
            panic(err)
        }

        fmt.Println("命令结果字节数:",len(output))
        dec := mahonia.NewDecoder("gbk")
        _, cdata, _ := dec.Translate(output, true)
        result := string(cdata)
        fmt.Println(result)
        conn.Write([]byte(string(result)))
    }

}
           

客户端:

package main

import (
    "bufio"
    "fmt"
    "net"
    "os"
    "strings"
)

func main() {
    // 1.连接服务端
    conn, err := net.Dial("tcp", "127.0.0.1:8888")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer conn.Close()
    // 缓冲区
    for true {

        reader := bufio.NewReader(os.Stdin) // 从标准输入生成读对象
        fmt.Println("输入执行命令>>>")
        text, _ := reader.ReadString('\n') // 读到换行
        text = strings.TrimSpace(text)

        fmt.Println("text",text)
        // 发送数据
        conn.Write([]byte(text))
        // 接收服务端返回信息
        res := make([]byte, 100000)  // 如何解决大文件传输问题呢?看下面的文件上传案例
        n, err := conn.Read(res)
        if err != nil {
            fmt.Println("err:",err)
            return
        }
        fmt.Println("n",n)
        result := res[:n]
        fmt.Printf("接收到数据:%s\n", string(result))
    }

}
           
package main

import (
    "bufio"
    "fmt"
    "net"
    "os"
    "strconv"
    "strings"
)

func main() {
    // 1.创建TCP服务端监听
    listenner, err := net.Listen("tcp", "0.0.0.0:8888")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer listenner.Close()

    for true {
        // 阻塞等待客户端连接
        conn, err := listenner.Accept()
        if err != nil {
            fmt.Println(err)
            return
        }
        go ClientConn(conn)
    }

}

// 处理服务端逻辑
func ClientConn(conn net.Conn) {
    defer conn.Close()
    // 获取客户端地址
    ipAddr := conn.RemoteAddr().String()
    fmt.Println(ipAddr, "连接成功")
    for true {
        // 缓冲区
        infoByte := make([]byte, 1024)
        // n是读取的长度
        n, err := conn.Read(infoByte)
        if err != nil {
            fmt.Println(err)
            return
        }
        //
        nameAndSize := strings.Split(string(infoByte[:n])," ")
        fileSize, err := strconv.Atoi(nameAndSize[1])
        fileName := nameAndSize[0]
        // 读数据和写文件
        file, err := os.OpenFile(fileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0666)
        writer := bufio.NewWriter(file)

        // 循环接收数据
        var readSize = 0
        fmt.Println(readSize, fileSize)
        for readSize < fileSize {
            data := make([]byte, 1024)
            // n是读取的长度
            n, _ := conn.Read(data)
            fmt.Println("n", n)
            _, _ = writer.WriteString(string(data[:n]))
            readSize += len(data)
        }
        _ = writer.Flush()
        fmt.Println("上传成功!")

    }

}

           
package main

import (
    "bufio"
    "fmt"
    "io"
    "net"
    "os"
    "strconv"
    "strings"
)

func main() {
    // 1.连接服务端
    conn, err := net.Dial("tcp", "127.0.0.1:8888")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer conn.Close()
    // 缓冲区
    for true {

        reader := bufio.NewReader(os.Stdin) // 从标准输入生成读对象
        fmt.Println("输入执行命令>>>")
        text, _ := reader.ReadString('\n') // 读到换行
        text = strings.TrimSpace(text)

        path := strings.Split(text," ")[1]

        //打开文件
        file, _ := os.Open(path)
        // 获取文件大小
        reader = bufio.NewReader(file)
        f, _ := os.Stat(path)

        // 发送文件大小
        fsize := f.Size()
        // 获取文件名称
        fname := f.Name()

        strInt64 := strconv.FormatInt(fsize, 10)
        _, _ = conn.Write([]byte(fname+" "+strInt64))

        // 发送文件数据
        for {
            // (1) 按行都字符串
            bytes, err := reader.ReadBytes('\n') // 读取到换行符为止,读取内容包括换行符
            // 发送数据
            _, _ = conn.Write(bytes)
            if err == io.EOF { //io.EOF 读取到了文件的末尾
                // fmt.Println("读取到文件末尾!")
                break
            }
        }

        fmt.Println("上传成功")

    }

}