网络与部署课后实践作业 | 青训营

134 阅读7分钟

服务端构建

启动UDP监听服务

在本地IP127.0.0.18000端口提供UDP监听服务,判断错误,并设置关闭函数。

server.go
复制代码
// 在 127.0.0.1:8000 提供UDP服务
socket, err := net.ListenUDP("udp", &net.UDPAddr{
    IP:   net.IPv4(127, 0, 0, 1),
    Port: 8000,
})
if err != nil {
    fmt.Println("UDP服务端启动失败!")
    os.Exit(1)
}
defer socket.Close()

收发过程变量声明

data作为byte数组,负责接收从客户端传来的数据。msg作为string字符串,负责作为待会发送给客户端ACK确认号。

acknumber是一个map,从int类型的客户端地址端口号映射到int类型的每个客户端记录的ACK号。这样做的原因是因为服务端一般情况下要接受多个客户端的请求,为了让多个客户端之间的ACK号不至于“串台”,要用map的方式针对每个客户端维护自己的ACK序列(到哪里了)。特别地,这里只需要使用int类型的端口号作为map的key,是因为在课程作业的情况下,服务端和客户端是运行在同一台机器上的,使用的都是本机IP127.0.0.1,地址的不同仅在于端口号不同。

另外有一点一开始没有想到的地方就是,关于acknumber的声明最初用的是var acknumber map[int]int的形式,然后后续下面的逻辑为每个新来的客户端建立首个ACK号的时候会发生向空map写入数据的错误,改成大括号初始化之后就没有问题了。存在但为空不存在之间亦有差异。

server.go
复制代码
var data [1024]byte // 接收客户端传过来的数据
var msg string      // 要发给客户端的信息
// 每个客户端的ACK确认号要根据端口号分开记录
acknumber := map[int]int{}

收发主体

我们的服务端要持续运行UDP监听服务,将主体逻辑写在无限循环中。

在循环中首先要做的是从客户端读取已经向目标监听IP上面发送过来的信息。ReadFromUDP()方法接受一个[]byte类型的切片,将监听的数据写入这个切片中,返回写入大小、听到的客户端地址和错误信息三个值。

随后进行简单的错误处理。

server.go
复制代码
// 无限循环启动服务端主体
for {
    // 从客户端接收信息
    n, addr, err := socket.ReadFromUDP(data[:])
    if err != nil {
       fmt.Println("从客户端接收信息失败!")
       return
    }

在无限循环的后半段,逻辑被封装在一个匿名函数中,并在运行时作为一个goroutine脱离server主体自运行。

独立成goroutine的原因是:作为服务端要考虑多客户端并发请求服务的影响。如果把监听到数据后,后面的逻辑全部按照最简单的模式写在for循环中,则可能导致服务端听到了客户端1发来的信息,正在处理要对客户端1回发的ACK信息时,被动忽略此时客户端2发来的信息丢失问题。

虽然我们在客户端设计了重传机制,但是如此高概率的重传场景显然是不符合我们本意的。因此将逻辑封装在goroutine中,可以让服务端监听到信息后直接启动独立的goroutine,然后快速进行下一次循环,继续监听UDP服务。这样可以最大化减少多客户端请求并发情况下的响应丢失问题。当然一开始还想过把整个for循环都套到goroutine中,实现多个服务程序的真并发运行,后来由于没法确定客户端信息到来时是哪个服务端监听就放弃了这个可能会导致重复响应的写法。

接下来开始介绍具体逻辑:首先是按照上面设置变量部分所说的,完成map对于每个新到来的客户端地址key的初始化操作(如果有必要)。随后在控制台打印输出接收到的信息(将[]byte数据string化),忽略最后的回车。

接着是回发ACK的部分,将针对本IP应该回发的ACK确认号拼接到msg字符串中。调用WriteToUDP方法向对应的客户端回发[]byte化的数据。addr地址的设置是有必要的,因为一个服务端要同时响应多个客户端。

最后将对应客户端地址的ACK号++,对应后续可能存在的数据传输。

server.go
复制代码
// 把响应主体放到goroutine中,提高并发效率
    go func() {
       // 设置每个客户端地址的ACK号,从0开始
       ack, ok := acknumber[addr.Port]
       if !ok {
          ack = 0
          acknumber[addr.Port] = 0
       }

       fmt.Println("从地址为", addr, "的客户端处收到信息:", string(data[:n-1])) // 最后的下标-1是为了去除'\n'

       // 向发送信息来的客户端回发ACK
       fmt.Println("向客户端回发确认序列号ACK:", ack)
       msg = "ACK: " + strconv.Itoa(ack)
       fmt.Println(msg)
       socket.WriteToUDP([]byte(msg), addr) // 一个服务端可能对应多个客户端,所以需要有前面接收的addr确定来源

       // ACK递增
       acknumber[addr.Port]++
    }()
    // 一次收发空一行,保持格式方便确认
    fmt.Println()
}

