Skip to content

Files

Latest commit

c4e46db · Oct 12, 2021

History

History
1142 lines (828 loc) · 46.1 KB

File metadata and controls

1142 lines (828 loc) · 46.1 KB

六、UDP 和多播

用户数据报协议UDP)位于 IP 之上,为 TCP 提供了不可靠的对应物。UDP 在网络中的两个节点之间发送单个数据包。UDP 数据包不知道其他数据包,也不能保证数据包将实际到达其预期目的地。当发送多个数据包时,无法保证到达顺序。UDP 消息只是简单地发送,然后被忘记,因为没有从收件人发送确认。

UDP 是一种无连接协议。两个节点之间没有消息交换来促进数据包传输。没有维护有关连接的状态信息。

UDP 适用于需要高效交付且无需保证交付的服务。例如用于域名系统****DNS服务、网络时间协议NTP服务、IP 语音****VOIP、P2P 网络的网络通信协调等,视频流媒体。如果视频帧丢失,如果丢失不频繁,观众可能永远不会注意到它。

有几种协议使用 UDP,包括:

  • 实时流协议(RTSP):此协议用于控制媒体的流
  • 路由信息协议(RIP):该协议确定用于传输数据包的路由
  • 域名系统(DNS):此协议查找互联网域名并返回其 IP 地址
  • 网络时间协议(NTP):该协议通过互联网同步时钟

UDP 数据包由 IP 地址和端口号组成,用于标识其目的地。UDP 数据包具有固定的大小,可以大到 65353 字节。但是,每个数据包使用至少 20 个字节作为 IP 报头,8 个字节作为 UDP 报头,将消息大小限制为 65507 个字节。如果消息大于此值,则需要发送多个数据包。

UDP 数据包也可以是多播的。这意味着数据包被发送到属于 UDP 组的每个节点。这是一种向多个节点发送信息的有效方法,而无需明确针对每个节点。相反,数据包被发送到其成员负责捕获其组数据包的组。

在本章中,我们将说明如何使用 UDP 协议:

  • 支持传统的客户端/服务器模型
  • 使用 NIO 通道执行 UDP 操作
  • 向组成员发送多播数据包
  • 将音频或视频等流媒体传送到客户端

我们将从概述 Java 对 UDP 的支持开始,并提供更多 UDP 协议的详细信息。

对 UDP 的 Java 支持

Java 使用DatagramSocket类在节点之间形成套接字连接。DatagramPacket类表示一个数据包。简单的发送和接收方法将通过网络传输数据包。

UDP 使用 IP 地址和端口号来标识节点。UDP 端口号的范围从065535。端口号分为三种类型:

  • 众所周知的端口(01023:这些是用于相对常见的服务的端口号。
  • 注册端口(102449151:这些是 IANA 分配给进程的端口号。
  • 动态/专用端口(4915265535:当连接启动时,这些端口被动态分配给客户端。这些通常是临时的,不能由 IANA 分配。

下表是 UDP 特定端口分配的简短列表。它们说明了 UDP 是如何广泛用于支持多种应用程序和服务的。更完整的 TCP/UDP 端口编号列表见中的https://en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers

|

已知端口(0 到 1023)

|

用法

| | --- | --- | | 7 | 这是回送协议 | | 9 | 这意味着在局域网上唤醒 | | 161 | 这就是简单的****网络管理协议SNMP) | | 319 | 这些是精确时间协议PTP事件消息 | | 320 | 这些是 PTP 的一般信息 | | 513 | 这表示用户是谁 | | 514 | 这是用于系统日志记录的系统日志 | | 520 | 这是路由信息协议RIP) | | 750 | 这是kerberos-iv,Kerberos 版本 IV | | 944 | 这是网络文件系统服务 | | 973 | 这是 IPv6 网络文件系统服务 |

下表列出了注册端口及其使用情况:

|

注册端口(1024 到 49151)

|

用法

| | --- | --- | | 1534 | 用于 Eclipse目标通信框架TCF中的) | | 1581 | 这用于 MIL STD 2045-47001 VMF 的 | | 1589 | 用于 CiscoVLAN 查询协议VQP)/VMPS | | 2190 | 这用于 TiVoConnect 信标 | | 2302 | 这是用于光环:战斗进化多人游戏 | | 3000 | 这用于 BitTorrent 同步 | | 4500 | 这用于 IPSec NAT 遍历 | | 5353 | 用于组播 DNSmDNS) | | 9110 | 这用于 SSMP 消息协议 | | 2750027900 | 这是用于 id 软件的地震世界 | | 2990029901 | 这是用于任天堂 Wi-Fi 连接的 | | 36963 | 这是用于虚拟软件多人游戏 |

TCP 与 UDP

TCP 和 UDP 之间有几个区别。这些差异包括:

  • 可靠性:TCP 比 UDP 更可靠
  • 排序:TCP 保证包传输的顺序将被保留
  • 头大小:UDP 头比 TCP 头小
  • 速度:UDP 比 TCP 快

当使用 TCP 发送数据包时,保证数据包到达。如果丢失,则会重新发送。UDP 不提供这种保证。如果数据包未到达,则不会重新发送。

TCP 保留数据包的发送顺序,而 UDP 不保留。如果 TCP 数据包到达目的地的顺序与发送顺序不同,TCP 将按照原始顺序重新组装数据包。使用 UDP 时,不会保留此顺序。

