面试官:你能实现一个 TCP 之外的可靠数据传输协议么?(基于 UDP 实现一个可靠的传输协议)

186 阅读36分钟

春天来了,又到了万物复苏程序员跑路的时节,各种操作系统,语言基础,网络知识,设计模式等,不知道各位大佬都准备的怎么样了~

兄弟前一阵儿面试时,就被问到了一道网络相关的题目:

“TCP 是可靠数据传输协议,
但是中间三次握手等过程太浪费时间,
你能自己实现一个可靠的传输协议么?”

当时一顿乱扯,扯完以后面试官蒙了,我自己也晕了。那么到底应该怎么样自己实现一个可靠的数据传输协议呢?这两天静下心来重新复习了一下《计算机网络-自顶向下方法》这本书,对于上面那个问题多少又有了些新的感悟:

所谓协议,就是约定好的事情,只要你能自己设计出两边都遵循的约定,
并且是一个可靠的约定,然后中间再有个东西能帮你把消息送过去,
那就相当于自己实现了一个可靠的传输协议。

这事儿吧,说起来挺简单,但是如果真的自己动手做的话还挺费劲,幸亏自顶向下那本书里头讲得还挺详细的,今儿就跟大家手动写一个可靠的传输协议,淦!


本次我们会从 0 一点点开始写,使用的语言是 nodejs,底层会使用 UDP 协议作为基本的传输层进行两端通信,我们将会一点点地把不可靠的通信变成一个可靠的 rdt 协议。


首先我们先创建一个最简单的发送端和服务端:

const dgram = require('dgram'); // dgram 模块提供了对 udp socket 的封装
const SEND_INTERVAL = 1000; // 每隔 1 秒向服务端发送一次消息
const SERVER_PORT = 13190; // 服务端的端口号时 13190
const SERVER_ADDRESS = '127.0.0.1'; // 服务端的地址就是本机
const CLIENT_PORT = 19411; // 当前这个客户端的端口号是 19411, 其实客户端也可以绑定 port, socket 也会自动绑定

// 首先定义一个客户端的 FSM
class ClientFiniteStateMachine {
  // actions 中定义所有的操作
  ACTIONS = {
    RDT_SEND: 'rdt_send', // rdt_send 表示发送一条消息
  };
  constructor({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT }) {
    if (SEND_INTERVAL && SERVER_PORT && SERVER_ADDRESS && CLIENT_PORT) {
      //创建一个监听某个端口的 udp server
      this.udp_client = dgram.createSocket('udp4');
      // 初始化一些参数
      this.SEND_INTERVAL = SEND_INTERVAL;
      this.SERVER_PORT = SERVER_PORT;
      this.SERVER_ADDRESS = SERVER_ADDRESS;
      this.CLIENT_PORT = CLIENT_PORT;
      this.init();
    }
  }

  init = () => {
    // 初始化一些 socket 自带的事件
    this.init_bind_port();
    this.init_on_message();
    this.init_on_close();
    this.init_on_error();
  }
  
  //  该方法作为暴露给上层的接口进行调用
  send_message = (msg) => this.dispatch('rdt_send', msg)
  
  // 接收消息 当服务端返回了消息的时候就会触发 message 事件
  init_on_message = () => this.udp_client.on('message', (msg, { port, address }) => {
    console.log(`udp 客户端接收到了来自 ${address}:${port} 的消息: ${String(msg)}`);
  });

  // dispatch 中定义所有的操作对应的动作
  dispatch = (action, msg) => {
    switch(action) {
      case this.ACTIONS.RDT_SEND:
        // 创建一个分组(或者说数据报)
        const packet = this.make_pkt(msg);
        // 发送该分组(或者说数据报)
        this.udt_send(packet);
      default: return;
    }
  }

  // 这里只是j简单d额进行一下 json 序列化
  make_pkt = (msg) => (JSON.stringify({ data: msg }));
  
  // 发送分组(或者说数据报)的方法
  udt_send = (pkt) => {
    // 有中文的话最好使用 Buffer 缓冲区 否则下面 send 方法的第三个参数的 length 不好判断
    const _buffer = Buffer.from(pkt);
    // 第二参数 0 表示要发送的信息在 _buffer 中的偏移量
    this.udp_client.send(_buffer, 0, _buffer.byteLength, this.SERVER_PORT, this.SERVER_ADDRESS);
  }

  // 绑定某个端口
  init_bind_port = () => this.udp_client.bind(this.CLIENT_PORT);

  // 当客户端关闭
  init_on_close = () => this.udp_client.on('close', () => console.log('udp 客户端关闭'));

  // 错误处理
  init_on_error = () => this.udp_client.on('error', (err) => console.log(`upd 服务发生错误: ${err}`));

}

// 初始化一个 UDP 客户端的状态机
const CFSM = new ClientFiniteStateMachine({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT });
// 每隔多少秒定时给客户端的 UDP 状态机派发一个发送消息的动作
setInterval(((index) => () => CFSM.send_message(`数字: ${index++}`))(0), SEND_INTERVAL);

下图为自顶向下方法中,rdt1.0 协议的状态机表示图(rdt 的意思就是可靠传输),“rdt_send” 这个 action 可以触发 “make_pkt” 以及 “udt_send” 这两个行为,上面的代码大致实现的就是这几个操作。

其实上面的代码中的类,严格来说并不能算是状态机,因为“等待来自上层的调用”这种状态属于异步的状态,并没办法在代码中有比较好的体现,因此通过在 “message” 事件中进行一些“操作(比如 rdt_send 等 )”,并在 “dispatch” 中触发一些“行为(比如 make_pkt 等)”来匹配下面的图片。

接下来是 UDP 的服务端:

const dgram = require('dgram'); // dgram 模块提供了对 udp socket 的封装
const SERVER_PORT = 13190; // 设置服务端的 port 为 13190, 要和客户端的保持一致

class ServerFiniteStateMachine {
  ACTIONS = {
    RDT_RECEIVE: 'rdt_rcv', // 该动作表示要处理接收到的 packet
  };

  constructor({ SERVER_PORT }) {
    if (SERVER_PORT) {
      this.udp_server = dgram.createSocket('udp4'); // 初始化一个 udp 的服务端
      this.SERVER_PORT = SERVER_PORT;
    }
  }

  // 该方法暴露给外部, 当初始化该 class 之后调用
  receive_message = () => this.init();

  init = () => {
    // 初始化监听事件
    this.init_bind_port();
    this.init_on_message();
    this.init_on_listening();
    this.init_on_error();
  }

  // 接收消息
  init_on_message = () => this.udp_server.on('message', (pkt, { port, address }) => {
    console.log(`${SERVER_PORT} 端口的 udp 服务接收到了来自 ${address}:${port} 的消息: ${pkt}`);
    // 在服务端的 UDP 服务中派发 接收到请求 这个动作
    this.dispatch('rdt_rcv', { packet: pkt, port, address });
  });

  dispatch = (action, { packet, port, address }) => {
    switch(action) {
      case this.ACTIONS.RDT_RECEIVE: // 该 action 表示要处理下层发送过来的消息
        // 处理 packet 得到 data
        const data = this.extract(packet);
        // 把 data 往上层应用层送
        this.deliver_data(data, { port, address });
      default: return;
    }
  }

  extract = (packet) => (JSON.parse(packet));

  deliver_data = (data, { port, address }) => {
    // 在这里进行 data 的处理, 可以推给上面的应用层或做一些其他的事情
    const _buffer = Buffer.from(`接收成功: ${JSON.stringify(data)}`);
    // 这里简单的返回一些信息给客户端 也可以不要这步 或者换成其他任意操作
    this.udp_server.send(_buffer, 0, _buffer.byteLength, port, address);
  }
  
  // 绑定端口
  init_bind_port = () => this.udp_server.bind(this.SERVER_PORT);

  // 监听端口
  init_on_listening = () => this.udp_server.on('listening', () => console.log(`upd 服务正在监听 ${SERVER_PORT} 端口`));

  // 错误处理
  init_on_error = () => this.udp_server.on('error', (err) => {
    console.log(`upd 服务发生错误: ${err}`);
    this.udp_server.close();
  });

}

const SFSM = new ServerFiniteStateMachine({ SERVER_PORT });
// 当开始调用 receive_message 表示开始监听端口并准备接受来自客户端的消息
SFSM.receive_message();

下图为 rdt1.0 协议中的服务端状态机:来自下层的消息触发 “rdt_rcv” 这个操作,该操作对调用 “extract” 这个行为对 packet 进行处理成 data,然后将 data 交付给 “deliver_data” 这个行为,该行为中可以由当前平台或软件环境自由定义,比如浏览器环境的话,这个 data 得到的可能就是个 http 协议,可以在这步操作中将得到的 http 协议的 data 送给应用层进行更上层的处理。

现在我们来运行一下我们的 rdt1.0 协议,看看效果:

一切看上去都 OK......

ただし!但是!现在是在正常的网络情况下,如果我们的网络状况十分不好或是发送到大洋彼岸呢?

现在我们就来模拟一下弱网并且容易丢包的情况:

sudo dnctl pipe 1 config bw 100Kbit/s delay 100 plr 0.5

首先我们在终端中使用如上命令来创建一条弱网的管道,上面的命令的意思是,创建一条 pipe 并且 id 为 1,带宽限制为 100Kbit/s,延迟 100ms,丢包率 50%。当然啦,光创建这么一条 pipe 没啥用,我们需要将协议引流到这条 pipe 中,那怎么做呢:

# udp
dummynet in proto udp from any to any pipe 1   
dummynet out proto udp from any to any pipe 1

首先我们创建一个配置文件 pf.conf,在任意目录下都可。pf 是 Mac 系统的防火墙工具,可以将流量导入到我们创建的 pipe 中。

之后我们依次执行:

sudo pfctl -e # 开启 pf 服务
sudo pfctl -f pf.conf # 加载自定义的防火墙规则

好了现在我们把 udp 的流量都导入到了创建的弱网并且丢包的 pipe 中了,我们现在可以重新跑一下客户端和服务端了:

可以看到,服务端接收到的并不是从 0 开始了,并且客户端也没有再接收到回信,说明中间丢的包还挺多。现在我们先使用下面的命令将防火墙的配置还原回去,要不其他的 udp 也都废了:

sudo pfctl -f /etc/pf.conf

另外如果想清除刚刚创建的那条弱网环境的 pipe 的话可以使用如下命令:

sudo dnctl -q flush

以上就是我们当前的 rdt 可靠传输协议在弱网可能会产生丢包的情况下的表现,不过其实还有另外一种场景,那就是数据在发送过程中产生了差错的场景,比如某个比特从 0 变成 1 之类的。但是由于发生比特错误的情况不是很好模拟,这玩意儿发生在物理层了,我们控制不了,所以暂时咱们先不测试该场景,只不过大家应该都明白,如果真的发生了差错,当前的协议肯定是处理不了的。

到此为止,我们第一版 rdt 可靠传输协议就算完成了,但是它目前就是个辣鸡,因为它既不能处理丢包,也不能处理数据出错的情况,接下来我们就要在 rdt1.0 的版本之上,一点点地解决这些问题了。


rdt2.0 走起~在 2.0 中,我们先来做个约定,我们先暂时约定或者说假设底层的物理层不会发生丢包这种情况,也就是说,在 2.0 中,我主要要先解决的问题是传输过程中,发生了比特差错的情况,至于丢包的情况,我们一会儿再在后面处理。

不过首先我们需要先想办法模拟一下数据发生错误的情况,那应该怎么模拟呢?我们都知道 checksum 校验和是一种常见的检测差错的机制,无论是数据库或是网络传输中都用到了这种技术,我们可以通过伪造 checksum 来简单模拟数据出错的情况:

class ClientFiniteStateMachine {
  // 省略上面的一些方法......
  dispatch = (action, msg) => {
    switch(action) {
      case this.ACTIONS.RDT_SEND:
        // 这里构建 packet 时添加一个 checksum      
        const packet = this.make_pkt(this.get_checksum(), msg);
        this.udt_send(packet);
        break;
      // 省略一些下面的代码......
    }
  }
  // 省略一些其他代码......
  // 创建 packet 时要把 checksum 也放进去
  make_pkt = (checksum, msg) => (JSON.stringify({ data: msg, checksum }));
  // 生成一个假的随机的校验和
  get_checksum = () => {
    // 由于当前不好模拟真正网络请求中校验和出错的场景 所以这里设置一个假的开关
    const random_error_switch = Math.random() >= 0.5;
    // 该开关为 0 时候表示校验和出现差错, 为 1 时表示校验和没有出现差错
    const checksum = random_error_switch ? 0 : 1;
    console.log(`本次分组随机生成的校验和是: ${checksum}`);
    return checksum;
  }
}