客户端构建

启动UDP沟通服务

使用DialUDP方法绑定客户端要沟通的地址。方法接受第二个参数为nil,意为保持默认(客户端运行时自动选择本地IP的可用端口)。第三个参数为远程IP,保持和服务端设置内容相同。

client.go
复制代码
// 向远程地址 127.0.0.1:8000 建立请求,本地IP为nil(默认)
socket, err := net.DialUDP("udp", nil, &net.UDPAddr{
    IP:   net.IPv4(127, 0, 0, 1),
    Port: 8000,
})
if err != nil {
    fmt.Println("UDP客户端启动失败!")
    os.Exit(1)
}
defer socket.Close()

收发过程变量声明

data与msg的含义与服务端中相同,不再赘述。

reader用于手动输入客户端要传递到客户端的信息。

acknumber由于客户端本体的地址是固定的,只需要一个简单的int变量记录即可。用于收发过程中模拟预期ACK号。

client.go
复制代码
var data [1024]byte                 // 接收服务端传过来的数据
var msg string                      // 要发给服务端的信息
reader := bufio.NewReader(os.Stdin) // 手动读入标准输入
acknumber := 0                      // 客户端acknumber代表预期的ACK号

收发主体

for循环中,首先读入要发送的消息,进行错误判断处理。

client.go
复制代码
// 无限循环启动客户端主体
for {
    // 向服务端发送信息
    fmt.Printf("向服务端发送消息:")
    msg, err = reader.ReadString('\n')
    if err != nil {
       fmt.Println("客户端信息读入失败!")
       return
    }

随后是for循环的主体部分,我们先不管和超时重传有关的代码。

在第4行,可以直接写入[]byte化的msg信息,这是由于服务端和客户端是一对多的关系,每个客户端只会对应一个服务端提供响应。而且这个客户端所监听服务的地址已经在最开始的部分写好了,所以只需要调用最简单的Write()方法就可以达到目的,不用和服务端一样通过多余的地址信息确定信息去向。

第11行则同理,采用最简单的Read()方法接收从服务端回发的ACK号。

在收到ACK确认号之后,将acknumber++,作为下一次的预期接收ACK号。

client.go
复制代码
    resend_time := 0 // 目前一共重传了几次

resend: // 重传label
    socket.Write([]byte(msg)) // 因为服务端地址仅有一个且在前面已经写定了,所以直接写就可以,不用传入地址

    // 接收服务端ACK
    fmt.Println("预期接收确认序列号ACK为:", acknumber)
    // 设置超时时间,这里为2s
    socket.SetReadDeadline(time.Now().Add(time.Second * 2))

    n, err := socket.Read(data[:]) // 和上面的Write一样,也不用传地址
    if err != nil {
       fmt.Println("从客户端接收确认失败!")
       // 错误信息的格式是"read udp 127.0.0.1:53920->127.0.0.1:8000: i/o timeout"
       // 所以可以往前数7个字符,看是不是"timeout"来确认是不是超时错误
       if err.Error()[len(err.Error())-7:] == "timeout" {
          // 限制重传次数最多为5次
          if resend_time <= 4 {
             fmt.Println("开始重传…… 第", resend_time+1, "次……")
             resend_time++
             goto resend // 用了goto……因为这样重传机制最好写……
             // 鶸没想出来更好的写法,如果有更好的写法欢迎指出
          } else {
             fmt.Println("重传次数过多,传不过去了,润!")
             goto giveup
          }
       }
       return
    }
    fmt.Println("收到确认序列号:", string(data[:n]))

    // ACK递增
    acknumber++
giveup:
    // 一次收发空一行,保持格式方便确认
    fmt.Println()
}