创建数据包时,附加报头信息以协助数据包的传递。对于 UDP,标头由 8 个字节组成。TCP 头的通常大小为 32 字节。

由于具有较小的报头大小和确保可靠性所需的开销,UDP 比 TCP 更高效。此外,创建连接所需的工作更少。这种效率使得流媒体成为更好的选择。

让我们从 UDP 示例开始,了解如何支持传统的客户端/服务器体系结构。

UDP 客户端/服务器

UDP 客户端/服务器应用程序的结构与 TCP 客户端/服务器应用程序的结构相似。在服务器端,创建一个 UDP 服务器套接字,等待客户端请求。客户端将创建相应的 UDP 套接字,并使用它向服务器发送消息。然后,服务器可以处理请求并发回响应。

UDP 客户端/服务器将使用DatagramSocket类作为套接字,并使用DatagramPacket保存消息。消息的内容类型没有限制。在我们的示例中,我们将使用文本消息。

UDP 服务器应用程序

接下来定义我们的服务器。构造器将执行服务器的工作:

public class UDPServer {
    public UDPServer() {
        System.out.println("UDP Server Started");
        ...
        System.out.println("UDP Server Terminating");
    }

    public static void main(String[] args) {
        new UDPServer();
    }
}

在构造函数的 try with resources 块中,我们创建了一个DatagramSocket类的实例。我们将使用的几种方法可能会抛出一个IOException异常,必要时将捕获该异常:

        try (DatagramSocket serverSocket = 
                new DatagramSocket(9003)) {
            ...
            }
        } catch (IOException ex) {
            //Handle exceptions
        }

创建套接字的另一种方法是使用bind方法,如下所示。DatagramSocket实例是使用null作为参数创建的。然后使用bind方法分配端口:

        DatagramSocket serverSocket = new DatagramSocket(null); 
        serverSocket.bind(new InetSocketAddress(9003)); 

这两种方法都将使用端口9003创建一个DatagramSocket实例。

发送消息的过程包括以下内容:

  • 创建字节数组
  • 创建一个DatagramPacket实例
  • 使用DatagramSocket实例等待消息到达

流程被封装在一个循环中,如下所示,以允许处理多个请求。接收到的消息被简单地回显到客户端程序。DatagramPacket实例是使用字节数组及其长度创建的。它被用作DatagramSocket类的receive方法的参数。数据包此时不包含任何信息。此方法将阻塞,直到发出请求,然后将填充数据包:

        while (true) {
            byte[] receiveMessage = new byte[1024];
            DatagramPacket receivePacket = new DatagramPacket(
                receiveMessage, receiveMessage.length);
            serverSocket.receive(receivePacket);
            ...
        }

当方法返回时,数据包被转换成字符串。如果发送了其他数据类型,则需要进行其他转换。然后显示发送的消息:

        String message = new String(receivePacket.getData());
        System.out.println("Received from client: [" + message
               + "]\nFrom: " + receivePacket.getAddress());

要发送响应,需要客户端的地址和端口号。分别使用getAddressgetPort方法,针对拥有此信息的数据包获取这些信息。我们将在讨论客户时看到这一点。还需要表示为字节数组的消息,getBytes方法提供:

        InetAddress inetAddress = receivePacket.getAddress();
        int port = receivePacket.getPort();
        byte[] sendMessage;
        sendMessage = message.getBytes();

使用消息、消息长度、客户端地址和端口号创建一个新的DatagramPacket实例。send方法向客户端发送数据包:

        DatagramPacket sendPacket = 
            new DatagramPacket(sendMessage,
                sendMessage.length, inetAddress, port);
        serverSocket.send(sendPacket);

定义了服务器之后,让我们检查一下客户端。

UDP 客户端应用程序

客户端应用程序将提示用户发送消息,然后将消息发送到服务器。它将等待响应,然后显示响应。声明如下:

class UDPClient {
    public UDPClient() {
        System.out.println("UDP Client Started");
        ...
        }
        System.out.println("UDP Client Terminating ");
    }

    public static void main(String args[]) {
        new UDPClient();
    }
}

Scanner类支持获取用户输入。try with resources 块创建一个DatagramSocket实例并处理异常:

        Scanner scanner = new Scanner(System.in);
        try (DatagramSocket clientSocket = new DatagramSocket()) {
            ...
            }
            clientSocket.close();
        } catch (IOException ex) {
            // Handle exceptions
        }

使用getByName方法访问客户端的当前地址,并声明对字节数组的引用。此地址将用于创建数据包:

        InetAddress inetAddress = 
            InetAddress.getByName("localhost");
        byte[] sendMessage;

无限循环用于提示用户输入消息。当用户输入“退出”时,应用程序将终止,如下所示:

        while (true) {
            System.out.print("Enter a message: ");
            String message = scanner.nextLine();
            if ("quit".equalsIgnoreCase(message)) {
                 break;
            }
        ...
        }

要创建包含消息的DatagramPacket实例,其构造函数需要一个字节数组来表示消息、消息长度以及客户端地址和端口号。在下面的代码中,服务器的端口是9003send方法将数据包发送到服务器:

            sendMessage = message.getBytes();
            DatagramPacket sendPacket = new DatagramPacket(
                sendMessage, sendMessage.length, 
                inetAddress, 9003);
            clientSocket.send(sendPacket);