我们在客户端创建 packet 的时候,构建了一个随机生成的假的checksum,该checksum会被送到服务端,在服务端判断如果该checksum是 0 就说明发生了错误,是 1 则表示没有发生错误。

有了 mock 场景的方法,我们接下来可以分析一下应该怎么处理这种会发生差错的场景了。

想象一下我们打电话时的场景:

A:“喂,大哥,你在干啥呢?”

B:“我这陪你嫂子呢” A:“仨嫂子里的谁啊?”(说错话了)

B 女朋友(一把抢过电话):“啥?你说他有仨嫂子?”(向我确认)

A:“对啊,仨!”(又说了一次)

......

像这种打电话时,如果对方对说的话感知不清晰的话,就会重新向另外一方确认一次,这种口述报文使用了肯定确认以及否定确认,此时接收到肯定确认或否定确认的人会再回复一次,这种行为可以被称为 重传。我们就可以使用这种方式来解决上面的数据出现差错的情况。首先我们先来分析一下使用差错检测(checksum),以及肯定确认(ACK)否定确认(NAK) 的 FSM,注意我们可以称肯定确认为 ACK 应答,否定确认为 NAK 应答:

发送端

首先上层应用触发了 “rdt_send” 操作,该操作会触发 “make_pkt” 这个行为以创建一个要发送的 packet,以及 “ust_send” 这个行为来发送该 packet。和 rdt1.0 协议中不同的是,在 make_pkt 的时候需要构建出一个 checksum,这个 checksum 就会随着 packet 一起被送到服务端,然后服务端将会通过这个 checksum 来判断当前的 packet 是否出现了差错。

下面我们来基于 rdt1.0 修改发送端的代码:

const dgram = require('dgram'); // dgram 模块提供了对 udp socket 的封装
const SEND_INTERVAL = 1000; // 每隔 1 秒向服务端发送一次消息
const SERVER_PORT = 13190; // 服务端的端口号时 13190
const SERVER_ADDRESS = '127.0.0.1'; // 服务端的地址就是本机
const CLIENT_PORT = 19411; // 当前这个客户端的端口号是 19411, 其实客户端也可以绑定 port, socket 也会自动绑定

class ClientFiniteStateMachine {
  ACTIONS = {
    RDT_SEND: 'rdt_send', // rdt_send 表示发送一条消息
    IS_ACK: 'is_ack', // 服务端可能会回送 肯定应答
    IS_NAK: 'is_nak', // 服务端可能会回送 否定应答
  };

  // 用一个变量来保存上一次发送的 msg
  prev_msg = null;

  constructor({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT }) {
    if (SEND_INTERVAL && SERVER_PORT && SERVER_ADDRESS && CLIENT_PORT) {
      //创建一个监听某个端口的 udp server
      this.udp_client = dgram.createSocket('udp4');
      this.SEND_INTERVAL = SEND_INTERVAL;
      this.SERVER_PORT = SERVER_PORT;
      this.SERVER_ADDRESS = SERVER_ADDRESS;
      this.CLIENT_PORT = CLIENT_PORT;
      this.init();
    }
  }

  init = () => {
    this.init_bind_port();
    this.init_on_message();
    this.init_on_close();
    this.init_on_error();
  }  

  //  该方法作为暴露给上层的接口进行调用
  send_message = (msg) => this.dispatch('rdt_send', msg)

  // 接收消息
  init_on_message = () => this.udp_client.on('message', (msg, { port, address }) => {
    console.log(`udp 客户端接收到了来自 ${address}:${port} 的消息`);
    // 服务端将会在相应中使用 flag 来标识是 ACK 还是 NAK
    const { flag } = JSON.parse(msg);
    if (flag === 'ACK') {
      console.log(`客户端发送消息后接收到了 ACK 应答, 该分组发送成功`);
      this.dispatch('is_ack');
    } else if (flag === 'NAK') {
      console.log(`客户端发送消息后接收到了 NAK 应答, 该分组发送失败, 将会重新发送`);
      this.dispatch('is_nak');
    }
  });

  dispatch = (action, msg) => {
    switch(action) {
      case this.ACTIONS.RDT_SEND:
        // 先把本次要发送的 msg 作为上一次的 msg 记录下来 方便之后拿到否定应答的话重传
        this.prev_msg = msg;
        // 这里构建 packet 时需要多加上一个 checksum
        const packet = this.make_pkt(this.get_checksum(), msg);
        this.udt_send(packet);
        break;
      case this.ACTIONS.IS_NAK:
        // 如果是 NAK 应答的话说明 UDP 的服务端认为数据报或分组发生了错误 此时需要重传
        this.dispatch('rdt_send', this.prev_msg);
        break;
      case this.ACTIONS.IS_ACK:
        // 如果是 ACK 应答的话就可以什么都不做或是继续发送下一个分组了
        console.log('可以做一些别的事情了比如发送下一个分组之类的或者断开 socket');
        this.udp_client.close();
        break;
      default: return;
    }
  }

  // 将 checksum 也构建进去
  make_pkt = (checksum, msg) => (JSON.stringify({ data: msg, checksum }));

  udt_send = (pkt) => {
    // 有中文的话最好使用 Buffer 缓冲区 否则下面 send 方法的第三个参数的 length 不好判断
    const _buffer = Buffer.from(pkt);
    // 第二参数 0 表示要发送的信息在 _buffer 中的偏移量
    this.udp_client.send(_buffer, 0, _buffer.byteLength, this.SERVER_PORT, this.SERVER_ADDRESS);
  }

  // 绑定某个端口
  init_bind_port = () => this.udp_client.bind(this.CLIENT_PORT);

  // 当客户端关闭
  init_on_close = () => this.udp_client.on('close', () => console.log('udp 客户端关闭'));

  // 错误处理
  init_on_error = () => this.udp_client.on('error', (err) => console.log(`upd 服务发生错误: ${err}`));

  // 生成一个假的随机的校验和
  get_checksum = () => {
    // 由于当前不好模拟真正网络请求中校验和出错的场景 所以这里设置一个假的开关
    const random_error_switch = Math.random() >= 0.5;
    // 该开关为 0 时候表示校验和出现差错, 为 1 时表示校验和没有出现差错
    const checksum = random_error_switch ? 0 : 1;
    console.log(`本次分组随机生成的校验和是: ${checksum}`);
    return checksum;
  }

}

// 初始化一个 UDP 客户端的状态机
const CFSM = new ClientFiniteStateMachine({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT });
// 这里先只进行一次发送 因为 CFSM 目前的实现中还没有办法按照顺序处理多个发生了错误的分组 暂时只能处理一个发生错误的分组
setTimeout(() => CFSM.send_message('ding test'), SEND_INTERVAL);

注意此时先暂时不用 setInterval 模拟上层发送数据,因为此时 CFSM 中还无法按照顺序处理多个发生了错误的分组,稍后会进行修改。

rdt2.0 的客户端和 rdt1.0 的最大差别在于多了两种 action,一个是 is_ack,一个是 is_nak,同时在 dispatch 中也多了对这两种 action 的处理。对于 ack 这种应答,我们不需要做其他事情,而对于 nak 这种应答,我们则需要将上一次的 msg 进行重新发送。

接下来我们来看看 rdt2.0 的服务端 FSM:

在 2.0 的服务端中,当接收到来自下层消息(rdt_rcv)的时候,并且发生了错误(corrupt)的时候,需要触发“make_pkt(NAK)”这个行为以构建一个 NAK 应答报文,之后要触发“udt_send”这个行为将刚刚构建的 NAK 报文发送回客户端。

而当接收到下层的消息(rdt_rcv)的时候,并且 checksum 没有发生错(notcorrupt)的时候,需要触发“extract”这个行为来对下层传来的数据做一些提取处理,之后触发“deliver_data”这个行为,该行为和 1.0 中一样,可以由当前环境任意处理,比如将 extract 出来的 data 发往上层等等,之后服务端还需要进行“make_pkt(ACK)”这个行为,构建出一个 ACK 报文,最后通过“udt_send”将 ACK 报文返回给客户端。

我们来看看此时 rdt2.0 的服务端代码:

// dgram 模块提供了对 udp socket 的封装
const dgram = require('dgram');
const SERVER_PORT = 13190;

class ServerFiniteStateMachine {
  ACTIONS = {
    RDT_RECEIVE: 'rdt_rcv', // 该动作表示要处理接收到的 packet
    NOT_CORRUPT: 'not_corrupt', // 该动作在校验和没出错的情况下触发
    CORRUPT: 'corrupt', // 该动作在校验和出错的情况下触发
  };

  constructor({ SERVER_PORT }) {
    if (SERVER_PORT) {
      this.udp_server = dgram.createSocket('udp4');
      this.SERVER_PORT = SERVER_PORT;
    }
  }

  // 该方法暴露给外部, 当初始化该 class 之后调用
  receive_message = () => this.init();

  init = () => {
    this.init_bind_port();
    this.init_on_message();
    this.init_on_listening();
    this.init_on_error();
  }

  // 接收消息
  init_on_message = () => this.udp_server.on('message', (pkt, { port, address }) => {
    console.log(`${SERVER_PORT} 端口的 udp 服务接收到了来自 ${address}:${port} 的消息`);
    const { checksum, data } = JSON.parse(pkt);
    if (checksum) {
      console.log(`消息的校验和 checksum 是正确的, 将返回 ACK 应答`);
      this.dispatch('not_corrupt', { packet: JSON.stringify(data), port, address });
    } else {
      console.log(`消息的校验和 checksum 发生了错误, 将返回 NAK 应答`);
      this.dispatch('corrupt', { port, address });
    }
  });

  dispatch = (action, { packet, port, address }) => {
    switch(action) {
      case this.ACTIONS.RDT_RECEIVE:
        // 处理 packet 得到 data
        const data = this.extract(packet);
        // 把 data 往上层应用层送
        this.deliver_data(data, { port, address });
        break;
      case this.ACTIONS.CORRUPT:
        // 如果发生了错误的话就构建一个 NAK 错误应答的报文
        const sndpkt1 = this.make_pkt('NAK');
        // 并且把这个 NAK 的否定应答返回给客户端
        this.udt_send(sndpkt1, { port, address });
        break;
      case this.ACTIONS.NOT_CORRUPT:
        // 如果状态是 not corrupt 说明客户端发送过来的报文的校验和是正确的
        this.dispatch('rdt_rcv', { packet, port, address });
        // 此时就要构建一个 ACK 应答表示成功接收到了数据报或分组
        const sndpkt2 = this.make_pkt('ACK');
        // 然后将成功应答返回给客户端
        this.udt_send(sndpkt2, { port, address });
        break;
      default: return;
    }
  }

  // flag 表示 NAK 或 ACK 标志位
  make_pkt = (flag, msg) => (JSON.stringify({ data: msg, flag }));

  extract = (packet) => (JSON.parse(packet));

  deliver_data = (data, { port, address }) => {
    // 在 deliver_data 可以自有地处理客户端发送过的数据报 比如将发过来的东西交给应用层等等
    console.log(`从 ${address}:${port} 接收数据分组成功, 发过来的 data: ${JSON.stringify(data)}`);
  }

  // 服务端在返回信息的时候需要知道客户端的 port 和 address
  udt_send = (pkt, { port, address }) => {
    // 有中文的话最好使用 Buffer 缓冲区 否则下面 send 方法的第三个参数的 length 不好判断
    const _buffer = Buffer.from(pkt);
    // 第二参数 0 表示要发送的信息在 _buffer 中的偏移量
    this.udp_server.send(_buffer, 0, _buffer.byteLength, port, address);
  }

  // 绑定端口
  init_bind_port = () => this.udp_server.bind(this.SERVER_PORT);

  // 监听端口
  init_on_listening = () => this.udp_server.on('listening', () => console.log(`upd 服务正在监听 ${SERVER_PORT} 端口`));

  // 错误处理
  init_on_error = () => this.udp_server.on('error', (err) => {
    console.log(`upd 服务发生错误: ${err}`);
    this.udp_server.close();
  });
  
}

