TCP 是为数据的可靠传输而设计的,如果数据在传输中丢失或损坏,TCP 会保证再次发送数据。如果数据包乱序到达,TCP 会将其置回正确的顺序。对于连接来说,如果数据到来的速度太快,TCP 会降低速度以避免数据包丢失。程序永远不用担心接收到乱序或不正确的数据。

不过这种可靠性是有代价的,代价就是速度,建立和关闭 TCP 连接会花费相当长的时间。对于部分场景,应用的传输速度可能更重要,比如视频会议、语音电话,其中丢失一部分数据的影响并不大,但如果出现传输中断或者延迟严重可能就无法接受了。于是就出现了 UDP 协议。

用户数据报协议(User Datagram Protocol)是在 IP 之上发送数据的另一种传输层协议,无需像 TCP 协议那样需要服务器端监听,也不必等待客户端与服务器端建立连接后才能通信,是一种无连接的协议,因此速度很快。当发送 UDP 数据时,无法知道数据是否会到达,也不知道数据的各个部分是否会以发送时的顺序到达,所以通常来说它是一种不可靠的传输层协议,适用于一些短小消息类的数据传输。

UDP 是直接对应用层提交的报文进行封装、传输,不会对其进行拆分、合并,保留原来报文的边界,所以 UDP 是报文流(TCP 是字节流)。此外,UDP 还支持各种通信方式,可以一对一、一对多、多对一和多对多(TCP 不支持组播、广播,只支持一对一),所以凡是广播、组播通信方式都是采用 UDP 协议进行传输的。

UDP 协议

在 Java 中 UDP 的实现分为两个类:DatagramPacketDatagramSocket。DatagramPacket 类将数据字节填充到 UDP 包中,称为数据报。DatagramSocket 可以收发 UDP 数据报。

如果想要发送数据,需要将数据放到 DatagramPacket 中,然后使用 DatagramSocket 来发送这个包。如果想要接收数据,可以从 DatagramSocket 中接收一个 DatagramPacket 对象,然后解析该数据报的内容。关于数据报的所有信息(包括发送的目标地址)都会包含在 DatagramPacket 中,DatagramSocket 只需要了解在哪个本地端口监听或发送。

这种指责划分与 TCP 使用的 Socket 和 ServerSocket 有所不同。UDP 没有两台主机间唯一连接的概念,一个 DatagramSocket 可以从多个独立主机收发数据,而不需要知道对方是哪一个远程主机。要确定由谁发送什么数据是应用程序的责任。其次,TCP socket 把网络连接看做是流,而 UDP 处理的总是单个数据报,填充在一个数据报的所有数据会以一个包的形式进行发送,这些数据作为一个组要么全部接收,要么完全丢失。

DatagramPacket

DatagramPacket 类提供了一些方法来获取和设置 IP 首部中的源或目标地址、获取和设置源或目标端口、获取和设置数据,以及获取和设置数据长度。

1. 构造函数

  1. public DatagramPacket(byte buf[], int length)
  2. public DatagramPacket(byte buf[], int offset, int length)
  3. public DatagramPacket(byte buf[], int length, InetAddress address, int port)
  4. public DatagramPacket(byte buf[], int offset, int length, InetAddress address, int port)
  5. public DatagramPacket(byte buf[], int length, SocketAddress address)
  6. public DatagramPacket(byte buf[], int offset, int length, SocketAddress address)

这六个构造函数都接收两个参数,一个是保存数据报数据的 byte 数组,另一个是该数组中用于数据报数据的字节数。如果指定了 offset,数据报文的数据部分将从字节数组的指定位置发送或接收数据。

前两种形式的构造函数主要用来创建接收端的 DatagramPacket 实例。当 socket 从网络接收数据报时,它将数据报的数据存储在 DatagramPacket 对象的缓冲区 byte 数组中,直到达到你指定的长度。

后四种形式主要用来创建发送端的 DatagramPacket 实例,因此需要指定数据报发往的目的地址和端口。DatagramSocket 会从中读取要发往的目的地址和端口,这一点和 TCP 不一样。

2. 获取数据报信息

  1. public synchronized InetAddress getAddress()

返回一个 InetAddress 对象,其中包含远程主机的地址。如果数据报是接收到的话,返回的地址则是发送该数据报的机器的地址(源地址)。如果数据报是本地创建要发送到一个远程机器的话,则会返回数据报将要发往的那个主机的地址(目标地址)。

  1. public synchronized int getPort()