接下来是和超时重传部分有关的代码:在Read()之前,我们通过SetReadDeadline()方法,设置Read()的超时时间为2s(实际逻辑是超时时刻设置为现在时刻起的2s后)。如果不进行设置,Read()就会一直阻塞等待服务端传来消息,而不会进行重传操作。

在设置了SetReadDeadline()以及相关参数之后,Read()在等待2s后仍然读取不到数据就会结束,然后回发错误信息。

我们对这个错误信息进行处理,取err.Error()的最后几位字符,判断如果为timeout就判断为超时错误,通过goto方法到resend标志出进行下一次向服务端发送信息,并等待读取服务端信息。

如此逻辑一致循环,通过resend_time变量记录循环次数(重传次数),限制最大次数为5次,如果达到最大次数仍然没有接收到服务端传来的ACK号,就说明存在某些严重的问题,放弃重传,跳转到giveup标志,预备进行下一次信息传输。

如果第一次就发送并接收ACK成功,或者在某一次重传后接收ACK成功,那么这次信息传送就顺利结束。执行普通逻辑里面的acknumber++,就可以进入下一次循环,预备传送新的信息了。

功能演示

这里我们开启三个终端来分别模拟服务端客户端1客户端2的运行情况。

正常运行

启动服务端、客户端1、客户端2,分别从客户端1和客户端2向服务端发送两句信息,观察正常运行时三端的控制台输出情况如下:

可以看到端口号为54171的客户端1和端口号为54172的客户端2,两者的ACK号在服务端是独立的。

1.png 2.png

3.png

超时重传

因为本机上实际上很难出现真正意义上的超时现象,我采用下面的方式进行模拟:

  1. 开启客户端,输入消息进行传输
  2. 待客户端出现“正在重传”提示时,立即开启服务端
  3. 观察两端控制台信息

可以看到在第3次重传的时候传输成功,而且不影响后续的传输进程。

4.png

微信图片_20230826000309.png

重传失败

同上面的步骤,重传失败的选择从头到尾一直不开启服务端来模拟:

可以看到,重传失败之后,再开启服务端,不影响后续传输成功。

微信图片_20230826000327.png

微信图片_20230826000248.png

其他问题

  1. 什么时候客户端认为是丢包?

通过SetReadDeadline()方法,将客户端读取服务端回发的ACK号时,从一直阻塞等待变为了等待固定的一段时间。在这段时间过后仍然没读到ACK号就认为是丢包,并放弃读取,启动重传程序。

  1. 重传怎么考虑效率?
  • 使用自适应增长的重传时间,减少非必要的重传次数
  • 使用快速重传,收到重复ACK时立即重传,不必等待超时时间
  • 局部重传,只重传必要的丢失的包,降低开销
  1. 能不能不阻塞只传丢掉的中间的段?

可以,但是UDP面向无连接服务,不像TCP一样有连接服务保证严格的检测机制,对多个包中间有部分包体丢失的话,需要由程序本身自己去定义并实现丢包重传的过程。可能需要对多个包体分别记录ACK等类似机制,然后实现选择性重传丢失的包。

小结

本笔记通过简单的UDP socket编程,实现了基础的感知ACK并进行丢包重传的功能。完成了网络与部署课的第一项课后作业。

完整代码

服务端

server.go
复制代码
package main

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