const SFSM = new ServerFiniteStateMachine({ SERVER_PORT });
SFSM.receive_message();

2.0 的服务端代码比起 1.0 最大的变化就在于在 init_on_message 方法中增加了对于客户端传来的 checksum 的校验,如果 checksum 是 1 则认为数据没出错,就触发“not_corrupt”,否则认为数据出错,触发“corrupt”,在dispatch 中如果触发了 corrupt 的话则会返回 NAK 报文。

接下来我们运行一下客户端和服务端,看看会发生什么事情:

从 gif 动图中可以看到,左侧的客户端第一次发起请求构建出来的 checksum 是 0,在咱们的约定中,0 代表数据出错,所有右侧的服务端第一次返回了 NAK 应答,此时左侧的客户端接收到了服务端回送的 NAK 应答,重新发送了 msg。

好了现在 rdt2.0 可以处理传输过程中发生了错误的场景了,但是此时的 rdt2.0 协议只能处理一个来自上层的 send 调用,接下来我们简单修改一下让该协议支持来自上层的多次 CFSM.send_message(msg) 的调用:

// 省略一些代码......

class ClientFiniteStateMachine {

  // 省一些代码......
  
  // 用一个变量来保存上一次发送的 msg
  prev_msg = null;
  // 设置一条队列用来缓存还未发送的数据
  buffer_queue = []; 

  // 省略一些代码......
  
  // 接收消息
  init_on_message = () => this.udp_client.on('message', (msg, { port, address }) => {
    // 省略一些代码......
    // 把上一次发送的 msg 也就是队列最左侧的 msg 先拿出来, 这个 msg 有可能发送成功, 也有可能发送失败
    const prev_msg = this.buffer_queue.shift();
    if (flag === 'ACK') {
      // 省略一些代码......
      // 如果进到这里的话说明发送成功则不用对 prev_msg 做任何处理
    } else if (flag === 'NAK') {
      console.log(`客户端发送消息后接收到了 NAK 应答, 该分组发送失败, 将会重新发送`);
      // 但是如果得到 NAK 应答说明发送失败 要重新传这个 prev msg
      this.dispatch('is_nak', prev_msg);
    }
  });

  /**
   * 可能会有多个场景触发 rdt_send
   *  1. 由上层应用(比如 setIntervel)调用
   *    a. 调用时可能 buffer_queue 存在其他 msg 说明当前正在有一个 msg 被发送中,
   *       此时要等上一个 msg 被发送成功后才能进入下一个 msg 的发送,
   *       所以先把传进来的新的 msg 放到 buffer_queue 的队尾 也就是最后一个
   *    b. 调用时 buffer_queue 中不存在其他 msg 说明可能是第一次调用 也可能是空闲时的调用,
   *       此时直接把 msg 放进 buffer_queue 的队头 对头的 msg 就表示下一个要发送的分组的 msg,
   *       然后走之后的逻辑 将该 msg 发送到 udp 的服务端
   *  2. 当 udp 的服务端返回 NAK 应答时需要重新发送 msg 此时需要调用 rdt_send
   *    a. 当接收到 NAK 的时候先使用一个临时变量将 buffer_queue 中所有的 msg 都保存出来
   *       然后将 buffer_queue 的 length 置为 0, 这是为了下一步调用 rdt_send 的时候可以保证走到上面 1.b 中的逻辑
   *  3. 当 udp 的服务端返回 ACK 应答的时候需要判断 buffer_queue 中是否还有排队中的 msg
   *    a. 如果 buffer_queue 中还有在排队的 msg, 则需要也把所有的 msg 先保存一下,
   *       然后让 buffer_queue 的 length 为 0, 也是为了保证下一步 rdt_send 的时候能够立即发送 buffer_queue[0] 的 msg
   *    b. 如果 buffer_queue 中没有 msg 了就可以做一些其他的操作了
   */
  dispatch = (action, msg) => {
    switch(action) {
      case this.ACTIONS.RDT_SEND:
        if (this.buffer_queue.length) {
          // 如果队列中有 msg 的话说明之前还有分组没有被发送完成
          // 需要先等之前的分组发送完成才能继续发送下一个分组
          // 把当前的 msg 先推入队列 只有当该 buffer_queue[0] 位的 msg 成功被发送到 udp 的服务端时
          // 才能把 buffer_queue[0] 真的从队列的左侧 shift 出来
          Array.isArray(msg) ? this.buffer_queue.push(...msg) : this.buffer_queue.push(msg);
        } else {
          // 如果 buffer_queue 中没有 msg 了那就可以立即发送当前传进来的 msg 了
          // 同样也要先把它放进 buffer_queue 的最左侧缓存起来 以防止该 msg 发送失败
          // 同时 unshift 之后也能保证再 udp 服务端没有应答之前再有新的消息进来的话可以保证走到上面有 length 的逻辑
          Array.isArray(msg) ? this.buffer_queue.unshift(...msg) : this.buffer_queue.unshift(msg);
          // 使用队列中最左侧的元素作为 msg 封装为 packet
          const packet = this.make_pkt(this.get_checksum(), this.buffer_queue[0]);
          // 发送该数据报
          this.udt_send(packet);
        }
        break;
      case this.ACTIONS.IS_NAK:
        // 如果是 NAK 应答的话说明 UDP 的服务端认为数据报或分组发生了错误 此时需要重传
        const temp_current_msg1 = [msg, ...this.buffer_queue];
        this.buffer_queue.length = 0;
        this.dispatch('rdt_send', temp_current_msg1);
        break;
      case this.ACTIONS.IS_ACK:
        // 如果是 ACK 应答的话就可以什么都不做或是继续发送下一个分组了
        if (this.buffer_queue.length) {
          // 从队列的最左侧拿出一个 msg
          const temp_current_msg2 = [...this.buffer_queue];
          this.buffer_queue.length = 0;
          this.dispatch('rdt_send', temp_current_msg2);
        } else {
          console.log('可以做一些别的事情了比如发送下一个分组之类的');
        }
        break;
      default: return;
    }
  }
  // 省略一些其他代码......  
}

// 初始化一个 UDP 客户端的状态机
const CFSM = new ClientFiniteStateMachine({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT });
// 每隔多少秒定时给客户端的 UDP 状态机派发一个发送消息的动作
setInterval(((index) => () => CFSM.dispatch('rdt_send', `数字: ${index++}`))(0), SEND_INTERVAL);

此时的 rdt2.0 与上一个版本的 2.0 的最大差别在于 class 中多设置了一条 buffer queue,由于在某条 msg 发送到服务端的时候可能会陆续有其他来自上层的调用进来,因为将新来的 msg 先暂时放在 queue 中。

另外如果某个 msg 发送失败将要重传的话,需要将该 msg 直接插到 queue 的对头中,如果发送成功则直接丢弃该 msg。

完整的 rdt2.0 客户端代码如下:

const dgram = require('dgram'); // dgram 模块提供了对 udp socket 的封装
const SEND_INTERVAL = 1000; // 每隔 1 秒向服务端发送一次消息
const SERVER_PORT = 13190; // 服务端的端口号时 13190
const SERVER_ADDRESS = '127.0.0.1'; // 服务端的地址就是本机
const CLIENT_PORT = 19411; // 当前这个客户端的端口号是 19411, 其实客户端也可以绑定 port, socket 也会自动绑定

class ClientFiniteStateMachine {
  ACTIONS = {
    RDT_SEND: 'rdt_send', // rdt_send 表示发送一条消息
    IS_ACK: 'is_ack', // 服务端可能会回送 肯定应答
    IS_NAK: 'is_nak', // 服务端可能会回送 否定应答
  };

  // 用一个变量来保存上一次发送的 msg
  prev_msg = null;

  // 设置一条队列用来缓存还未发送的数据
  buffer_queue = [];

  constructor({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT }) {
    if (SEND_INTERVAL && SERVER_PORT && SERVER_ADDRESS && CLIENT_PORT) {
      //创建一个监听某个端口的 udp server
      this.udp_client = dgram.createSocket('udp4');
      this.SEND_INTERVAL = SEND_INTERVAL;
      this.SERVER_PORT = SERVER_PORT;
      this.SERVER_ADDRESS = SERVER_ADDRESS;
      this.CLIENT_PORT = CLIENT_PORT;
      this.init();
    }
  }

  init = () => {
    this.init_bind_port();
    this.init_on_message();
    this.init_on_close();
    this.init_on_error();
  }

  //  该方法作为暴露给上层的接口进行调用
  send_message = (msg) => this.dispatch('rdt_send', msg)

  // 接收消息
  init_on_message = () => this.udp_client.on('message', (msg, { port, address }) => {
    console.log(`udp 客户端接收到了来自 ${address}:${port} 的消息`);
    // 服务端将会在相应中使用 flag 来标识是 ACK 还是 NAK
    const { flag } = JSON.parse(msg);
    // 把上一次发送的 msg 也就是队列最左侧的 msg 先拿出来, 这个 msg 有可能发送成功, 也有可能发送失败
    const prev_msg = this.buffer_queue.shift();
    if (flag === 'ACK') {
      console.log(`客户端发送消息后接收到了 ACK 应答, 该分组发送成功`);
      // 如果得到 ACK 应答说明发送成功因此 prev msg 可以直接撇了
      this.dispatch('is_ack');
    } else if (flag === 'NAK') {
      console.log(`客户端发送消息后接收到了 NAK 应答, 该分组发送失败, 将会重新发送`);
      // 但是如果得到 NAK  应答说明发送失败 要重新传这个 prev msg
      this.dispatch('is_nak', prev_msg);
    }
  });

  /**
   * 可能会有多个场景触发 rdt_send
   *  1. 由上层应用(比如 setIntervel)调用
   *    a. 调用时可能 buffer_queue 存在其他 msg 说明当前正在有一个 msg 被发送中,
   *       此时要等上一个 msg 被发送成功后才能进入下一个 msg 的发送,
   *       所以先把传进来的新的 msg 放到 buffer_queue 的队尾 也就是最后一个
   *    b. 调用时 buffer_queue 中不存在其他 msg 说明可能是第一次调用 也可能是空闲时的调用,
   *       此时直接把 msg 放进 buffer_queue 的队头 对头的 msg 就表示下一个要发送的分组的 msg,
   *       然后走之后的逻辑 将该 msg 发送到 udp 的服务端
   *  2. 当 udp 的服务端返回 NAK 应答时需要重新发送 msg 此时需要调用 rdt_send
   *    a. 当接收到 NAK 的时候先使用一个临时变量将 buffer_queue 中所有的 msg 都保存出来
   *       然后将 buffer_queue 的 length 置为 0, 这是为了下一步调用 rdt_send 的时候可以保证走到上面 1.b 中的逻辑
   *  3. 当 udp 的服务端返回 ACK 应答的时候需要判断 buffer_queue 中是否还有排队中的 msg
   *    a. 如果 buffer_queue 中还有在排队的 msg, 则需要也把所有的 msg 先保存一下,
   *       然后让 buffer_queue 的 length 为 0, 也是为了保证下一步 rdt_send 的时候能够立即发送 buffer_queue[0] 的 msg
   *    b. 如果 buffer_queue 中没有 msg 了就可以做一些其他的操作了
   */
  dispatch = (action, msg) => {
    switch(action) {
      case this.ACTIONS.RDT_SEND:
        if (this.buffer_queue.length) {
          // 如果队列中有 msg 的话说明之前还有分组没有被发送完成
          // 需要先等之前的分组发送完成才能继续发送下一个分组
          // 把当前的 msg 先推入队列 只有当该 buffer_queue[0] 位的 msg 成功被发送到 udp 的服务端时
          // 才能把 buffer_queue[0] 真的从队列的左侧 shift 出来
          Array.isArray(msg) ? this.buffer_queue.push(...msg) : this.buffer_queue.push(msg);
        } else {
          // 如果 buffer_queue 中没有 msg 了那就可以立即发送当前传进来的 msg 了
          // 同样也要先把它放进 buffer_queue 的最左侧缓存起来 以防止该 msg 发送失败
          // 同时 unshift 之后也能保证再 udp 服务端没有应答之前再有新的消息进来的话可以保证走到上面有 length 的逻辑
          Array.isArray(msg) ? this.buffer_queue.unshift(...msg) : this.buffer_queue.unshift(msg);
          // 使用队列中最左侧的元素作为 msg 封装为 packet
          const packet = this.make_pkt(this.get_checksum(), this.buffer_queue[0]);
          // 发送该数据报
          this.udt_send(packet);
        }
        break;
      case this.ACTIONS.IS_NAK:
        // 如果是 NAK 应答的话说明 UDP 的服务端认为数据报或分组发生了错误 此时需要重传
        const temp_current_msg1 = [msg, ...this.buffer_queue];
        this.buffer_queue.length = 0;
        this.dispatch('rdt_send', temp_current_msg1);
        break;
      case this.ACTIONS.IS_ACK:
        // 如果是 ACK 应答的话就可以什么都不做或是继续发送下一个分组了
        if (this.buffer_queue.length) {
          // 从队列的最左侧拿出一个 msg
          const temp_current_msg2 = [...this.buffer_queue];
          this.buffer_queue.length = 0;
          this.dispatch('rdt_send', temp_current_msg2);
        } else {
          console.log('可以做一些别的事情了比如发送下一个分组之类的');
        }
        break;
      default: return;
    }
  }