为了接收响应,将创建一个接收数据包,并以与服务器中处理该数据包相同的方式使用receive方法。此方法将一直阻止,直到服务器响应,然后显示消息:

            byte[] receiveMessage = new byte[1024];
            DatagramPacket receivePacket = new DatagramPacket(
                    receiveMessage, receiveMessage.length);
            clientSocket.receive(receivePacket);
            String receivedSentence = 
                new String(receivePacket.getData());
            System.out.println("Received from server [" 
                + receivedSentence + "]\nfrom "
                + receivePacket.getSocketAddress());

现在,让我们看看这些应用程序的工作情况。

UDP 客户端/服务器正在运行

首先启动服务器。它将显示以下消息:

UDP 服务器启动

接下来,启动客户端应用程序。它将显示以下消息:

UDP 客户端启动

输入消息:

输入一条消息,例如以下消息:

输入一条消息:祝您早上愉快

服务器将显示它已收到消息,如下所示。您将看到几个空的输出行。这是用于保存消息的 1024 字节数组的内容。然后将消息回显到客户端:

**从客户处收到:**您早上好]

。。。

**From:/127.0.0.1

在客户端,将显示响应。在此示例中,用户然后输入“退出”终止应用程序:

**从服务器【您的凌晨】**收到

。。。

**从/127.0.0.1:9003

输入消息:退出

UDP 客户端终止

当我们发送和接收测试消息时,我们可以在显示消息时使用trim方法简化消息的显示,如下所示。此代码可在服务器端和客户端上使用:

        System.out.println("Received from client: [" 
                + message.trim()
                + "]\nFrom: " + receivePacket.getAddress());

输出将更易于阅读,如下所示:

从客户处收到:[早上好给你]

From:/127.0.0.1

此客户端/服务器应用程序可以通过多种方式进行增强,包括使用线程,以使其能够更好地处理多个客户端。此示例演示了用 Java 开发 UDP 客户端/服务器应用程序的基础知识。在下一节中,我们将了解通道如何支持 UDP。**** ****# 对 UDP 的通道支持

DatagramChannel类提供了对 UDP 的额外支持。它可以支持非阻塞交换。DatagramChannel类派生自SelectableChannel类,使多线程应用程序更容易。我们将在第 7 章网络可扩展性中探讨其使用。

DatagramSocket类将通道绑定到端口。该类使用后,不再直接使用。使用DatagramChannel类的方法,我们不必直接使用数据报数据包。相反,使用ByteBuffer类的实例传输数据。此类提供了几种方便的方法来访问其数据。

为了演示DatagramChannel类的使用,我们将开发一个 echo 服务器和客户端应用程序。服务器将等待来自客户端的消息,然后将其发送回客户端。

UDP echo 服务器应用程序

UDP echo 服务器应用程序声明遵循并使用端口9000。在main方法中,try with resources 块打开通道并创建套接字。DatagramChannel类没有公共构造函数。为了创建一个通道,我们使用open方法,它返回DatagramChannel类的一个实例。通道的socket方法为通道创建DatagramSocket实例:

public class UDPEchoServer {

    public static void main(String[] args) {
        int port = 9000;
        System.out.println("UDP Echo Server Started");
        try (DatagramChannel channel = DatagramChannel.open();
            DatagramSocket socket = channel.socket();){
                ...
            }
        }
        catch (IOException ex) {
            // Handle exceptions
        }
        System.out.println("UDP Echo Server Terminated");
    }
}

创建后,我们需要将其与端口关联。首先创建一个表示套接字地址的SocketAddress类实例。InetSocketAddress类派生自SocketAddress类并实现了一个 IP 地址。其在以下代码序列中的使用将与端口9000关联。DatagramSocket类的bind方法将此地址绑定到套接字:

            SocketAddress address = new InetSocketAddress(port);
            socket.bind(address);

ByteBuffer类是使用数据报通道的核心。我们在第 3 章NIO 对网络的支持中讨论了它的创建。在下一条语句中,使用allocateDirect方法创建该类的一个实例。此方法将尝试直接在缓冲区上使用本机操作系统支持。这可能比使用数据报包方法更有效。在这里,我们创建了一个具有最大可能大小的缓冲区:

            ByteBuffer buffer = ByteBuffer.allocateDirect(65507);

添加下面的无限循环,它将从客户端接收消息,显示消息,然后将其发送回:

            while (true) {
                // Get message
                // Display message
                // Return message
            }

receive方法针对通道应用,以获取客户端消息。它将一直阻止,直到收到消息为止。它的单个参数是用于保存传入数据的字节缓冲区。如果消息超过缓冲区的大小,多余的字节将被悄悄地丢弃。

flip方法允许处理缓冲区。它将缓冲区的限制设置为缓冲区中的当前位置,然后将该位置设置为0。后续的 get type 方法将从缓冲区的开头开始:

        SocketAddress client = channel.receive(buffer);
        buffer.flip();

虽然对于 echo 服务器不是必需的,但接收到的消息会显示在服务器上。这使我们能够验证消息是否已收到,并建议如何修改消息,使其不只是简单地回显消息。