如果数据报是接收到的话,则返回发送包的主机上的端口(源端口)。如果数据报是本地创建要发送到一个远程机器的话,则会返回数据报将要发往的那个主机的端口(目标端口)。

  1. public synchronized SocketAddress getSocketAddress()

返回一个 SocketAddress 对象,其中包含远程主机的 IP 地址和端口。语义同 getAddress() 方法一样。

  1. public synchronized byte[] getData()

返回一个 byte 数组,其中包含数据报中的数据。通常我们需要将这些字节转换成其他的某种数据形式,一种方式是将 byte 数组转换成一个 string;还可以将其转换成一个 ByteArrayInputStream 对象,但要注意在创建时一定要指定 offset 和 length。

  1. public synchronized int getLength()

返回数据报中数据的字节数,该方法返回的值可能小于 getData() 返回的数组的长度。

  1. public synchronized int getOffset()

对于 getData() 返回的数组,该方法会返回数组中的一个位置,即开始填充数据报数据的那个位置。

3. 填充数据报信息

  1. public synchronized void setAddress(InetAddress iaddr)
  2. public synchronized void setPort(int iport)
  3. public synchronized void setSocketAddress(SocketAddress address)
  4. public synchronized void setData(byte[] buf)
  5. public synchronized void setLength(int length)
  6. public synchronized void setData(byte[] buf, int offset, int length)

通常六个构造函数已经足够创建数据报了。不过 java 还提供了几个方法,可以在创建数据报之后改变数据、远程地址和远程端口,这样可以有效的复用 DatagramPacket 对象,减少垃圾回收的时间。

setAddress() 方法会修改数据报发往的地址和端口,可以允许我们将同一个数据报发送给多个不同的接收方。当然也可以用 setAddress() 和 setPort() 方法代替该方法,效果都是一样的。

setLength() 方法会改变内部缓冲区中包含实际数据报数据的字节数,而不包括未填充数据的空间。这个方法在接收数据报时很有用。当接收数据报时,内部缓冲区长度会设置为入站数据的长度,如果我们试图在同一个 DatagramPacket 中接收另一个数据报时,会限制第二个数据报的字节数不能大于第一个数据报的字节数。也就是说,一旦接收了一个 10 字节的数据报,所有后续的数据报都将截断为 10 字节。利用该方法就可以允许我们重置缓冲区的长度,这样就不会截断后续的数据报了。

UDP 客户端

一个典型的 UDP 客户端主要执行以下三步:

  • 创建一个 DatagramSocket 实例,该 socket 并不知道远程主机或地址是什么。
  • 使用 DatagramSocket 类的 send() 和 receive() 方法来发送和接收 DatagramPacket 实例,进行通信。
  • 通信完成后,使用 DatagramSocket 类的 close() 方法来关闭套接字。
  1. private static void DatagramSocketClientTest(String host, int port, byte[] data) throws Exception {
  2. DatagramSocket socket = new DatagramSocket();
  3. socket.setSoTimeout(1000);
  4. InetAddress address = InetAddress.getByName(host);
  5. DatagramPacket sendPacket = new DatagramPacket(data, data.length, address, port);
  6. byte[] bytes = new byte[1024];
  7. DatagramPacket receivePacket = new DatagramPacket(bytes, bytes.length);
  8. // 数据报文可能丢失,必须有重传的逻辑
  9. int tries = 0;
  10. boolean receiveResponse = false;
  11. do {
  12. socket.send(sendPacket);
  13. try {
  14. // receive()方法将阻塞等待,直到收到一个数据报文或等待超时
  15. socket.receive(receivePacket);
  16. if (!receivePacket.getAddress().equals(address)) {
  17. throw new IOException("Receive packet form unknown resource");
  18. }
  19. receiveResponse = true;
  20. } catch (IOException e) {
  21. tries += 1;
  22. System.out.println("Timeout, rites count:" + tries);
  23. }
  24. } while (!receiveResponse && (MAX_TRIES >= tries));
  25. if (receiveResponse) {
  26. System.out.println("receive :" + new String(receivePacket.getData()));
  27. }
  28. socket.close();
  29. }

当发送信息时,创建一个包含了待发送信息的 DatagramPacket 实例,并将其作为参数传递给 DatagramSocket 类的 send() 方法。

当接收信息时,创建一个 DatagramPacket 实例,该实例预先分配了一些空间(byte[])用来将接收到的信息存放在该空间中,然后把该实例作为参数传递给 DatagramSocket 类的 receive() 方法。