  // 将 checksum 也构建进去
  make_pkt = (checksum, msg) => (JSON.stringify({ data: msg, checksum }));

  udt_send = (pkt) => {
    // 有中文的话最好使用 Buffer 缓冲区 否则下面 send 方法的第三个参数的 length 不好判断
    const _buffer = Buffer.from(pkt);
    // 第二参数 0 表示要发送的信息在 _buffer 中的偏移量
    this.udp_client.send(_buffer, 0, _buffer.byteLength, this.SERVER_PORT, this.SERVER_ADDRESS);
  }


  // 绑定某个端口
  init_bind_port = () => this.udp_client.bind(this.CLIENT_PORT);

  // 当客户端关闭
  init_on_close = () => this.udp_client.on('close', () => console.log('udp 客户端关闭'));

  // 错误处理
  init_on_error = () => this.udp_client.on('error', (err) => console.log(`upd 服务发生错误: ${err}`));
  
  // 生成一个假的随机的校验和
  get_checksum = () => {
    // 由于当前不好模拟真正网络请求中校验和出错的场景 所以这里设置一个假的开关
    const random_error_switch = Math.random() >= 0.5;
    // 该开关为 0 时候表示校验和出现差错, 为 1 时表示校验和没有出现差错
    const checksum = random_error_switch ? 0 : 1;
    console.log(`本次分组随机生成的校验和是: ${checksum}`);
    return checksum;
  }
}

// 初始化一个 UDP 客户端的状态机
const CFSM = new ClientFiniteStateMachine({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT });
// 每隔多少秒定时给客户端的 UDP 状态机派发一个发送消息的动作
setInterval(((index) => () => CFSM.dispatch('rdt_send', `数字: ${index++}`))(0), SEND_INTERVAL);

而服务端的代码不用进行任何修改。

我们来看下现在运行客户端和服务端的情况: tmp.map.gif

可以看到左侧的客户端代码有时候会构建出 checksum 为 0,也就是数据出错的 packet,发送到服务端的时候服务端都会返回 NAK,并且左侧的客户端接收到 NAK 应答后会重新发送,右侧的服务端也都按照正确的顺序接收到了来自客户端的 msg。

好,到这里,看上去 rdt2.0 比较牛批,已经能正确处理可能会出错的数据了。

ただし!但是!此时有个比较致命的问题,那就是我们在服务端对来自客户端的报文进行了 checksum 的校验,如果成功返回 ACK,失败返回 NAK,但是我们却忽略了在客户端对接收到的 ACK 报文以及 NAK 报文的校验,因为既然两方都是报文,那在网络通信过程中就都可能会发生错误,因此我们应该对客户端和服务端都一视同仁地加入 checksum。


我们把服务端也加入 checksum 的协议成为 rdt2.1 版本,首先在 2.0 的基础之上给服务端加上 checksum:

// 省略一些代码......

class ServerFiniteStateMachine {

  // 省略一些代码......

  dispatch = (action, { packet, port, address }) => {
    switch(action) {
      // 省略一些代码......
      case this.ACTIONS.CORRUPT:
        // 省略一些代码......
        // 此时这里也需要加上 checksum
        const sndpkt1 = this.make_pkt('NAK', this.get_checksum());
        // 省略一些代码......
      case this.ACTIONS.NOT_CORRUPT:
        // 省略一些代码......
        // 此时这里也需要加上 checksum
        const sndpkt2 = this.make_pkt('ACK', this.get_checksum());
        // 省略一些代码......      
    }
  }

  // 生成一个假的随机的校验和
  get_checksum = () => {
    // 由于当前不好模拟真正网络请求中校验和出错的场景 所以这里设置一个假的开关
    const random_error_switch = Math.random() >= 0.5;
    // 该开关为 0 时候表示校验和出现差错, 为 1 时表示校验和没有出现差错
    const checksum = random_error_switch ? 0 : 1;
    console.log(`本次分组随机生成的校验和是: ${checksum}`);
    return checksum;
  }

}

好了此时服务端返回的报文中也有校验和了,此时我们就可以在客户端处理来自服务端的 checksum 了,然后我们在客户端拿到 checksum,之后如果 checksum 错了的话,我们从客户端给服务端返回一个 NAK 报文告诉服务端响应报文数据出错了你丫再给老子发一次,再然后服务端拿到了来自客户端的 NAK 报文之后就要......嗯......就要......就要干啥呀......我擦嘞......

怎么样,是不是觉得有点不对劲儿了,服务端返回给客户端 ACK 或者 NAK 应答报文,客户端还要再给服务端返回 ACK 或 NAK 报文......是不是怎么想都觉得不对劲儿~我们的客户端从理论上讲应该是只负责保证来自上层的 msg 能正确无误地发送到服务端,如果我们要是再在客户端强加上想服务端返回相应报文的功能的话明显违反了客户端的单一原则,而且相应的在服务端也需要加上处理来自客户端的 ACK/NAK 应答的功能,明显两端功能上产生了冗余,那么我们应该怎么做才能让客户端正确相应来自服务端的发生了错误的 ACK/NAK 应答报文呢?

一种常见的做法是,对客户端发送到服务端的每个数据分组进行编号,如果服务端返回了 NAK 报文或者返回的 ACK/NAK 报文发生了错误的话,由客户端将上一个序号的数据报再发送一次,服务端接收到数据后,如果发现是已经接收过的数据报的话,可以直接丢弃并且再次返回该数据报的 ACK 报文,通过此方法,虽然在网络中可能会产生冗余的数据报,但是此时的服务端以及客户端均可正确地处理对方发生了错误的情况。

举个 :考虑某个 ACK/NAK 报文发生错误的情况

左侧的 S 表示 send 端,右侧的 R 表示 receive 端

  1. s 端发送 p0
  2. r 端返回 ack/nak 报文,但是很不幸,这个应答的 checksum 挂了
  3. s 端可能接收到一个模糊不清的应答,那怎么办呢,管他三七二一再发一次 p0 好了
  4. 服务端又接到一次 p0,眉头一锁发现事情不对,于是它明白了是自己刚才给 s 端的报文挂了,那咋整呢,没事儿,再整一个 ack
  5. s 端又接收到了应答,发现这次的应答的 checksum 没毛病了,ojbk 发下一个 p1

然后我们来看一看 rdt2.1 协议的状态跃迁:

  1. 首先服务端等待来自上层的 rdt_send 操作,当上层触发了 rdt_send 后会对应两个动作

    1. make_pkt,但是此时构建 packet 时除了需要 2.0 中的 checksum 之外还需要一个 seq0
    2. udt_send,将上层的数据以及 checksum 还有 seq0 都发送到服务端
  2. 在等待服务端返回后,如果发现服务端应答报文的 checksum 发生了 corrupt 的话或者服务端直接返回了 NAK 应答,则触发 udt_send 重新发送当前数据报

  3. 若是发现服务端返回的 checksum 没毛病并且报文是 ACK 的话说明本次的数据报被正确地发送到了服务端可以不做任何其他操作了或者继续等待上层应用的调用

  4. 然后继续等待来自上层的 rdt_send 操作,当上层触发了 rdt_send 后,本次也会对应两个动作

    1. make_pkt,但是此时构建 packet 时除了需要 2.0 中的 checksum 之外还需要一个 seq1,注意这里不同于 1 中的构建 seq0,这里需要构建一个 seq1
    2. udt_send,将上层的数据以及 checksum 还有 seq1 都发送到服务端
  5. 在等待服务端返回后,如果发现服务端应答报文的 checksum 发生了 corrupt 的话或者服务端直接返回了 NAK 应答,则触发 udt_send 重新发送当前数据报

  6. 若是发现服务端返回的 checksum 没毛病并且报文是 ACK 的话说明本次的数据报被正确地发送到了服务端可以不做任何其他操作或者继续等待上层应用的调用

  7. 如果之后上层再来了一个 send_message 的调用的话,还走一样的逻辑,只不过此次的 seq 序号又要变成 0 了,也就是说 seq 的序号需要在 0 和 1 两个数字之前不停切换。

然后我们来看下 rdt2.1 发生了变化的代码:

// 省略一些代码......


class ClientFiniteStateMachine {
  ACTIONS = {
    RDT_SEND: 'rdt_send', // 表示将客户端传来的数据交付给上层处理
    IS_ACK: 'is_ack', // 表示触发了接收到 ack 报文的动作
    IS_NAK: 'is_nak', // 表示触发了接收到 nak 报文的动作
    NOT_CORRUPT: 'not_corrupt', // 由于来自服务端的 checksum 可能是正确的所以需要这么一个行为动作
    CORRUPT: 'corrupt', // 由于来自服务端的 checksum 可能会出错所以需要这么一个行为动作
  };

  // 省略一些代码......

  // 设置一个初始序号 该序号要在 0 和 1 之间来回切换
  current_seq = 0;
  
  // 省略一些代码......
 
  // 接收消息
  init_on_message = () => this.udp_client.on('message', (msg, { port, address }) => {
    // 省略一些代码......
    // 在当前协议中 udp 的服务端也会返回 checksum, 因为此时的 ack 应答中其实也可能会产生错误
    const { flag, checksum } = JSON.parse(msg);
    if (!checksum) {
      // ACK/NAK 应答报文在网络传输过程中其实也是可能发生错误的 此时 checksum 会不正确
      console.log('服务端返回的 checksum 错误, 该应答回送失败, 客户端将会重新发送当前分组');
      this.dispatch('corrupt', prev_msg);
    } else {
      // 进到这里说明服务端回送的数据报没有问题
      if (flag === 'NAK') {
        console.log('服务端返回了 NAK 应答, 客户端将会重新发送当前分组');
        this.dispatch('is_nak', prev_msg);
      } else if (flag === 'ACK') {
        console.log('服务端返回了 ACK 应答, 本次分组发送成功');
        // 如果校验和没出错同时还是 ACK 应答的话 说明该分组没毛病 可以发送下一个序号的分组了
        this.current_seq = this.current_seq === 1 ? 0 : 1;
        this.dispatch('is_ack');
      }
    }
  });

  dispatch = (action, msg) => {
    switch(action) {
      case this.ACTIONS.RDT_SEND:       
          // 省略一些代码......
          // 同时要使用 current_seq 作为一个序号 第一次是 0
          const packet = this.make_pkt(this.current_seq, this.get_checksum(), this.buffer_queue[0]);
          // 省略一些代码......
        }
        break;
      case this.ACTIONS.CORRUPT:
        // 出错的话逻辑和 NAK 时的逻辑基本一样
        const temp_current_msg1 = msg ? [msg, ...this.buffer_queue] : [...this.buffer_queue];
        this.buffer_queue.length = 0;
        this.dispatch('rdt_send', temp_current_msg1);
        break;
      case this.ACTIONS.NOT_CORRUPT:
        // 没出错时的逻辑和 ACK 应答时基本一样
        if (this.buffer_queue.length) {
          const temp_current_msg2 = [...this.buffer_queue];
          this.buffer_queue.length = 0;
          this.dispatch('rdt_send', temp_current_msg2);
        } else {
          console.log('可以做一些别的事情了比如断开 socket');
        }
      case this.ACTIONS.IS_NAK:
        // 省略一些代码......
      case this.ACTIONS.IS_ACK:
        // 省略一些代码......
      default: return;
    }
  }

  // 现在需要把 seq 序号也带上
  make_pkt = (seq, checksum, msg) => (JSON.stringify({ data: msg, checksum, seq }));
  
  // 省略一些代码......
  
}