为了显示消息,我们需要使用get方法获取每个字节,然后将其转换为适当的类型。echo 服务器用于回显简单字符串。因此,在显示字节之前,需要将其转换为字符。

但是,get方法会修改缓冲区中的当前位置。在将消息发送回客户端之前,我们需要将位置恢复到其原始状态。缓冲器的markreset方法用于此目的。

所有这些都是按照以下代码顺序执行的。mark方法将标记设置在当前位置。StringBuilder实例用于重新创建客户端发送的字符串。缓冲区的hasRemaining方法控制 while 循环。显示消息,reset方法将位置恢复到先前标记的值:

        buffer.mark();
        System.out.print("Received: [");
        StringBuilder message = new StringBuilder();
        while (buffer.hasRemaining()) {
            message.append((char) buffer.get());
        }
        System.out.println(message + "]");
        buffer.reset();

最后一步是将字节缓冲区发送回客户端。send方法就是这样做的。显示一条指示已发送消息的消息,然后显示clear方法。之所以使用这种方法,是因为我们已经通过了缓冲区。它会将位置设置为 0,将缓冲区的限制设置为其容量,并丢弃标记:

        channel.send(buffer, client);
        System.out.println("Sent: [" + message + "]");
        buffer.clear();

当服务器启动时,我们将看到一条这样的消息,如下所示:

UDP 回显服务器启动

我们现在准备好看看客户端是如何实现的。

UDP echo 客户端应用程序

UDP echo 客户端的实现非常简单,使用以下步骤:

  • 已建立到 echo 服务器的连接
  • 将创建一个字节缓冲区来保存消息
  • 缓冲区被发送到服务器
  • 客户端将阻塞,直到消息被发回

客户端的实现细节与服务器的类似。我们从应用程序声明开始,如下所示:

public class UDPEchoClient {

    public static void main(String[] args) {
        System.out.println("UDP Echo Client Started");
        try {
            ...
        }
        catch (IOException ex) {
            // Handle exceptions
        }
        System.out.println("UDP Echo Client Terminated");
    }
}

在服务器中,单个参数InetSocketAddress构造函数将端口9000与当前 IP 地址关联。在客户端中,我们需要指定服务器的 IP 地址和端口。否则,它将无法确定将消息发送到何处。这在下面的语句中使用类的双参数构造函数完成。我们使用地址127.0.0.1,假设客户端和服务器位于同一台机器上:

        SocketAddress remote = 
            new InetSocketAddress("127.0.0.1", 9000);

然后使用open方法创建通道,并使用connect方法连接到套接字地址:

        DatagramChannel channel = DatagramChannel.open();
        channel.connect(remote);

在下一个代码序列中,将创建消息字符串,并分配字节缓冲区。缓冲区的大小设置为字符串的长度。然后,put方法将消息分配给缓冲区。由于put方法需要一个字节数组,所以我们使用String类的getBytes方法来获取对应于消息内容的字节数组:

        String message = "The message";
        ByteBuffer buffer = ByteBuffer.allocate(message.length());
        buffer.put(message.getBytes());

在我们将缓冲区发送到服务器之前,调用flip方法。它将限制设置为当前位置,并将位置设置为 0。因此,当服务器接收时,可以对其进行处理:

        buffer.flip();

要将消息发送到服务器,将调用通道的write方法,如下所示。这将把底层数据包直接发送到服务器。但是,此方法仅在连接了通道的插座时有效,这是以前实现的:

        channel.write(buffer);
        System.out.println("Sent: [" + message + "]");

接下来,清除缓冲区,允许我们重用缓冲区。read方法将接收缓冲区,缓冲区将使用服务器中使用的相同过程显示:

        buffer.clear();
        channel.read(buffer);
        buffer.flip();
        System.out.print("Received: [");
        while(buffer.hasRemaining()) {
            System.out.print((char)buffer.get());
        }
        System.out.println("]");

我们现在已经准备好将客户端与服务器结合使用。

UDP 回送客户端/服务器正在运行

需要先启动服务器。我们将看到初始的服务器消息,如下所示:

UDP 回显服务器启动

接下来,启动客户端。将显示以下输出,显示发送消息的客户端,然后显示返回的消息:

UDP 回显客户端启动

已发送:【消息】

收到:【消息】

UDP 回送客户端终止

在服务器端,我们将看到消息被接收,然后被发送回客户端:

收到:【消息】

已发送:【消息】

使用DatagramChannel类可以加快 UDP 通信速度。

UDP 多播

多播是同时向多个客户端发送消息的过程。每个客户端将收到相同的消息。为了参与这个过程,客户端需要加入一个多播组。发送消息时,其目标地址表示它是多播消息。多播组是动态的,客户端可以随时进出该组。

多播是旧的 IPv4 D 类空间,使用224.0.0.0239.255.255.255的地址。IPv4 多播地址空间注册表列出了多播地址分配,位于http://www.iana.org/assignments/multicast-addresses/multicast-addresses.xml 。IP 多播的主机扩展文档位于http://tools.ietf.org/html/rfc1112 。它定义了支持多播的实现需求。

UDP 多播服务器

接下来声明服务器应用程序。此服务器是一个时间服务器,将每秒广播当前数据和时间。这对于多播消息是一个很好的用途,因为可能有多个客户端对同一信息感兴趣,可靠性不是一个问题。try 块将在异常发生时处理这些异常:

public class UDPMulticastServer {

    public UDPMulticastServer() {
        System.out.println("UDP Multicast Time Server Started");
        try {
            ...
        } catch (IOException | InterruptedException ex) {
            // Handle exceptions
        }
        System.out.println(
            "UDP Multicast Time Server Terminated");
    }

    public static void main(String args[]) {
        new UDPMulticastServer();
    }
}

需要一个MulticastSocket类的实例以及一个持有多播 IP 地址的InetAddress实例。在此示例中,地址228.5.6.7表示多播组。使用joinGroup方法加入该组播组,如下图:

    MulticastSocket multicastSocket = new MulticastSocket();
    InetAddress inetAddress = InetAddress.getByName("228.5.6.7");
    multicastSocket.joinGroup(inetAddress);

为了发送消息,我们需要一个字节数组来保存消息和数据包。声明如下所示:

    byte[] data;
    DatagramPacket packet;

服务器应用程序将使用无限循环每秒广播一个新的日期和时间。线程暂停一秒钟,然后使用Data类创建新的日期和时间。DatagramPacket实例是使用此信息创建的。端口9877已分配给此服务器,需要客户端知道。send方法将数据包发送给感兴趣的客户端:

    while (true) {
        Thread.sleep(1000);
        String message = (new Date()).toString();
        System.out.println("Sending: [" + message + "]");
        data = message.getBytes();
        packet = new DatagramPacket(data, message.length(), 
                inetAddress, 9877);
        multicastSocket.send(packet);
    }

接下来将讨论客户端应用程序。

UDP 多播客户端

此应用程序将加入由地址228.5.6.7定义的多播组。它将阻塞,直到收到消息,然后显示消息。应用程序定义如下:

public class UDPMulticastClient {

    public UDPMulticastClient() {
        System.out.println("UDP Multicast Time Client Started");
        try {
            ...
        } catch (IOException ex) {
            ex.printStackTrace();
        }

        System.out.println(
            "UDP Multicast Time Client Terminated");
    }

    public static void main(String[] args) {
        new UDPMulticastClient();
    }
}

使用端口号9877创建MulticastSocket类的实例。这是必需的,以便它可以连接到 UDP 多播服务器。使用228.5.6.7的多播地址创建InetAddress实例。然后,客户端使用joinGroup 方法加入多播组。

    MulticastSocket multicastSocket = new MulticastSocket(9877);
    InetAddress inetAddress = InetAddress.getByName("228.5.6.7");
    multicastSocket.joinGroup(inetAddress);

需要一个DatagramPacket实例来接收发送给客户端的消息。创建一个字节数组并用于实例化此数据包,如下所示:

    byte[] data = new byte[256];
    DatagramPacket packet = new DatagramPacket(data, data.length);

然后,客户端应用程序进入一个无限循环,在该循环中,它通过receive方法阻塞,直到服务器发送消息。消息到达后,将显示消息:

    while (true) {
        multicastSocket.receive(packet);
        String message = new String(
            packet.getData(), 0, packet.getLength());
        System.out.println("Message from: " + packet.getAddress() 
            + " Message: [" + message + "]");
    }

接下来,我们将演示客户端和服务器如何交互。

UDP 多播客户端/服务器正在运行

启动服务器。服务器的输出将类似于以下输出,但日期和时间将不同:

UDP 多播时间服务器启动

发送:[Sat Sep 19 13:48:42 CDT 2015]

发送:[Sat Sep 19 13:48:43 CDT 2015]

发送:[Sat Sep 19 13:48:44 CDT 2015]

发送:[2015 年 9 月 19 日星期六 13:48:45 CDT]

发送:[Sat Sep 19 13:48:46 CDT 2015]

发送:[Sat Sep 19 13:48:47 CDT 2015]

。。。

接下来,启动客户端应用程序。它将开始接收类似以下内容的消息:

UDP 多播时间客户端启动

消息来源:/192.168.1.7 消息:[Sat Sep 19 13:48:44 CDT 2015]

消息来源:/192.168.1.7 消息:[2015 年 9 月 19 日星期六 13:48:45 CDT]

消息来源:/192.168.1.7 消息:[Sat Sep 19 13:48:46 CDT 2015]

。。。

如果程序在 Mac 上执行,则可能是通过套接字异常。如果发生这种情况,请使用-Djava.net.preferIPv4Stack=true VM选项。

如果启动后续客户端,则每个客户端都将收到相同系列的服务器消息。

带信道的 UDP 多播

我们也可以通过频道多播。我们将使用 IPv6 来演示此过程。这个过程与我们之前使用的DatagramChannel类类似,只是需要使用多播组。为此,我们需要知道哪些网络接口可用。在讨论使用通道进行多播的细节之前,我们将演示如何获取机器的网络接口列表。

NetworkInterface类表示一个网络接口。本课程在第 2 章网络寻址中讨论。以下是该章中演示的方法的变体。它已被扩充以显示特定接口是否支持多播,如下所示:

        try {
            Enumeration<NetworkInterface> networkInterfaces;
            networkInterfaces = 
                NetworkInterface.getNetworkInterfaces();
            for (NetworkInterface networkInterface : 
                    Collections.list(networkInterfaces)) {
                displayNetworkInterfaceInformation(
                    networkInterface);
            }
        } catch (SocketException ex) {
            // Handle exceptions
        }