DatagramSocket

要收发 DatagramPacket 必须打开一个数据报 Socket。在 Java 中,数据报 Socket 通过 DatagramSocket 类创建和访问。DatagramSocket 需要绑定到一个本地端口,在这个端口上监听入站数据,这个端口也会放置在出站数据报的首部。

如果要编写一个客户端,我们不需要关心本地端口,可以让系统来分配一个未使用的端口,这个端口会放置在所有出站数据报中,服务器将用它来确定响应数据报的发送地址。

如果要编写一个服务器,客户端需要知道服务器在哪个端口监听入站数据报。因此,在构造服务器时需要指定它监听的本地端口。客户端和服务器使用的 Socket 是一样的,区别只在于使用匿名端口还是已知端口。与 TCP 不同,并没有单独的 DatagramServerSocket 类。

1. 构造函数

  1. public DatagramSocket() throws SocketException
  2. public DatagramSocket(int port) throws SocketException
  3. public DatagramSocket(int port, InetAddress laddr) throws SocketException
  4. public DatagramSocket(SocketAddress bindaddr) throws SocketException

第一个构造函数在匿名本地端口打开一个数据报 Socket,一般在发起与服务器对话的客户端中可能要使用这个构造函数,因为客户端不关心 Socket 绑定到哪个端口上。

第二个构造函数创建一个在指定本地端口监听入站数据报的 Socket,如果无法创建 socket,就会抛出一个 SocketException 异常。通常失败的原因是:指定的端口已被占用或者试图连接低于 1024 的端口但是没有足够的权限(例如在 UNIX 系统上非 root 用户不能使用低于 1024 的端口)。

后面两个构造函数创建一个在指定本地端口和网络接口监听入站数据报的 Socket,它会匹配该主机某个网络接口的 InetAddress 对象。如果无法创建 socket 则抛出 SocketException 异常。通常失败的原因是:指定的端口已被占用,试图连接低于 1024 的端口但没有足够的权限,网络接口地址错误。

所有构造函数都只处理本地地址和端口,远程地址和端口存储在 DatagramPacket 中。

2. 发送和接收数据报

DatagramSocket 类的首要任务是发送和接收 UDP 数据报,一个 DatagramSocket 可以即发送又接收数据报。事实上,它可以同时对多台主机收发数据。

  1. public void send(DatagramPacket p) throws IOException

如果发送数据时出现异常,该方法可能会抛出一个 IOException 异常。但这种情况不是很常见,因为 UDP 是不可靠的,这意味着,你不会只是因为包没有到达目的地而得到一个异常。如果试图发送过大的数据报(大于主机底层网络软件所支持的数据报大小)可能会得到该异常。

  1. public synchronized void receive(DatagramPacket p) throws IOException

该方法从网络接收一个 UDP 数据报,存储在现有的 DatagramPacket 对象中。与 ServerSocket 类的 accept 方法相似,该方法也会阻塞调用线程直到数据报到达。

数据报的缓冲区应当足够大,足以保存接收的数据,否则 receive 方法会在缓冲区中放置能保存的尽可能多的数据,其他数据则会丢失。注意,UDP 数据报的数据部分最大长度为 65507 字节(即 IP 数据报的最大长度 65535 减去 IP 首部的 20 字节和 UDP 首部的 8 字节)。

  1. public void close()

用于释放 DatagramSocket 占用的端口等资源

  1. public int getLocalPort()

该方法返回 DatagramSocket 正在监听的本地端口。如果创建了一个匿名端口的 DatagramSocket,希望得出为 Socket 分配的端口,可以使用这个方法。

3. 管理连接

与 TCP socket 不同,数据报 socket 不太在意与谁对话。事实上,默认情况下它们可以与任何人对话,但这通常不是我们所希望的。利用下面五个方法,可以选择允许收发数据报的主机,而拒绝所有其他主机的包。

  1. public void connect(InetAddress address, int port)
  2. public void connect(SocketAddress addr)

该方法不会建立 TCP 意义上的连接。不过,它确实指定了 DatagramSocket 只对指定远程主机和指定远程端口收发数据报,如果向另外的主机或端口发送数据报则抛出 IllegalArgumentException 异常。从其他的主机或端口接收的包将被丢弃,没有异常也没有通知。�

  1. public void disconnect()

该方法用于中断已连接 DatagramSocket 的连接,从而可以再次收发任何主机和端口的包。

4. Socket 选项