// 省略一些代码......

rdt2.1 中的客户端代码和 2.0 中最大变化在于增加了一个 current_seq 来表示本次要发送的 packet 的序号,然后当服务端返回了消息的时候,先判断如果 checksum 出错了说明服务端返回的 ACK/NAK 报文发生了错误,此时不管三七二十一直接重新发送上一次的 msg,如果没出错的话,就要判断本次的报文是 ACK 还是 NAK,不同的报文有不同的处理,如果是 NAK 的话也要重新发送上次的数据报,但是如果是 ACK 的话,需要将 current_seq 置为下一个序号,以防止下一次要发送的数据报的 seq 和上一次的 seq 冲突。

rdt2.1 的客户端完整代码如下:

// dgram 模块提供了对 udp socket 的封装
const dgram = require('dgram');
const SEND_INTERVAL = 1000;
const SERVER_PORT = 13190;
const SERVER_ADDRESS = '127.0.0.1';
const CLIENT_PORT = 19411;

class ClientFiniteStateMachine {
  ACTIONS = {
    RDT_SEND: 'rdt_send',
    IS_ACK: 'is_ack', // 表示触发了接收到 ack 报文的动作
    IS_NAK: 'is_nak', // 表示触发了接收到 nak 报文的动作
    NOT_CORRUPT: 'not_corrupt', // 由于来自服务端的 checksum 可能是正确的所以需要这么一个行为动作
    CORRUPT: 'corrupt', // 由于来自服务端的 checksum 可能会出错所以需要这么一个行为动作
  };

  // 用一个变量来保存上一次发送的 msg
  prev_msg = null;

  // 设置一条队列用来缓存还未发送的数据
  buffer_queue = [];

  // 设置一个初始序号 该序号要在 0 和 1 之间来回切换
  current_seq = 0;

  constructor({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT }) {
    if (SEND_INTERVAL && SERVER_PORT && SERVER_ADDRESS && CLIENT_PORT) {
      //创建一个监听某个端口的 udp server
      this.udp_client = dgram.createSocket('udp4');
      this.SEND_INTERVAL = SEND_INTERVAL;
      this.SERVER_PORT = SERVER_PORT;
      this.SERVER_ADDRESS = SERVER_ADDRESS;
      this.CLIENT_PORT = CLIENT_PORT;
      this.init();
    }
  }

  init = () => {
    this.init_bind_port();
    this.init_on_message();
    this.init_on_close();
    this.init_on_error();
  }

  //  该方法作为暴露给上层的接口进行调用
  send_message = (msg) => this.dispatch('rdt_send', msg)

  // 接收消息
  init_on_message = () => this.udp_client.on('message', (msg, { port, address }) => {
    console.log(`udp 客户端接收到了来自 ${address}:${port} 的消息`);
    // 把上一次发送的 msg 也就是队列最左侧的 msg 先拿出来, 这个 msg 有可能发送成功, 也有可能发送失败
    const prev_msg = this.buffer_queue.shift();
    // 在当前协议中 udp 的服务端也会返回 checksum, 因为此时的 ack 应答中其实也可能会产生错误
    const { flag, checksum } = JSON.parse(msg);
    if (!checksum) {
      // ACK/NAK 应答报文在网络传输过程中其实也是可能发生错误的 此时 checksum 会不正确
      console.log('服务端返回的 checksum 错误, 该应答回送失败, 客户端将会重新发送当前分组');
      this.dispatch('corrupt', prev_msg);
    } else {
      // 进到这里说明服务端回送的数据报没有问题
      if (flag === 'NAK') {
        console.log('服务端返回了 NAK 应答, 客户端将会重新发送当前分组');
        this.dispatch('is_nak', prev_msg);
      } else if (flag === 'ACK') {
        console.log('服务端返回了 ACK 应答, 本次分组发送成功');
        // 如果校验和没出错同时还是 ACK 应答的话 说明该分组没毛病 可以发送下一个序号的分组了
        this.current_seq = this.current_seq === 1 ? 0 : 1;
        this.dispatch('is_ack');
      }
    }
  });

  dispatch = (action, msg) => {
    switch(action) {
      case this.ACTIONS.RDT_SEND:
        if (this.buffer_queue.length) {
          // 如果队列中有 msg 的话说明之前还有分组没有被发送完成
          // 需要先等之前的分组发送完成才能继续发送下一个分组
          // 把当前的 msg 先推入队列 只有当该 buffer_queue[0] 位的 msg 成功被发送到 udp 的服务端时
          // 才能把 buffer_queue[0] 真的从队列的左侧 shift 出来
          Array.isArray(msg) ? this.buffer_queue.push(...msg) : this.buffer_queue.push(msg);
        } else {
          // 如果 buffer_queue 中没有 msg 了那就可以立即发送当前传进来的 msg 了
          // 同样也要先把它放进 buffer_queue 的最左侧缓存起来 以防止该 msg 发送失败
          // 同时 unshift 之后也能保证再 udp 服务端没有应答之前再有新的消息进来的话可以保证走到上面有 length 的逻辑
          Array.isArray(msg) ? this.buffer_queue.unshift(...msg) : this.buffer_queue.unshift(msg);
          // 使用队列中最左侧的元素作为 msg 封装为 packet
          // 同时要使用 current_seq 作为一个序号 第一次是 0
          const packet = this.make_pkt(this.current_seq, this.get_checksum(), this.buffer_queue[0]);
          // 发送该数据报
          this.udt_send(packet);
        }
        break;
      case this.ACTIONS.CORRUPT:
        // 出错的话逻辑和 NAK 时的逻辑基本一样
        const temp_current_msg1 = msg ? [msg, ...this.buffer_queue] : [...this.buffer_queue];
        this.buffer_queue.length = 0;
        this.dispatch('rdt_send', temp_current_msg1);
        break;
      case this.ACTIONS.NOT_CORRUPT:
        // 没出错时的逻辑和 ACK 应答时基本一样
        if (this.buffer_queue.length) {
          const temp_current_msg2 = [...this.buffer_queue];
          this.buffer_queue.length = 0;
          this.dispatch('rdt_send', temp_current_msg2);
        } else {
          console.log('可以做一些别的事情了比如断开 socket');
        }
      case this.ACTIONS.IS_NAK:
        // 如果是 NAK 应答的话说明 UDP 的服务端认为数据报或分组发生了错误 此时需要重传
        const temp_current_msg3 = msg ? [msg, ...this.buffer_queue] : [...this.buffer_queue];
        this.buffer_queue.length = 0;
        this.dispatch('rdt_send', temp_current_msg3);
        break;
      case this.ACTIONS.IS_ACK:
        // 如果是 ACK 应答的话就可以什么都不做或是继续发送下一个分组了
        if (this.buffer_queue.length) {
          const temp_current_msg4 = [...this.buffer_queue];
          this.buffer_queue.length = 0;
          this.dispatch('rdt_send', temp_current_msg4);
        } else {
          console.log('可以做一些别的事情了比如断开 socket');
        }
        break;
      default: return;
    }
  }

  make_pkt = (seq, checksum, msg) => (JSON.stringify({ data: msg, checksum, seq }));

  udt_send = (pkt) => {
    // 有中文的话最好使用 Buffer 缓冲区 否则下面 send 方法的第三个参数的 length 不好判断
    const _buffer = Buffer.from(pkt);
    // 第二参数 0 表示要发送的信息在 _buffer 中的偏移量
    this.udp_client.send(_buffer, 0, _buffer.byteLength, this.SERVER_PORT, this.SERVER_ADDRESS);
  }

  // 绑定某个端口
  init_bind_port = () => this.udp_client.bind(this.CLIENT_PORT);

  // 当客户端关闭
  init_on_close = () => this.udp_client.on('close', () => console.log('udp 客户端关闭'));

  // 错误处理
  init_on_error = () => this.udp_client.on('error', (err) => console.log(`upd 服务发生错误: ${err}`));

  // 生成一个假的随机的校验和
  get_checksum = () => {
    // 由于当前不好模拟真正网络请求中校验和出错的场景 所以这里设置一个假的开关
    const random_error_switch = Math.random() >= 0.5;
    // 该开关为 0 时候表示校验和出现差错, 为 1 时表示校验和没有出现差错
    const checksum = random_error_switch ? 0 : 1;
    console.log(`本次分组随机生成的校验和是: ${checksum}`);
    return checksum;
  }

}

// 初始化一个 UDP 客户端的状态机
const CFSM = new ClientFiniteStateMachine({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT });
// 每隔多少秒定时给客户端的 UDP 状态机派发一个发送消息的动作
setInterval(((index) => () => CFSM.send_message(`数字: ${index++}`))(0), SEND_INTERVAL);

看完了客户端,来看一看服务端,首先来分析一下服务端的状态跃迁:

诶嘛......看上去乱糟糟的,我都不想写字了......

  1. 首先服务端在等待来自客户端的数据,等数据来了之后判断 checksum 是否 corrupt,如果 corrupt 也就是客户端发来的 checksum 出错的话,就触发 make_pkt 创建一个 NAK 报文,同时要添加一个属于服务端 ACK/NAK 报文的 checksum,下一步将该应答报文回送给客户端

  2. 如果服务端接收到来自客户端的数据后,checksum 是正确的,并且此时拿到的来自客户端的 seq 序号是 0 的话(初始设置为 0,当然也可以把初始的预期值设置为 1,但是这我们约定 0 作为初始的预期值),说明符合预期,则触发 extract 以及 deliver_data 将数据交付给上层处理,之后make_pkt 构建 ACK 报文,同时创建属于 ACK 报文的 checksum,并通过 udt_send 返回应答给客户端。

  3. 返回一次正确的 ACK 报文后,此时服务端又进入了继续等待客户端 msg 的状态,这个时候如果客户端又发送消息过来的话,服务端还是要进行 checksum 以及 seq 的判断,只不过不同的是,由于上一轮服务端已经接收过 seq 为 0 的数据报了,此时再接收应该接收 seq 为 1 的数据报,所以此时除了会判断 checksum,还会判断 seq 是否是 1,如果不是 1 的话,说明客户端有发送了一次 seq0,seq0 表示的是上一次已经发送过的数据,那么为什么客户端会把上一次已经发送过的 seq0 再发送一次呢?可以回到上面 2.1 的客户端状态机的分析中看看,客户端重复发送同一个 seq 的数据报,就意味着服务端为上一次请求返回的 ACK/NAK 应答的 checksum 可能产生了错误,所以客户端不知道服务端是否接收到了正确的 seq0 的数据报,于是客户端只好重新发送一次 seq0 的数据报。服务端有接收到上一次的 seq0 的数据报,此时就知道,嗷嗷我刚刚返回给客户端的应答报文废了,那我再发一次好了,于是重新 make_pkt 构建了一个 ACK 报文,然后 udt_send 给客户端

  4. 接下来服务端继续等待来自客户端的数据报,直到接收到 seq 为 1 的数据报,再返回 ACK 报文后,服务端再次将“渴望收到的 seq”置为 0,等待下一次 seq 为 0 的客户端报文送过来

上面解释了一大堆,下面可以来看看 rdt2.1 服务端代码了:

// 省略一些代码......

class ServerFiniteStateMachine {
  // 省略一些代码......
  
  // 用该变量表示服务端渴望接收到的分组的序号
  desired_seq = 0;

  // 省略一些代码......

  // 接收消息
  init_on_message = () => this.udp_server.on('message', (pkt, { port, address }) => {
    console.log(`${SERVER_PORT} 端口的 udp 服务接收到了来自 ${address}:${port} 的消息`);
    // 这里接收的数据分组会多一个来自客户端额 seq 序号
    const { seq, checksum, data } = JSON.parse(pkt);
    if (checksum && seq === this.desired_seq) {
      // 如果校验和没有出错并且客户端传过来的序号和服务端渴望得到的序号也一致
      // 那就可以返回 ACK 报文
      console.log(`消息的校验和 checksum 以及 seq 都是正确的, 将返回 ACK 应答`);
      // 然后要修改渴望得到的序号为下一个
      this.desired_seq = this.desired_seq === 0 ? 1 : 0;
      this.dispatch('not_corrupt', { packet: JSON.stringify(data), port, address });
    } else {
      if (!checksum) {
        // 如果校验和出错说明客户端传过来的数据本身可能出现问题了
        console.log(`消息的校验和 checksum 出错, 将返回 NAK 应答`);
        this.dispatch('corrupt', { port, address });
      } else if (seq !== this.desired_seq) {
        // 如果校验和没错但是传过来的序号不是期望得到的
        // 说明可能在上次一服务端往客户端传送应答时的那份儿应答挂了
        // 此时需要重新回传一份 ACK, 这份 ACK 就是网络中冗余的报文分组
        this.dispatch('not_corrupt', { packet: JSON.stringify(data), port, address });
      }
    }
  });