func main() {
    // 在 127.0.0.1:8000 提供UDP服务
    socket, err := net.ListenUDP("udp", &net.UDPAddr{
       IP:   net.IPv4(127, 0, 0, 1),
       Port: 8000,
    })
    if err != nil {
       fmt.Println("UDP服务端启动失败!")
       os.Exit(1)
    }
    defer socket.Close()

    var data [1024]byte // 接收客户端传过来的数据
    var msg string      // 要发给客户端的信息
    // 每个客户端的ACK确认号要根据端口号分开记录
    acknumber := map[int]int{}

    // 无限循环启动服务端主体
    for {
       // 从客户端接收信息
       n, addr, err := socket.ReadFromUDP(data[:])
       if err != nil {
          fmt.Println("从客户端接收信息失败!")
          return
       }

       // 把响应主体放到goroutine中,提高并发效率
       go func() {
          // 设置每个客户端地址的ACK号,从0开始
          ack, ok := acknumber[addr.Port]
          if !ok {
             ack = 0
             acknumber[addr.Port] = 0
          }

          fmt.Println("从地址为", addr, "的客户端处收到信息:", string(data[:n-1])) // 最后的下标-1是为了去除'\n'

          // 向发送信息来的客户端回发ACK
          fmt.Println("向客户端回发确认序列号ACK:", ack)
          msg = "ACK: " + strconv.Itoa(ack)
          fmt.Println(msg)
          socket.WriteToUDP([]byte(msg), addr) // 一个服务端可能对应多个客户端,所以需要有前面接收的addr确定来源

          // ACK递增
          acknumber[addr.Port]++
       }()
       // 一次收发空一行,保持格式方便确认
       fmt.Println()
    }
}

客户端

client.go
复制代码
package main

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

func main() {
    // 向远程地址 127.0.0.1:8000 建立请求,本地IP为nil(默认)
    socket, err := net.DialUDP("udp", nil, &net.UDPAddr{
       IP:   net.IPv4(127, 0, 0, 1),
       Port: 8000,
    })
    if err != nil {
       fmt.Println("UDP客户端启动失败!")
       os.Exit(1)
    }
    defer socket.Close()

    var data [1024]byte                 // 接收服务端传过来的数据
    var msg string                      // 要发给服务端的信息
    reader := bufio.NewReader(os.Stdin) // 手动读入标准输入
    acknumber := 0                      // 客户端acknumber代表预期的ACK号

    // 无限循环启动客户端主体
    for {
       // 向服务端发送信息
       fmt.Printf("向服务端发送消息:")
       msg, err = reader.ReadString('\n')
       if err != nil {
          fmt.Println("客户端信息读入失败!")
          return
       }

       resend_time := 0 // 目前一共重传了几次

    resend: // 重传label
       socket.Write([]byte(msg)) // 因为服务端地址仅有一个且在前面已经写定了,所以直接写就可以,不用传入地址

       // 接收服务端ACK
       fmt.Println("预期接收确认序列号ACK为:", acknumber)
       // 设置超时时间,这里为2s
       socket.SetReadDeadline(time.Now().Add(time.Second * 2))

       n, err := socket.Read(data[:]) // 和上面的Write一样,也不用传地址
       if err != nil {
          fmt.Println("从客户端接收确认失败!")
          // 错误信息的格式是"read udp 127.0.0.1:53920->127.0.0.1:8000: i/o timeout"
          // 所以可以往前数7个字符,看是不是"timeout"来确认是不是超时错误
          if err.Error()[len(err.Error())-7:] == "timeout" {
             // 限制重传次数最多为5次
             if resend_time <= 4 {
                fmt.Println("开始重传…… 第", resend_time+1, "次……")
                resend_time++
                goto resend // 用了goto……因为这样重传机制最好写……
                // 鶸没想出来更好的写法,如果有更好的写法欢迎指出
             } else {
                fmt.Println("重传次数过多,传不过去了,润!")
                goto giveup
             }
          }
          return
       }
       fmt.Println("收到确认序列号:", string(data[:n]))

       // ACK递增
       acknumber++
    giveup:
       // 一次收发空一行,保持格式方便确认
       fmt.Println()
    }
}