displayNetworkInterfaceInformation方法如下所示。此方法已从改编为 https://docs.oracle.com/javase/tutorial/networking/nifs/listing.html

    static void displayNetworkInterfaceInformation(
            NetworkInterface networkInterface) {
        try {
            System.out.printf("Display name: %s\n", 
                networkInterface.getDisplayName());
            System.out.printf("Name: %s\n", 
                networkInterface.getName());
            System.out.printf("Supports Multicast: %s\n", 
                networkInterface.supportsMulticast());
            Enumeration<InetAddress> inetAddresses = 
                networkInterface.getInetAddresses();
            for (InetAddress inetAddress : 
                    Collections.list(inetAddresses)) {
                System.out.printf("InetAddress: %s\n", 
                    inetAddress);
            }
            System.out.println();
        } catch (SocketException ex) {
            // Handle exceptions
        }
    }

当执行本例时,您将得到如下类似的输出:

显示名称:软件环回接口 1

名称:lo

支持多播:true

InetAddress:/127.0.0.1

InetAddress:/0:0:0:0:0:0:0:1

显示名称:微软内核调试网络适配器

名称:eth0

支持多播:true

显示名称:Realtek PCIe FE 系列控制器

名称:eth1

支持多播:true

InetAddress:/fe80:0:0:0:91d0:8e19:31f1:cb2d%eth1

显示名称:Realtek RTL8188EE 802.11 b/g/n Wi-Fi 适配器

名称:wlan0

支持多播:true

InetAddress:/192.168.1.7

InetAddress:/2002:42be:6659:0:0:0:1001

InetAddress:/fe80:0:0:0:9cdb:371f:d3e9:4e2e%wlan0

。。。

对于我们的客户端/服务器,我们将使用eth0接口。您需要选择一个最适合您的平台。例如,在 Mac 上,这可能是en0awdl0

UDP 通道多播服务器

UDP 通道多播服务器将:

  • 设置频道和多播组
  • 创建包含消息的缓冲区
  • 使用无限循环发送和显示组消息

服务器的定义如下所示:

public class UDPDatagramMulticastServer {

    public static void main(String[] args) {
        try {
            ...
            }
        } catch (IOException | InterruptedException ex) {
            // Handle exceptions
        }
    }

}

第一个任务使用System类的setProperty方法指定使用 IPv6。然后创建一个DatagramChannel实例,并创建eth0网络接口。setOption方法将将信道与用于识别组的网络接口相关联。该组由使用 IPv6 节点本地作用域多播地址的InetSocketAddress实例表示,如下所示。有关IPv6 多播地址空间注册表文档的更多详细信息,请参见中的http://www.iana.org/assignments/ipv6-multicast-addresses/ipv6-multicast-addresses.xhtml

            System.setProperty(
                "java.net.preferIPv6Stack", "true");
            DatagramChannel channel = DatagramChannel.open();
            NetworkInterface networkInterface = 
                NetworkInterface.getByName("eth0");
            channel.setOption(StandardSocketOptions.
                IP_MULTICAST_IF, 
                networkInterface);
            InetSocketAddress group = 
                new InetSocketAddress("FF01:0:0:0:0:0:0:FC", 
                        9003);

然后根据消息字符串创建字节缓冲区。缓冲区的大小设置为字符串的长度,并使用putgetBytes方法的组合进行分配:

            String message = "The message";
            ByteBuffer buffer = 
                ByteBuffer.allocate(message.length());
            buffer.put(message.getBytes());

在 while 循环中,缓冲区被发送给组成员。为了清楚地看到发送了什么,缓冲区的内容将使用在UDP echo server 应用程序部分中使用的相同代码显示。缓冲区被重置,以便可以再次使用。应用程序暂停一秒钟,以避免出现过多的消息。例如:

            while (true) {
                channel.send(buffer, group);
                System.out.println("Sent the multicast message: " 
                    + message);
                buffer.clear();

                buffer.mark();
                System.out.print("Sent: [");
                StringBuilder msg = new StringBuilder();
                while (buffer.hasRemaining()) {
                    msg.append((char) buffer.get());
                }
                System.out.println(msg + "]");
                buffer.reset();

                Thread.sleep(1000);
        }

现在,我们已经为客户端应用程序做好了准备。

UDP 通道多播客户端

UDP 通道多播客户端将加入组,接收消息,显示消息,然后终止。正如我们将看到的,MembershipKey类表示多播组的成员身份。

申请声明如下。首先,我们指定使用 IPv6。然后声明网络接口,该接口与服务器使用的接口相同:

public class UDPDatagramMulticastClient {
    public static void main(String[] args) throws Exception {
        System.setProperty("java.net.preferIPv6Stack", "true");
        NetworkInterface networkInterface = 
            NetworkInterface.getByName("eth0");
        ...
    }
}

接下来创建DatagramChannel实例。该通道绑定到端口9003并与网络接口实例关联:

        DatagramChannel channel = DatagramChannel.open()
                .bind(new InetSocketAddress(9003))
                .setOption(StandardSocketOptions.IP_MULTICAST_IF, 
                    networkInterface);