  // 省略一些代码......

}
// 省略一些代码......

rdt2.1 的服务端代码和 2.0 比最大的区别在于增加了一个 desired_seq 属性表示服务端本次接收客户端数据时渴望得到的数据序号,该序号要在 0 和 1 两个数字之间切换。之后再 init_on_message 方法中需要对该 seq 做判断,如果接收到某次来自客户端的数据之后,发现 checksum 没问题同时 seq 也是渴望得到的话,那就直接触发 not_corrupt 的行为,该行为会构建 ACK 报文并返回给客户端,之后将 desired_seq 期望得到的数据序号置为另外一个。

如果服务端发现 checksum 没有出错,但是 seq 不是期望得到的,说明客户端可能将上一次已经发送过的数据报又发送了一次,表示客户端那边在上一次并没有能够得到正确的服务端返回过去的 ACK/NAK 应答,所以此时服务端只需要再根据 checksum 重新返回一次 ACK/NAK 报文就好。

完整的 rdt2.1 服务端代码如下:

// dgram 模块提供了对 udp socket 的封装
const dgram = require('dgram');
const SERVER_PORT = 13190;

class ServerFiniteStateMachine {
  ACTIONS = {
    RDT_RECEIVE: 'rdt_rcv',
    NOT_CORRUPT: 'not_corrupt', // 该动作在校验和没出错的情况下触发
    CORRUPT: 'corrupt', // 该动作在校验和出错的情况下触发
  };

  // 用该变量表示服务端渴望接收到的分组的序号
  desired_seq = 0;

  constructor({ SERVER_PORT }) {
    if (SERVER_PORT) {
      this.udp_server = dgram.createSocket('udp4');
      this.SERVER_PORT = SERVER_PORT;
    }
  }

  // 该方法暴露给外部, 当初始化该 class 之后调用
  receive_message = () => this.init();

  init = () => {
    this.init_bind_port();
    this.init_on_message();
    this.init_on_listening();
    this.init_on_error();
  }

  // 接收消息
  init_on_message = () => this.udp_server.on('message', (pkt, { port, address }) => {
    console.log(`${SERVER_PORT} 端口的 udp 服务接收到了来自 ${address}:${port} 的消息`);
    const { seq, checksum, data } = JSON.parse(pkt);
    if (checksum && seq === this.desired_seq) {
      // 如果校验和没有出错并且客户端传过来的序号和服务端渴望得到的序号也一致
      // 那就可以返回 ACK 报文
      console.log(`消息的校验和 checksum 以及 seq 都是正确的, 将返回 ACK 应答`);
      // 然后要修改渴望得到的序号为下一个
      this.desired_seq = this.desired_seq === 0 ? 1 : 0;
      this.dispatch('not_corrupt', { packet: JSON.stringify(data), port, address });
    } else {
      if (!checksum) {
        // 如果校验和出错说明客户端传过来的数据本身可能出现问题了
        console.log(`消息的校验和 checksum 出错, 将返回 NAK 应答`);
        this.dispatch('corrupt', { port, address });
      } else if (seq !== this.desired_seq) {
        // 如果校验和没错但是传过来的序号不是期望得到的
        // 说明可能在上次一服务端往客户端传送应答时的那份儿应答挂了
        // 此时需要重新回传一份 ACK, 这份 ACK 就是网络中冗余的报文分组
        this.dispatch('not_corrupt', { packet: JSON.stringify(data), port, address });
      }
    }
  });

  dispatch = (action, { packet, port, address }) => {
    switch(action) {
      case this.ACTIONS.RDT_RECEIVE:
        // 处理 packet 得到 data
        const data = this.extract(packet);
        // 把 data 往上层应用层送
        this.deliver_data(data, { port, address });
        break;
      case this.ACTIONS.CORRUPT:
        // 如果发生了错误的话就构建一个 NAK 错误应答的报文
        const sndpkt1 = this.make_pkt('NAK', this.get_checksum());
        // 并且把这个 NAK 的否定应答返回给客户端
        this.udt_send(sndpkt1, { port, address });
        break;
      case this.ACTIONS.NOT_CORRUPT:
        // 如果状态是 not corrupt 说明客户端发送过来的报文的校验和是正确的
        this.dispatch('rdt_rcv', { packet, port, address });
        // 此时就要构建一个 ACK 应答表示成功接收到了数据报或分组
        const sndpkt2 = this.make_pkt('ACK', this.get_checksum());
        // 然后将成功应答返回给客户端
        this.udt_send(sndpkt2, { port, address });
        break;
      default: return;
    }
  }

  // flag 表示 NAK 或 ACK 标志位
  // 由于返回的应答报文实际上也可能会发生错误 所以也需要有个 checksum
  make_pkt = (flag, checksum, msg) => (JSON.stringify({ data: msg, flag, checksum }));

  extract = (packet) => (JSON.parse(packet));

  deliver_data = (data, { port, address }) => {
    // 在 deliver_data 可以自有地处理客户端发送过的数据报 比如将发过来的东西交给应用层等等
    console.log(`从 ${address}:${port} 接收数据分组成功, 发过来的 data: ${JSON.stringify(data)}`);
  }

  // 服务端在返回信息的时候需要知道客户端的 port 和 address
  udt_send = (pkt, { port, address }) => {
    // 有中文的话最好使用 Buffer 缓冲区 否则下面 send 方法的第三个参数的 length 不好判断
    const _buffer = Buffer.from(pkt);
    // 第二参数 0 表示要发送的信息在 _buffer 中的偏移量
    this.udp_server.send(_buffer, 0, _buffer.byteLength, port, address);
  }

  // 绑定端口
  init_bind_port = () => this.udp_server.bind(this.SERVER_PORT);

  // 监听端口
  init_on_listening = () => this.udp_server.on('listening', () => console.log(`upd 服务正在监听 ${SERVER_PORT} 端口`));

  // 错误处理
  init_on_error = () => this.udp_server.on('error', (err) => {
    console.log(`upd 服务发生错误: ${err}`);
    this.udp_server.close();
  });

  // 生成一个假的随机的校验和
  get_checksum = () => {
    // 由于当前不好模拟真正网络请求中校验和出错的场景 所以这里设置一个假的开关
    const random_error_switch = Math.random() >= 0.5;
    // 该开关为 0 时候表示校验和出现差错, 为 1 时表示校验和没有出现差错
    const checksum = random_error_switch ? 0 : 1;
    console.log(`本次分组随机生成的校验和是: ${checksum}`);
    return checksum;
  }

}

const SFSM = new ServerFiniteStateMachine({ SERVER_PORT });
SFSM.receive_message();

现在我们来看一下 rdt2.1 是否能够正常运行:

tmp2.map.gif

好的到目前我们创建了一个看上去还算是 ok 的协议 rdt2.1,该协议可以在网络传输中,不管是请求的一方还是响应的一方发送的数据发生错误的情况下正确的处理,但是它目前还有一个小的可以优化的地方,细心的话可以发现,在当前 rdt2.1 的客户端代码中的 init_on_message 方法中,实际上只对 ack 和 nak 报文进行了处理,并没有用到 seq 序号的处理,此时我们的客户端和服务端仍然都需要维护 ack/nak/seq 三个东西,我们现在已经有了序号这种东西,那么是不是就可以用序号这个东西进行判断,来对优化客户端以及服务端所需要维护的状态,使用了序号之后,服务端的响应报文,实际上只需要维护一个 ACK 报文就 ok 了,我们可以通过给 ACK 报文加上 序号 的处理来替代之前 NAK 报文的效果。

优化过后的协议可以称它为 rdt2.2,我们来看一看 rdt2.2 的客户端以及服务端的状态机:

由于 rdt2.2 和 rdt2.1 协议的变化并不是很大,基本原理都是一样的,只不过通过将 seq 的序号添加给 ACK 报文来替代 NAK 报文而已。也就是说原来是通过 ACK 和 NAK 两种报文来表示接收成功还是失败,现在可以通过 ACK0 和 ACK1 来替代原来的所有功能了。

rdt2.2 的客户端完整代码如下:

// dgram 模块提供了对 udp socket 的封装
const dgram = require('dgram');
const SEND_INTERVAL = 1000;
const SERVER_PORT = 13190;
const SERVER_ADDRESS = '127.0.0.1';
const CLIENT_PORT = 19411;

class ClientFiniteStateMachine {
  ACTIONS = {
    RDT_SEND: 'rdt_send',
    NOT_CORRUPT: 'not_corrupt',
    CORRUPT: 'corrupt',
  };

  prev_msg = null;

  buffer_queue = [];

  // 设置一个初始序号 该序号要在 0 和 1 之间来回切换
  current_seq = 0;

  constructor({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT }) {
    if (SEND_INTERVAL && SERVER_PORT && SERVER_ADDRESS && CLIENT_PORT) {
      //创建一个监听某个端口的 udp server
      this.udp_client = dgram.createSocket('udp4');
      this.SEND_INTERVAL = SEND_INTERVAL;
      this.SERVER_PORT = SERVER_PORT;
      this.SERVER_ADDRESS = SERVER_ADDRESS;
      this.CLIENT_PORT = CLIENT_PORT;
      this.init();
    }
  }

  init = () => {
    this.init_bind_port();
    this.init_on_message();
    this.init_on_close();
    this.init_on_error();
  }


  //  该方法作为暴露给上层的接口进行调用
  send_message = (msg) => this.dispatch('rdt_send', msg)

  // 接收消息
  init_on_message = () => this.udp_client.on('message', (msg, { port, address }) => {
    console.log(`udp 客户端接收到了来自 ${address}:${port} 的消息`);
    // 把上一次发送的 msg 也就是队列最左侧的 msg 先拿出来, 这个 msg 有可能发送成功, 也有可能发送失败
    const prev_msg = this.buffer_queue.shift();
    // 在当前协议中 udp 的服务端也会返回 checksum, 因为此时的 ack 应答中其实也可能会产生错误
    // 当前协议中不再需要其他应答报文 只需要有个 ACK 应答就好
    const { ack_with_seq, checksum } = JSON.parse(msg);
    // 不过需要对 ACK 报文的序号做处理
    const { is_ack, ack_seq } = this.get_ack_and_seq(ack_with_seq);
    if (!checksum) {
      // ACK 应答报文在网络传输过程中其实也是可能发生错误的 此时 checksum 会不正确
      console.log('服务端返回的 checksum 错误, 该应答回送失败, 客户端将会重新发送当前分组');
      // 并且保证序号 seq 不发生改变
      this.dispatch('corrupt', prev_msg);
    } else if (is_ack) {
      // 在该协议中干掉 NAK 应答 只保留 ACK 应答, 但是要给每个 ACK 进行编号
      // 是为了减少网络中要处理的报文状态 以为当日后跳脱出当前这种等停模式之后
      // 如果同时处理多个分组时每个分组都有两个报文的状态的话会很乱且麻烦

      if (ack_seq === this.current_seq) {
        console.log('服务端返回了 ACK 应答, 并且 ACK 序号和预期一致本次, 分组发送成功');
        // 如果校验和没出错同时还是 ACK 应答的话 说明该分组没毛病 可以发送下一个序号的分组了
        this.current_seq = this.current_seq === 1 ? 0 : 1;
        this.dispatch('not_corrupt');
      } else {
        console.log('服务端虽然返回了 ACK 应答但是客户端接收到的序号和本次发送的不一样, 说明客户端发送过去的 msg 可能出现错误, 将重新发送该分组');
        this.dispatch('corrupt', prev_msg);
      }
    }
  });