Java 支持如下 6 个 UDP Socket 选项:

  • SO_TIMEOUT
  • SO_RCVBUF
  • SO_SNDBUF
  • SO_REUSEADDR
  • SO_BROADCAST
  • IP_TOS

SO_TIMEOUT

  1. public synchronized void setSoTimeout(int timeout) throws SocketException
  2. public synchronized int getSoTimeout() throws SocketException

SO_TIMEOUT 是 receive() 在抛出 InterruptedIOException 异常前等待入站数据报的时间,以毫秒计。如果它的值为 0 则表示 receive() 永远不会超时,默认永远不超时。如果要实现一个安全协议,要求在一定时间内响应可能就需要该选项。

这个值可以用 setSoTimeout() 方法改变,如果时间超时,阻塞的 receive() 会抛出 SocketTimeoutException 异常。但是要在调用 receive() 前设置这个选项。

SO_RCVBUF、SO_SNDBUF

  1. public synchronized void setReceiveBufferSize(int size) throws SocketException
  2. public synchronized int getReceiveBufferSize() throws SocketException
  3. public synchronized void setSendBufferSize(int size) throws SocketException
  4. public synchronized int getSendBufferSize() throws SocketException

DatagramSocket 的 SO_RCVBUF 选项与 Socket 的 SO_RCVBUF 选项紧密相关,它确定了用于网络 I/O 的缓冲区大小。对于相当快的连接,较大的接收缓冲区有助于提升性能,因为在溢出前能存储更多的入站数据报。与 TCP 相比,对于 UDP ,足够大的接收缓冲区更为重要,因为在缓冲区满时到达的 UDP 数据报会丢失。

SO_REUSEADDR

  1. public synchronized void setReuseAddress(boolean on) throws SocketException
  2. public synchronized boolean getReuseAddress() throws SocketException

该选项对于 UDP Socket 的意义与对于 TCP Socket 的意义有所不同。对于 TCP,开启该选项是为了重用处于 TIME_WAIT 状态的 TCP 连接;对于 UDP,开启该选项是为了允许多个 DatagramSocket 同时绑定到相同的端口和地址,这样接收的包将复制给绑定的所有 DatagramSocket。

为了可靠的设置该选项,必须在 DatagramSocket 绑定到端口之前调用该方法,典型使用场景就是组播。通过继承的方式在绑定端口前就开启了该选项:

  1. public MulticastSocket(SocketAddress bindaddr) throws IOException {
  2. super((SocketAddress) null);
  3. setReuseAddress(true);
  4. if (bindaddr != null) {
  5. try {
  6. bind(bindaddr);
  7. } finally {
  8. if (!isBound())
  9. close();
  10. }
  11. }
  12. }

SO_BROADCAST

  1. public synchronized void setBroadcast(boolean on) throws SocketException
  2. public synchronized boolean getBroadcast() throws SocketException

该选项控制是否允许一个 Socket 向广播地址收发包,UDP 广播常用于 DHCP 等协议,这些协议需要与本地网络中的服务器通信,但预先不知道这些服务器的地址。该选项默认是打开的。

UDP 服务器端

UDP 服务器几乎遵循与 UDP 客户端同样的模式。建立一个通信终端,并被动等待客户端发起连接。由于 UDP 是无连接的,UDP 通信通过客户端的数据报文初始化,并没有 TCP 中建立连接那一步。典型的 UDP 服务器要执行以下三步:

  • 创建一个 DatagramSocket 实例,指定本地端口号及本地地址。此时,服务器已经准备好从任何客户端接收数据报文。
  • 使用 DatagramSocket 类的 receive() 方法来接收一个 DatagramPacket 实例。
  • 使用 DatagramSocket 类的 send() 来发送 DatagramPacket 实例,与客户端进行通信。
  1. private static void DatagramSocketServerTest(int port) throws Exception {
  2. DatagramSocket socket = new DatagramSocket(port);
  3. while (true) {
  4. // 阻塞获取从客户端发来的数据报文,每个数据报文都可能发送自不同的客户端
  5. DatagramPacket receive = new DatagramPacket(new byte[1024], 1024);
  6. socket.receive(receive);
  7. System.out.println("Handling client at " + receive.getAddress().getHostAddress() + ", on port " + receive.getPort());
  8. // 响应
  9. byte[] resBytes = "ok".getBytes();
  10. DatagramPacket response = new DatagramPacket(resBytes, resBytes.length, receive.getSocketAddress());
  11. socket.send(response);
  12. }
  13. }