Node.js 网络编程之 UDP

UDP 协议(无连接传输协议)是运行在运输层之上,能够为调用它的应用程序提供一种无需建立连接就可以直接发送数据报的网络传输协议;它主要有以下特点:

  • 无连接:
    • 不同于 TCP 在数据传输之前需要经过三次握手来建立连接,UDP 无需做任何准备即可进行数据传输操作,因此 UDP 的数据传输不存在因连接而导致的时延;
    • TCP 需要在客户端与服务端之间维护连接状态,以便实现 TCP 的可靠数据传输服务,但由于 UDP 是无连接的,因此 UDP 无需维护客户端与服务端之间的连接状态。
  • 应用层拥有绝对控制权:
    • UDP 会把应用层的数据报直接交给网络层,同样的在接收到网络层的数据报后直接交给应用层,UDP 不会对数据报做任何操作;
    • TCP 一般会通过拥塞控制来防止过多数据进入网络,从而导致网络阻塞,但 UDP 并不理会这些问题,因此能够最大限度的独霸网络带宽。

    出于上述两个原因,使用 UDP 的应用拥有对数据报、带宽的绝对控制权。不过需要注意的是,即使 UDP 也使用了检验和机制,也不意味着它对数据有效性进行了任何保证,UDP 的检验和仅用来检查数据报是否出现了差错,即使出现了差错,它还是会将数据报交给应用层,除了给予应用层一些友好的警告外,它不会采取或提供任何补救措施。

相对于 TCP,UDP 是一个相对比较简单的运输层网络传输协议,也由于它的简便性,它拥有 TCP 无法比拟的效率优势,所以对于 DNS、语音视频流等对效率要求较高、对准确性要求较低的应用一般多使用 UDP 协议。

UDP 广(多)播

我们常见的 UDP 服务是一对一的单播服务,接下来我们将讨论一对多的 UDP 服务:

  • 广播:广播与单播的主要区别是目标 IP 地址的不同,单播的目标 IP 地址是具体的主机地址,而广播的目标 IP 地址是所属局域子网中的广播地址,即位于该局域子网下的所有主机均能收到一份数据副本;
  • 多播:也称为组播,是将网络中属于同一业务类型的主机进行逻辑上的分组,信息收发仅发生在同一分组中,不在该分组的的主机无法收发对应的数据。

由于广播几乎会占用所属局域子网的所有带宽,且只能在局域网中使用,因此基于广播的应用相对于多播来说,数量非常少,而多播的一些优点使得它非常适用于生产者/消费者模式下的网络应用:

  • 同一分组下的主机共享同一通道,这大大节省了服务器带宽;
  • 由于多播协议由数据消费者来确定是否进行数据的转发,所以对于生产者的服务端来说,其所需的带宽是固定的,与作为消费者的客户端的数量无关;
  • 多播不仅可以在局域网中使用,也可在广域网中使用。

最后需要注意的是,无论是广播还是多播,它们仅仅进行数据的转发,而不关心且无法保证接收端能够正确地接收到数据,其特性完全符合 UDP 协议,因此广播多播常用于 UDP 协议。

应用

在 Node.js 中,我们可通过 dgram 模块创建 UDP 服务和相关客户端,比如下面的例子:

// server.js
const { createSocket } = require('dgram');

const server = createSocket('udp4');

server.on('error', (err) => {
  console.log(`server error:\n${err.stack}`);
  server.close();
});