  dispatch = (action, msg) => {
    switch(action) {
      case this.ACTIONS.RDT_SEND:
        if (this.buffer_queue.length) {
          // 如果队列中有 msg 的话说明之前还有分组没有被发送完成
          // 需要先等之前的分组发送完成才能继续发送下一个分组
          // 把当前的 msg 先推入队列 只有当该 buffer_queue[0] 位的 msg 成功被发送到 udp 的服务端时
          // 才能把 buffer_queue[0] 真的从队列的左侧 shift 出来
          Array.isArray(msg) ? this.buffer_queue.push(...msg) : this.buffer_queue.push(msg);
        } else {
          // 如果 buffer_queue 中没有 msg 了那就可以立即发送当前传进来的 msg 了
          // 同样也要先把它放进 buffer_queue 的最左侧缓存起来 以防止该 msg 发送失败
          // 同时 unshift 之后也能保证再 udp 服务端没有应答之前再有新的消息进来的话可以保证走到上面有 length 的逻辑
          Array.isArray(msg) ? this.buffer_queue.unshift(...msg) : this.buffer_queue.unshift(msg);
          // 使用队列中最左侧的元素作为 msg 封装为 packet
          // 同时要使用 current_seq 作为一个序号 第一次是 0
          const packet = this.make_pkt(this.current_seq, this.get_checksum(), this.buffer_queue[0]);
          // 发送该数据报
          this.udt_send(packet);
        }
        break;
      case this.ACTIONS.CORRUPT:
        const temp_current_msg1 = msg ? [msg, ...this.buffer_queue] : [...this.buffer_queue];
        this.buffer_queue.length = 0;
        this.dispatch('rdt_send', temp_current_msg1);
        break;
      case this.ACTIONS.NOT_CORRUPT:
        if (this.buffer_queue.length) {
          const temp_current_msg2 = [...this.buffer_queue];
          this.buffer_queue.length = 0;
          this.dispatch('rdt_send', temp_current_msg2);
        } else {
          console.log('可以做一些别的事情了比如断开 socket');
        }
      default: return;
    }
  }

  make_pkt = (seq, checksum, msg) => (JSON.stringify({ data: msg, checksum, seq }));

  udt_send = (pkt) => {
    // 有中文的话最好使用 Buffer 缓冲区 否则下面 send 方法的第三个参数的 length 不好判断
    const _buffer = Buffer.from(pkt);
    // 第二参数 0 表示要发送的信息在 _buffer 中的偏移量
    this.udp_client.send(_buffer, 0, _buffer.byteLength, this.SERVER_PORT, this.SERVER_ADDRESS);
  }

  // 绑定某个端口
  init_bind_port = () => this.udp_client.bind(this.CLIENT_PORT);

  // 当客户端关闭
  init_on_close = () => this.udp_client.on('close', () => console.log('udp 客户端关闭'));

  // 错误处理
  init_on_error = () => this.udp_client.on('error', (err) => console.log(`upd 服务发生错误: ${err}`));

  // 生成一个假的随机的校验和
  get_checksum = () => {
    // 由于当前不好模拟真正网络请求中校验和出错的场景 所以这里设置一个假的开关
    const random_error_switch = Math.random() >= 0.5;
    // 该开关为 0 时候表示校验和出现差错, 为 1 时表示校验和没有出现差错
    const checksum = random_error_switch ? 0 : 1;
    console.log(`本次分组随机生成的校验和是: ${checksum}`);
    return checksum;
  }

  get_ack_and_seq = (ack_with_seq) => ({is_ack: 'ACK' === ack_with_seq.replace(/(\d)+/ig, '').toLocaleUpperCase(), ack_seq: Number(ack_with_seq.replace(/[a-zA-Z]+/ig, ''))});

}

// 初始化一个 UDP 客户端的状态机
const CFSM = new ClientFiniteStateMachine({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT });
// 每隔多少秒定时给客户端的 UDP 状态机派发一个发送消息的动作
setInterval(((index) => () => CFSM.send_message(`数字: ${index++}`))(0), SEND_INTERVAL);

rdt2.2 的服务端完整代码如下:

// dgram 模块提供了对 udp socket 的封装
const dgram = require('dgram');
const SERVER_PORT = 13190;

class ServerFiniteStateMachine {
  ACTIONS = {
    RDT_RECEIVE: 'rdt_rcv',
    NOT_CORRUPT: 'not_corrupt', // 该动作在校验和没出错的情况下触发
    CORRUPT: 'corrupt', // 该动作在校验和出错的情况下触发
  };

  // 上一次的 seq 理论上永远和渴望得到的 seq 是不一样的
  prev_seq = 1;
  // 用该变量表示服务端渴望接收到的分组的序号
  desired_seq = 0;
  // 用该变量表示服务端要返回给客户端的带有序号的 ACK 报文
  ack_with_seq = null;

  constructor({ SERVER_PORT }) {
    if (SERVER_PORT) {
      this.udp_server = dgram.createSocket('udp4');
      this.SERVER_PORT = SERVER_PORT;
    }
  }

  // 该方法暴露给外部, 当初始化该 class 之后调用
  receive_message = () => this.init();

  run = () => this.init();

  init = () => {
    this.init_bind_port();
    this.init_on_message();
    this.init_on_listening();
    this.init_on_error();
  }

  // 接收消息
  init_on_message = () => this.udp_server.on('message', (pkt, { port, address }) => {
    console.log(`${SERVER_PORT} 端口的 udp 服务接收到了来自 ${address}:${port} 的消息`);
    const { seq, checksum, data } = JSON.parse(pkt);
    if (checksum && seq === this.desired_seq) {
      // 如果校验和没有出错并且客户端传过来的序号和服务端渴望得到的序号也一致
      // 那就可以返回 ACK 报文
      console.log(`消息的校验和 checksum 以及 seq 都是正确的, 将返回 ACK 应答`);
      // 然后要修改渴望得到的序号为下一个
      this.desired_seq = this.desired_seq === 0 ? 1 : 0;
      // 将本次发过来的 seq 记录为 "最近一次正确的 seq"
      this.prev_seq = seq;
      this.dispatch('not_corrupt', { packet: JSON.stringify(data), port, address });
    } else {
      if (!checksum) {
        // 如果校验和出错说明客户端传过来的数据本身可能出现问题了
        console.log(`消息的校验和 checksum 出错, 将返回 ACK${this.prev_seq} 应答`);
        this.dispatch('corrupt', { port, address });
      } else if (seq !== this.desired_seq) {
        console.log(`消息的校验和 checksum 正确, 本次请求的序号 seq 和期望的不一致, 将返回 ACK${this.prev_seq} 应答`);
        // 如果校验和没错但是传过来的序号不是期望得到的
        // 说明可能在上次一服务端往客户端传送应答时的那份儿应答挂了
        // 此时需要重新回传一份 ACK 并且序号是上一次 msg 的序号
        this.dispatch('corrupt', { port, address });
      }
    }
  });

  dispatch = (action, { packet, port, address }) => {
    switch(action) {
      case this.ACTIONS.RDT_RECEIVE:
        // 处理 packet 得到 data
        const data = this.extract(packet);
        // 把 data 往上层应用层送
        this.deliver_data(data, { port, address });
        break;
      case this.ACTIONS.CORRUPT:
        // 发生错误的话构建一个 ACK 应答并且将上一个序号返回
        const sndpkt1 = this.make_pkt(this.create_ack_with_seq(), this.get_checksum());
        // 并且把这个 NAK 的否定应答返回给客户端
        this.udt_send(sndpkt1, { port, address });
        break;
      case this.ACTIONS.NOT_CORRUPT:
        // 如果状态是 not corrupt 说明客户端发送过来的报文的校验和是正确的
        this.dispatch('rdt_rcv', { packet, port, address });
        // 此时就要构建一个 ACK 应答表示成功接收到了数据报或分组
        const sndpkt2 = this.make_pkt(this.create_ack_with_seq(), this.get_checksum());
        // 然后将成功应答返回给客户端
        this.udt_send(sndpkt2, { port, address });
        break;
      default: return;
    }
  }

  // flag 表示 NAK 或 ACK 标志位
  // 由于返回的应答报文实际上也可能会发生错误 所以也需要有个 checksum
  make_pkt = (ack_with_seq, checksum, msg) => (JSON.stringify({ data: msg, ack_with_seq, checksum }));

  extract = (packet) => (JSON.parse(packet));

  deliver_data = (data, { port, address }) => {
    // 在 deliver_data 可以自有地处理客户端发送过的数据报 比如将发过来的东西交给应用层等等
    console.log(`从 ${address}:${port} 接收数据分组成功, 发过来的 data: ${JSON.stringify(data)}`);
  }

  // 服务端在返回信息的时候需要知道客户端的 port 和 address
  udt_send = (pkt, { port, address }) => {
    // 有中文的话最好使用 Buffer 缓冲区 否则下面 send 方法的第三个参数的 length 不好判断
    const _buffer = Buffer.from(pkt);
    // 第二参数 0 表示要发送的信息在 _buffer 中的偏移量
    this.udp_server.send(_buffer, 0, _buffer.byteLength, port, address);
  }

  // 绑定端口
  init_bind_port = () => this.udp_server.bind(this.SERVER_PORT);

  // 监听端口
  init_on_listening = () => this.udp_server.on('listening', () => console.log(`upd 服务正在监听 ${SERVER_PORT} 端口`));

  // 错误处理
  init_on_error = () => this.udp_server.on('error', (err) => {
    console.log(`upd 服务发生错误: ${err}`);
    this.udp_server.close();
  });

  // 生成一个假的随机的校验和
  get_checksum = () => {
    // 由于当前不好模拟真正网络请求中校验和出错的场景 所以这里设置一个假的开关
    const random_error_switch = Math.random() >= 0.5;
    // 该开关为 0 时候表示校验和出现差错, 为 1 时表示校验和没有出现差错
    const checksum = random_error_switch ? 0 : 1;
    console.log(`本次分组随机生成的校验和是: ${checksum}`);
    return checksum;
  }

  create_ack_with_seq = (seq = this.prev_seq) => (`ACK${Number(seq)}`);

}

const SFSM = new ServerFiniteStateMachine({ SERVER_PORT });
SFSM.receive_message();

在 rdt2.2 中,和 2.1 的差别不大,就是服务端需要返回给客户端带有序号的 ACK 报文,客户端可以只保留 corrupt 以及 not_corrupt 两个用于处理成功和失败的行为。差别不大也不太难,大家可以自己悟,不多说了~


好,到目前为止,我们已经得到了一个能够正确处理数据报文发生错误的协议,看上去已经不错了,但是回头我们最初的问题,一个可靠的协议除了发送过程中比特可能出现差错,另外一个最操蛋的问题就是在传送过程中,数据还有发生丢失的可能,也就是丢包,我们接下来将在 rdt2.2 的基础之上实现 rdt3.0 以解决网络传输中丢包的问题。

我们要向解决丢包的问题应该怎么做呢?从客户端,也就是发送方的角度来看,当发送的数据出错可以重传,当应答的数据出错可以重传,重传可以说是一种万能的方法,但是对于数据丢失或者是延迟在网络中的场景,我们怎么才能复用重传这种机制呢,常见的做法是设置一个 倒数定时器,在一个给定的时间过期之后让客户端重新发送一次数据报。

接下来我们分析一下 rdt3.0 客户端 的 FSM:

  1. 首先上层应用触发了 rdt_send 操作,然后通过 make_pkt 构建 seq0 以及带有 checksum 的请求报文,然后触发 udt_send 方法发送数据,当数据发送过后开启一个倒计时定时器(注:这里的定时器要设置多长时间,需要根据数据包的往返时间以及网络中的拥堵情况动态计算。)

  2. 之后客户端进入等待 ACK0 的状态,当接收到下层送上来的数据时,检测 checksum,如果错误或者得到的 ACK 序号是 1 的话就不做任何事情,直接等待上面第一步中设置的定时器超时就好,就会自动重新发送。同时如果服务端返回的数据报有丢失或者延迟的话,客户端也会进入到 timeout 定时器超时阶段,超时定时器触发后,内部执行两个动作

    1. udt_send 重新发送数据
    2. 开启新的定时器
  3. 接下来等待来自上层的调用,在等待过程中,如果又接收到了响应报文,说明该报文可能是刚刚在网络中发生了延迟的报文,可以啥也不做就算是直接丢弃了

  4. 再往下的状态变化基本和上面一样,只不过在创建 packet 数据报时 seq 变为 1,需要接受的响应的 ACK 也应该是 ACK1,其他过程和前面一样也无需再赘述

然后我们来看看 rdt3.0 客户端的代码实现:

// 省略一些代码......

class ClientFiniteStateMachine {
  // 省略一些代码......