然后根据服务器使用的相同 IPv6 地址创建组,并使用通道的join方法创建MembershipKey实例,如下所示。将显示按键和等待消息,以说明客户端的工作方式:

        InetAddress group = 
            InetAddress.getByName("FF01:0:0:0:0:0:0:FC");
        MembershipKey key = channel.join(group, networkInterface);
        System.out.println("Joined Multicast Group: " + key);
        System.out.println("Waiting for a  message...");

创建一个大小为1024的字节缓冲区。这个大小对于本例来说足够了,然后调用receive方法,该方法将阻塞,直到收到消息:

        ByteBuffer buffer = ByteBuffer.allocate(1024);
        channel.receive(buffer);

要显示缓冲区的内容,我们需要翻转它。内容的显示方式与我们之前所做的相同:

        buffer.flip();
        System.out.print("Received: [");
        StringBuilder message = new StringBuilder();
        while (buffer.hasRemaining()) {
            message.append((char) buffer.get());
        }
        System.out.println(message + "]");

当我们使用成员身份密钥时,我们应该表明我们不再对使用drop方法接收组消息感兴趣:

        key.drop();

如果有数据包等待套接字处理,则消息仍可能到达。

UDP 通道多播客户端/服务器正在运行

首先启动服务器。此服务器将每秒显示一系列消息,如下所示:

发送多播报文:报文

已发送:【消息】

发送多播报文:报文

已发送:【消息】

发送多播报文:报文

已发送:【消息】

。。。

接下来,启动客户端应用程序。它将显示多播组,等待消息,然后显示消息,如下所示:

加入多播组:ff01:0:0:0:0:fc,eth1

正在等待消息。。。

收到:【消息】

使用通道可以提高 UDP 多播消息的性能。

UDP 流媒体

使用 UDP 传输音频或视频是很常见的。它是有效的,任何数据包丢失或无序数据包都会导致最小的问题。我们将通过播放现场音频来说明这项技术。UDP 服务器将捕获麦克风的声音并将其发送到客户端。UDP 客户端将接收音频并在系统扬声器上播放。

UDP 流媒体服务器的思想是将流分解为一系列数据包,然后发送到 UDP 客户端。然后,客户端将接收这些数据包,并使用它们来重建流。

为了演示流式音频,我们需要了解一些 Java 如何处理音频流。音频由javax.sound.sampled包中的一系列类处理。用于捕获和播放音频的主要类包括:

  • AudioFormat:此类指定所用音频格式的特征。由于有几种音频格式可用,系统需要知道使用哪种格式。
  • AudioInputStream:此类表示正在录制或播放的音频。
  • AudioSystem:此类提供对系统音频设备和资源的访问。
  • DataLine:此接口控制对流应用的操作,如启动和停止流。
  • SourceDataLine:表示声音的目的地,如扬声器。
  • TargetDataLine:表示声源,如麦克风。

用于SourceDataLineTargetDataLine接口的术语可能有点混乱。这些术语是从线条和混音器的角度来看的。

UDP 音频服务器的实现

AudioUDPServer类的声明如下。它使用TargetDataLine实例作为音频源。它被声明为实例变量,因为它在多个方法中使用。构造函数使用setupAudio方法初始化音频,并使用broadcastAudio方法将此音频发送到客户端:

public class AudioUDPServer {
    private final byte audioBuffer[] = new byte[10000];
    private TargetDataLine targetDataLine;

    public AudioUDPServer() {
        setupAudio();
        broadcastAudio();
    }
    ...
    public static void main(String[] args) {
        new AudioUDPServer();
    }
}

以下是getAudioFormat方法,服务器和客户端都使用该方法来指定音频流特征。模拟音频信号每秒采样 1600 次。每个样本都是一个有符号的 16 位数字。channels变量被指定为1,这意味着音频是单声道的。示例中字节的顺序很重要,设置为 big-endian:

    private AudioFormat getAudioFormat() {
        float sampleRate = 16000F;
        int sampleSizeInBits = 16;
        int channels = 1;
        boolean signed = true;
        boolean bigEndian = false;
        return new AudioFormat(sampleRate, sampleSizeInBits, 
            channels, signed, bigEndian);
    }

Big-endian 和 little-endian 是指字节的顺序。Big-endian 表示一个字的最高有效字节存储在最小的内存地址,最低有效字节存储在最大的内存地址。小恩迪安颠倒了这个顺序。不同的计算机体系结构使用不同的顺序。

setupAudio方法初始化音频。DataLine.Info类使用音频格式信息创建表示音频的行。AudioSystem类的getLine方法返回一条与麦克风对应的数据线。线路打开并启动:

    private void setupAudio() {
        try {
            AudioFormat audioFormat = getAudioFormat();
            DataLine.Info dataLineInfo = 
                new DataLine.Info(TargetDataLine.class, 
                        audioFormat);
            targetDataLine =  (TargetDataLine) 
                AudioSystem.getLine(dataLineInfo);
            targetDataLine.open(audioFormat);
            targetDataLine.start();
        } catch (Exception ex) {
            ex.printStackTrace();
            System.exit(0);
        }
    }

broadcastAudio方法创建 UDP 数据包。使用端口8000创建套接字,并为当前机器创建InetAddress实例:

    private void broadcastAudio() {
        try {
            DatagramSocket socket = new DatagramSocket(8000);
            InetAddress inetAddress = 
                InetAddress.getByName("127.0.0.1");
            ...
        } catch (Exception ex) {
            // Handle exceptions
        }
    }