server.on('message', (msg, rinfo) => {
  console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`);
});

server.on('listening', () => {
  const address = server.address();
  console.log(`server listening ${address.address}:${address.port}`);
});

server.bind(41234);

// client.js
const { createSocket } = require('dgram');

const client = createSocket('udp4');

client.send('Hello World', 41234, 'localhost', (error) => {
  client.close();
});

上例实现了一个简单的 UDP 服务,主要涉及了 dgram.createSocketdgram.Socket 的使用,下面我们对其进行一一介绍。

dgram.createSocket

dgram.createSocket 主要用于创建 dgram.Socket 实例;有以下两种签名:

  • dgram.createSocket(type[, callback])
    • type:套接字类型,可用值为 udp4udp6;其中 udp4 指运行在 IPv4 下,udp6 指运行在 IPv6 下;
    • callback:该回调会追加到 dgram.Socketmessage 事件监听队列中,当接收到另一端发送的数据时触发。
  • dgram.createSocket(options[, callback])
    • options:属性设置,相关属性如下:
      • type:Socket 类型,可用值为 udp4udp6;其中 udp4 指运行在 IPv4 下,udp6 指运行在 IPv6 下;
      • reuseAddr:假如一个 socket 绑定了 0.0.0.0:41234,另一个 socket 绑定了 127.0.0.1:41234
        • 如果 reuseAddrfalse,将抛出 EADDRINUSE 异常,这是因为 0.0.0.0 代表任何一个 IP 地址,其他的 IP 地址(比如上文中的 127.0.0.1)均会被系统认为已占用;
        • 如果 reuseAddrtrue,此时 0.0.0.0:41234127.0.0.1:41234 代表的是完全不同的地址端口对,所以这两个 socket 均能绑定成功。

        该属性的默认值为 false

      • ipv6Only:是否禁用双协议栈;默认值为 false
      • recvBufferSize:设置套接字 SO_RCVBUF 的值,即设置接收缓冲区大小;
      • sendBufferSize:设置套接字 SO_SNDBUF 的值,即设置发送缓冲区大小;
      • lookup:自定义 DNS 查询逻辑,默认调用 dns.lookup()
      • signal:使用指定的 AbortSignal 来关闭套接字。
    • callback:该回调会追加到 dgram.Socketmessage 事件监听队列中,当接收到另一端发送的数据时触发。

dgram.Socket

dgram.Socket 主要作为服务端与客户端之间通信的桥梁。

常用方法

  • bind:绑定 IP 地址和端口号,这样通信另一端可通过指定的 IP 地址和端口号向该 socket 发送数据报信息,该方法有以下两种签名:
    • socket.bind([port][, address][, callback])
      • port:要绑定的端口号,如果该参数的值为 0 或未指定,系统将随机分配段口号;
      • address:要绑定的 IP 地址,默认值为 0.0.0.0
      • callback:绑定完成后的回调函数。
    • socket.bind(options[, callback])
      • options:属性设置,相关属性如下:
        • port:要绑定的端口号,如果该参数的值为 0 或未指定,系统将随机分配段口号;
        • address:要绑定的 IP 地址,默认值为 0.0.0.0
        • exclusive:在 cluster 中是否允许共享服务监听句柄;默认值为 false
        • fd:已存在 socket 相关文件描述符,如指定则使用该 socket,否则将创建一个新的 socket。
      • callback:绑定完成后的回调函数。
  • close:关闭套接字并停止监听来自通信另一端的数据报,该方法会触发 close 事件;
  • connect:与通信另一端的地址与端口号建立关联,相关参数如下:
    • port:通信另一端端口号;
    • address:通信另一端地址,默认值 udp4 下为 127.0.0.1udp6 下为 ::1
    • callback:连接成功后触发 connect 事件以及指定的 callback,连接失败仅触发指定的 callback

    前文我们说 UDP 是无连接的协议,因此这里的 connect 并不是在通信双方之间建立真正的连接,而只是用来设置通信另一端的地址和端口号;连接建立后,socket.send() 调用无需指定 portaddress 参数,并且仅能收到连接指定的通信另一端的数据报。

  • disconnect:与通信另一端的地址与端口号取消关联;
  • send:发送数据报给指定的通信另一端,相关参数如下:
    • msg:要发送的数据报;
    • offset:数据报第一个字节在缓冲区的偏移量;
    • length:数据报的字节大小;
    • port:通信另一端端口号,如果当前 socket 未连接,则需要指定该参数,否则将使用连接时指定的端口号,而无需指定该参数;
    • address:通信另一端地址,如果当前 socket 未连接,则需要指定该参数,否则将使用连接时指定的地址,而无需指定该参数;
    • callback:数据报发送成功后的回调函数。
  • setBroadcast:设置套接字选项 SO_BROADCAST 的值,用来控制是否允许发送广播数据,其参数 flagboolean 类型;
  • setMulticastInterface:设置多播接口,其参数 multicastInterfacestring 类型;其值在 IPv4IPv6 的要求如下:
    • IPv4 下,值为具体的 IP 地址,比如下面的例子:
      const socket = dgram.createSocket('udp4');
      
      socket.bind(1234, () => {
        socket.setMulticastInterface('10.0.0.2');
      });
      
    • IPv6 下,值应该包含一个作用域,比如下面的例子:
      const socket = dgram.createSocket('udp6');
      
      socket.bind(1234, () => {
        socket.setMulticastInterface('::%eth1');
      });
      
  • addMembership:在指定的接口上将指定的地址加入到一个不限源的多播组中(内部使用了套接字选项 IP_ADD_MEMBERSHIP),相关参数如下:
    • multicastAddress:多播地址;
    • multicastInterface:多播接口,如未指定,操作系统将自行选择一个接口。
  • dropMembership:在指定的接口上将指定的地址从不限源的多播组中移除(内部使用了套接字选项 IP_DROP_MEMBERSHIP),相关参数如下:
    • multicastAddress:多播地址;
    • multicastInterface:多播接口,如未指定,将会从首个匹配的多播组中将指定的地址移除。
  • addSourceSpecificMembership:在指定的接口上加入一个特定于源的多播组(内部使用了套接字选项 IP_ADD_SOURCE_MEMBERSHIP),相关参数如下:
    • sourceAddress:源地址;
    • groupAddress:多播组地址;
    • multicastInterface:多播接口,如未指定,操作系统将自行选择一个接口。
  • dropSourceSpecificMembership:在指定的接口上移除特定于源的多播组(内部使用了套接字选项 IP_DROP_SOURCE_MEMBERSHIP),相关参数如下:
    • sourceAddress:源地址;
    • groupAddress:多播组地址;
    • multicastInterface:多播接口,如未指定,将会解除首个匹配的特定于源的多播组之间的成员关系。
  • setMulticastLoopback:设置套接字选项 IP_MULTICAST_LOOP 的值,用来控制数据是否可以回送到本地的回环接口(默认情况下,当本机发送多播数据到某个网络接口时,在 IP 层,数据会回送到本地的回环接口),其参数为 flagboolean 类型。

相关事件

  • listening:当 socket 已准备好,可以接收数据时触发;该事件可通过 socket.bind() 显示触发,也可通过 socket.send() 隐式触发;需要注意的是,套接字相关的系统资源在该事件触发之前将不可用;
  • connect:通过调用 socket.connect() 与远程端成功建立起连接后触发;
  • message:当 socket 接收到新的数据报时触发;回调函数的参数如下:
    • msg:数据报信息,类型为 Buffer
    • rinfo:数据报发送端信息,相关属性如下:
      • address:数据报发送端的 IP 地址;
      • family:数据报发送端 IP 地址协议版本,值为 IPv4IPv6
      • port:数据报发送端的端口号;
      • size:数据报大小。
  • error:发生异常时触发;
  • close:通过调用 socket.close() 成功关闭 socket 后触发;一旦触发了该事件,将不会再触发 message 事件。

总结

本文我们首先对 UDP 协议进行介绍,它是一种无连接的、非可靠的运输层传输协议,该协议常用于 DNS、NFS、多媒体流等领域;在了解了相关协议的基本运行原理后,我们接着介绍了 Node.js 中 dgram 模块的使用;希望能够通过这种从原理到实践的方式让大家真正掌握 Node.js 网络编程。