  // 用来保存定时器的 id
  timer = -1;

  // 接收消息
  init_on_message = () => this.udp_client.on('message', (msg, { port, address }) => {
    // 省略一些代码
    if (!checksum) {
      // ACK 应答报文在网络传输过程中其实也是可能发生错误的 此时 checksum 会不正确
      logger.info('服务端返回的 checksum 错误, 该应答回送失败, 超时定时器将会重新发送');
    } else if (is_ack) {
      if (ack_seq === this.current_seq) {
        // 省略一些代码......
        
        // 如果 checksum 以及 seq 都校验成功就取消上面的定时器以防止发送冗余的数据报

        clearTimeout(this.timer);
        
        // 省略一些代码......
      } else {
        // 返回 ACK 但是序号不一致的话说明服务端可能没有正确地接收本次的数据报
        // 所以可以什么都不做 等待定时器触发从而重新发送数据报
        logger.info('服务端虽然返回了 ACK 应答但是客户端接收到的序号和本次发送的不一样, 说明客户端发送过去的 msg 可能出现错误, 超时定时器将会重新发送');
      }
    }
  });
  dispatch = (action, msg) => {
    switch(action) {
      case this.ACTIONS.RDT_SEND:
        if (this.buffer_queue.length) {
          // 省略一些代码......

        } else {
          // 省略一些代码......
          
          // 开启一个定时器
          this.start_timer();
        }
        // 省略一些代码......
    }
  }


  // 设置一个创建定时器的方法
  start_timer = (timeout = this.DEFAULT_TIME_OUT) => this.timer = setTimeout(() => {
    logger.info('超时定时器被触发');
    // 定时器开始先把 queue 队头的 msg 拿出来
    const prev_msg = this.buffer_queue.shift();
    // 重新触发失败后发送的动作
    this.dispatch('corrupt', prev_msg);
  }, timeout);
}
// 省略一些代码......

rdt3.0 和 rdt2.2 的最大区别就在于多当触发了客户端的 rdt_send 动作时候多设置了一个定时器,当成功接收到 ACK 并且当服务端返回的序号和本次客户端发送的序号一致的时候就可以取消该定时器,其他失败的情况可以放任等待定时器的触发。

rdt3.0 客户端的完整代码如下:

// dgram 模块提供了对 udp socket 的封装
const dgram = require('dgram');
const log4js = require('log4js');
const SEND_INTERVAL = 1000;
const SERVER_PORT = 13190;
const SERVER_ADDRESS = '127.0.0.1';
const CLIENT_PORT = 19411;
const DEFAULT_TIME_OUT = 500;

log4js.configure({
  replaceConsole: true,
  pm2: true,
  appenders: { stdout: { type: 'console' } },
  categories: { default: { appenders: ['stdout'], level: 'info' } }
});

const logger = log4js.getLogger();

class ClientFiniteStateMachine {
  ACTIONS = {
    RDT_SEND: 'rdt_send',
    NOT_CORRUPT: 'not_corrupt',
    CORRUPT: 'corrupt',
  };

  prev_msg = null;

  buffer_queue = [];

  // 设置一个初始序号 该序号要在 0 和 1 之间来回切换
  current_seq = 0;

  // 用来保存定时器的 id
  timer = -1;

  constructor({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT, DEFAULT_TIME_OUT }) {
    if (SEND_INTERVAL && SERVER_PORT && SERVER_ADDRESS && CLIENT_PORT) {
      //创建一个监听某个端口的 udp server
      this.udp_client = dgram.createSocket('udp4');
      this.SEND_INTERVAL = SEND_INTERVAL;
      this.SERVER_PORT = SERVER_PORT;
      this.SERVER_ADDRESS = SERVER_ADDRESS;
      this.CLIENT_PORT = CLIENT_PORT;
      this.DEFAULT_TIME_OUT = DEFAULT_TIME_OUT;
      this.init();
    }
  }

  init = () => {
    this.init_bind_port();
    this.init_on_message();
    this.init_on_close();
    this.init_on_error();
  }

  //  该方法作为暴露给上层的接口进行调用
  send_message = (msg) => this.dispatch('rdt_send', msg)

  // 接收消息
  init_on_message = () => this.udp_client.on('message', (msg, { port, address }) => {
    logger.info(`udp 客户端接收到了来自 ${address}:${port} 的消息`);
    // 把上一次发送的 msg 也就是队列最左侧的 msg 先拿出来, 这个 msg 有可能发送成功, 也有可能发送失败
    // const prev_msg = this.buffer_queue.shift();
    // 在当前协议中 udp 的服务端也会返回 checksum, 因为此时的 ack 应答中其实也可能会产生错误
    // 当前协议中不再需要其他应答报文 只需要有个 ACK 应答就好
    const { ack_with_seq, checksum } = JSON.parse(msg);
    // 不过需要对 ACK 报文的序号做处理
    const { is_ack, ack_seq } = this.get_ack_and_seq(ack_with_seq);
    if (!checksum) {
      // ACK 应答报文在网络传输过程中其实也是可能发生错误的 此时 checksum 会不正确
      logger.info('服务端返回的 checksum 错误, 该应答回送失败, 超时定时器将会重新发送');
    } else if (is_ack) {
      // 在该协议中干掉 NAK 应答 只保留 ACK 应答, 但是要给每个 ACK 进行编号
      // 是为了减少网络中要处理的报文状态 以为当日后跳脱出当前这种等停模式之后
      // 如果同时处理多个分组时每个分组都有两个报文的状态的话会很乱且麻烦
      if (ack_seq === this.current_seq) {
        logger.info('服务端返回了 ACK 应答, 并且 ACK 序号和预期一致本次, 分组发送成功, 清除上一次的定时器');
        clearTimeout(this.timer);
        // 然后还要清掉 queue 中最左侧的数据
        this.buffer_queue.shift();
        // 如果校验和没出错同时还是 ACK 应答的话 说明该分组没毛病 可以发送下一个序号的分组了
        this.current_seq = this.current_seq === 1 ? 0 : 1;
        this.dispatch('not_corrupt');
      } else {
        logger.info('服务端虽然返回了 ACK 应答但是客户端接收到的序号和本次发送的不一样, 说明客户端发送过去的 msg 可能出现错误, 超时定时器将会重新发送');
      }
    }
  });

  dispatch = (action, msg) => {
    switch(action) {
      case this.ACTIONS.RDT_SEND:
        if (this.buffer_queue.length) {
          // 如果队列中有 msg 的话说明之前还有分组没有被发送完成
          // 需要先等之前的分组发送完成才能继续发送下一个分组
          // 把当前的 msg 先推入队列 只有当该 buffer_queue[0] 位的 msg 成功被发送到 udp 的服务端时
          // 才能把 buffer_queue[0] 真的从队列的左侧 shift 出来
          Array.isArray(msg) ? this.buffer_queue.push(...msg) : this.buffer_queue.push(msg);
        } else {
          // 如果 buffer_queue 中没有 msg 了那就可以立即发送当前传进来的 msg 了
          // 同样也要先把它放进 buffer_queue 的最左侧缓存起来 以防止该 msg 发送失败
          // 同时 unshift 之后也能保证再 udp 服务端没有应答之前再有新的消息进来的话可以保证走到上面有 length 的逻辑
          Array.isArray(msg) ? this.buffer_queue.unshift(...msg) : this.buffer_queue.unshift(msg);
          // 使用队列中最左侧的元素作为 msg 封装为 packet
          // 同时要使用 current_seq 作为一个序号 第一次是 0
          const packet = this.make_pkt(this.current_seq, this.get_checksum(), this.buffer_queue[0]);
          // 发送该数据报
          this.udt_send(packet);
          // 开启一个定时器
          this.start_timer();
        }
        break;
      case this.ACTIONS.CORRUPT:
        const temp_current_msg1 = msg ? [msg, ...this.buffer_queue] : [...this.buffer_queue];
        this.buffer_queue.length = 0;
        this.dispatch('rdt_send', temp_current_msg1);
        break;
      case this.ACTIONS.NOT_CORRUPT:
        if (this.buffer_queue.length) {
          const temp_current_msg2 = [...this.buffer_queue];
          this.buffer_queue.length = 0;
          this.dispatch('rdt_send', temp_current_msg2);
        } else {
          logger.info('可以做一些别的事情了比如断开 socket');
        }
      default: return;
    }
  }

  make_pkt = (seq, checksum, msg) => (JSON.stringify({ data: msg, checksum, seq }));

  udt_send = (pkt) => {
    // 有中文的话最好使用 Buffer 缓冲区 否则下面 send 方法的第三个参数的 length 不好判断
    const _buffer = Buffer.from(pkt);
    // 第二参数 0 表示要发送的信息在 _buffer 中的偏移量
    this.udp_client.send(_buffer, 0, _buffer.byteLength, this.SERVER_PORT, this.SERVER_ADDRESS);
  }

  // 绑定某个端口
  init_bind_port = () => this.udp_client.bind(this.CLIENT_PORT);

  // 当客户端关闭
  init_on_close = () => this.udp_client.on('close', () => logger.info('udp 客户端关闭'));

  // 错误处理
  init_on_error = () => this.udp_client.on('error', (err) => logger.info(`upd 服务发生错误: ${err}`));

  // 生成一个假的随机的校验和
  get_checksum = () => {
    // 由于当前不好模拟真正网络请求中校验和出错的场景 所以这里设置一个假的开关
    const random_error_switch = Math.random() >= 0.5;
    // 该开关为 0 时候表示校验和出现差错, 为 1 时表示校验和没有出现差错
    const checksum = random_error_switch ? 0 : 1;
    logger.info(`本次分组随机生成的校验和是: ${checksum}`);
    return checksum;
  }

  get_ack_and_seq = (ack_with_seq) => ({is_ack: 'ACK' === ack_with_seq.replace(/(\d)+/ig, '').toLocaleUpperCase(), ack_seq: Number(ack_with_seq.replace(/[a-zA-Z]+/ig, ''))});

  start_timer = (timeout = this.DEFAULT_TIME_OUT) => this.timer = setTimeout(() => {
    logger.info('超时定时器被触发');
    const prev_msg = this.buffer_queue.shift();
    // 重新触发失败后发送的动作
    this.dispatch('corrupt', prev_msg);
  }, timeout);
}

// 初始化一个 UDP 客户端的状态机
const CFSM = new ClientFiniteStateMachine({ SEND_INTERVAL, SERVER_PORT, SERVER_ADDRESS, CLIENT_PORT, DEFAULT_TIME_OUT });
// 每隔多少秒定时给客户端的 UDP 状态机派发一个发送消息的动作
setInterval(((index) => () => CFSM.send_message(`数字: ${index++}`))(0), SEND_INTERVAL);

注:至于定时器到底应该设置为多长时间,理论上应该设置为比一个数据报的往返时间稍微长一点的时间,并且要根据网络拥堵情况动态更新。但是我们这里只讨论如何使协议变得可靠,所以暂且先忽略计算超时时间的算法。

至于 rdt3.0 的服务端,可以完全采用 rdt2.2 的代码无需改动,因为在 rdt2.2 中已经实现了处理冗余数据报的场景。

接下来我们采用上面测试 rdt1.0 时的 dnctl 命令创建一条拦截流量的 pipe,然后再使用 pfctl 工具将 udp 流量导入到这条 pipe 中,之后运行客户端和服务端查看效果: 从动图中可以看到,右侧的服务端产生了多次错误的 checksum 并且左侧的客户端也因为丢包触发了多次定时器,但是服务端仍然按照正确的顺序接收到了来自客户端的数据报。

至此,一个基于 UDP 的基本可靠的数据传输协议就做完了。

只不过当前的实现仍然存在一些问题,比如定时器设置的并不合理,如果设置过短会导致网络中存在大量冗余的数据报;还有当前的协议完全是一个基于“等-停”的协议,也就是说只能一个一个按照顺序发,网络信道的利用率并不高;还有当网络中发生了某个应答发生严重延迟时,当该延迟的数据报到达客户端正好和客户端的序号重合的时候等等各种问题。

下一次我们将尝试基于“流水线”协议,实现“GBN(回退N步,也叫滑动窗口)”协议以及“SR(选择重传)”协议,基于以上几个协议我们将会得到更加完美的可靠数据传输协议~

以下为代码仓库地址:

github.com/y805939188/…

如果各位大佬发现内容中哪里有错误或者建议的话请指正,感谢~