进入无限循环,read方法填充audioBuffer数组并返回读取的字节数。对于大于0的计数,使用缓冲区创建一个新数据包,并发送到监听端口9786的客户端:

    while (true) {
        int count = targetDataLine.read(
            audioBuffer, 0, audioBuffer.length);
        if (count > 0) {
            DatagramPacket packet = new DatagramPacket(
            audioBuffer, audioBuffer.length, inetAddress, 9786);
            socket.send(packet);
        }
    }

执行时,来自麦克风的声音将作为一系列数据包发送到客户端。

UDP 音频客户端的实现

下面声明AudioUDPClient应用程序。在构造函数中,调用initiateAudio方法开始从服务器接收数据包的过程:

public class AudioUDPClient {
    AudioInputStream audioInputStream;
    SourceDataLine sourceDataLine;
    ...
    public AudioUDPClient() {
        initiateAudio();
    }

    public static void main(String[] args) {
        new AudioUDPClient();
    }
}

initiateAudio方法创建绑定到端口9786的套接字。创建字节数组以保存 UDP 数据包中包含的音频数据:

    private void initiateAudio() {
        try {
            DatagramSocket socket = new DatagramSocket(9786);
            byte[] audioBuffer = new byte[10000];
            ...
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

无限循环将从服务器接收数据包,创建一个AudioInputStream实例,然后调用playAudio方法播放声音。使用以下代码创建数据包,然后阻塞,直到收到数据包:

    while (true) {
        DatagramPacket packet
            = new DatagramPacket(audioBuffer, audioBuffer.length);
        socket.receive(packet);
        ...
    }

接下来,创建音频流。从数据包中提取字节数组。它用作ByteArrayInputStream构造函数的参数,该构造函数与音频格式信息一起用于创建实际的音频流。这与SourceDataLine实例关联,该实例已打开并启动。调用playAudio方法播放声音:

        try {
            byte audioData[] = packet.getData();
            InputStream byteInputStream = 
                new ByteArrayInputStream(audioData);
            AudioFormat audioFormat = getAudioFormat();
            audioInputStream =  new AudioInputStream(
                byteInputStream, 
                audioFormat, audioData.length / 
                audioFormat.getFrameSize());
            DataLine.Info dataLineInfo = new DataLine.Info(
                SourceDataLine.class, audioFormat);
            sourceDataLine = (SourceDataLine) 
                AudioSystem.getLine(dataLineInfo);
            sourceDataLine.open(audioFormat);
            sourceDataLine.start();
            playAudio();
        } catch (Exception e) {
            // Handle exceptions
        }

所使用的getAudioFormat方法与AudioUDPServer申请中声明的方法相同。playAudio方法如下。AudioInputStreamread方法填充一个缓冲区,该缓冲区被写入源数据行。这将有效地在系统扬声器上播放声音:

    private void playAudio() {
        byte[] buffer = new byte[10000];
        try {
            int count;
            while ((count = audioInputStream.read(
                   buffer, 0, buffer.length)) != -1) {
                if (count > 0) {
                    sourceDataLine.write(buffer, 0, count);
                }
            }
        } catch (Exception e) {
            // Handle exceptions
        }
    }

服务器运行时,启动客户端将播放服务器发出的声音。通过使用服务器和客户端中的线程来处理声音的录制和播放,可以增强播放效果。为了简化示例,省略了此细节。

在本例中,连续模拟声音被数字化并分解成数据包。然后这些数据包被发送到客户端,在那里它们被转换成声音并播放。

在其他几个框架中还发现了对 UDP 流的额外支持。Java 媒体框架JMF)http://www.oracle.com/technetwork/articles/javase/index-jsp-140239.html 支持音频和视频媒体的处理。实时传输协议RTP)https://en.wikipedia.org/wiki/Real-time_Transport_Protocol 用于通过网络发送音频和视频数据。

总结

在本章中,我们研究了 UDP 协议的性质以及 Java 如何支持它。我们对比了 TCP 和 UDP,以便在决定哪种协议最适合于给定问题时提供一些指导。

我们从一个简单的 UDP 客户端/服务器开始,演示如何使用DatagramPacketDatagramSocket类。我们看到了如何使用InetAddress类来获取套接字和数据包使用的地址。

DatagramChannel类支持在 UDP 环境中使用 NIO 技术,这比使用DatagramPacketDatagramSocket方法更有效。该方法使用字节缓冲区来保存服务器和客户端之间发送的消息。此示例说明了在第 3 章NIO 网络支持中开发的许多技术。

接下来讨论了 UDP 多播是如何工作的。这提供了一种向组成员广播消息的简单技术。举例说明了MulticastSocketDatagramChannelMembershipKey类的用法。后一类用于在使用DatagramChannel类时建立一个组。

我们最后给出了一个示例,说明如何使用 UDP 支持音频流。我们详细介绍了javax.sound.sampled包中几个类的使用,包括AudioFormatTargetDataLine类用于收集和播放音频。我们使用DatagramSocketDatagramPacket类来传输音频。

在下一章中,我们将研究可用于提高客户端/服务器应用程序可伸缩性的技术。****