TCP Socket 和 UDP DatagramSocket 都是单播 socket,它们在两个明确的端点之间创建一个连接,提供点对点的通信。但在很多场景下,点对点通信并不能够满足需求,比如:电视信号的广播。为此,操作系统提供了组播的方式,组播建立在 UDP 的基础上,Java 中的组播要使用 DatagramSocket 及 MulticastSocket 类。
组播比单播的点对点通信宽但比广播通信窄且目标更明确。组播将数据从一个主机发送给多个不同的主机,但不是发送给每一个人,数据只传送到加入某个特定的组播组(group)从而表示对此感兴趣的客户端。虽然 IP 还支持广播,但使用广播是严格受限的,路由器会限制广播仅限于本地网络或子网以防止对整个 Internet 广播。
组播设计为尽可能无缝地用于 Internet,大多数工作都由路由器完成,路由器会进行相应的转发工作,这对于应用程序来说应当是透明的。应用程序只是将数据报发送给一个组播地址,它在功能上与任何其他 IP 地址没有区别,路由器将确保包被分发到该组播组中的所有主机。但最大的问题是组播路由器并非普遍存在,因此使用前需要确认你的主机和远程主机之间是否有一个由组播路由器构成的路径。
至于应用程序本身,需要注意数据报中的首部字段 TTL(生存时间)的值,该字段表示允许数据报经过的最大路由器数目,当达到这个最大值时,即数据报已经经过了这么多路由器,就会将这个包丢弃。组播使用 TTL 作为一种专门方法来限制包可以传输多远。
组播地址和组
组播地址(multicast address)是称为组播组(multicast group)的一组主机的共享地址。IPv4 组播地址是在 224.0.0.0/4 中的 IP 地址,即范围在 224.0.0.0 到 239.255.255.255 之间。与所有 IP 地址类似,组播地址也可以有一个主机名。
组播组是一组共享一个组播地址的 Internet 主机,任何发送给该组播地址的数据都会中继给组中的所有成员。组播组中的成员是开放的,主机可以在任何时候进入或离开组。组可以是永久的也可以是临时的,永久的组播组分配的地址保持不变,而不论组中是否有成员。但大多数组播组都是临时的,只是在有成员时才存在。
224.0.0.0~224.0.0.255 为预留的组播地址(永久组地址),地址 224.0.0.0 保留不做分配,其它地址供路由协议使用。
224.0.1.0~224.0.1.255 是公用组播地址,可以用于 Internet。
- 224.0.2.0~238.255.255.255 为用户可用的组播地址(临时组地址),全网范围内有效。
- 239.0.0.0~239.255.255.255 为本地管理组播地址,仅在特定的本地范围内有效。
当一台主机希望向组播组发送数据时,它会将数据放在组播数据报中,组播数据通过 UDP 发送,虽然不可靠但比通过面向连接的 TCP 发送数据要快很多。
前面讲过,从应用程序的角度看,组播与使用正常的 UDP socket 之间的主要区别在于必须要考虑 TTL 值。这是 IP 首部中取值为 1~255 的一个字节,它可以粗略的解释为包被丢弃前可以经过的路由器数目。包每通过一个路由器,其 TTL 字段就至少减一,有些路由器会减二甚至更多,当 TTL 到达 0 时包就被丢弃。
TTL 字段最初是为了防止路由循环而设置的,以保证所有包最终都会被丢弃。它可以防止配置有误的路由器相互之间无限地来回传递包。在 IP 组播中,TTL 会在地理范围上限制组播。
路由器和路由
下图展示了一种最简单的组播配置:一个服务器向四台连接同一路由器的客户端发送相同数据,组播 socket 向客户端的路由器发出一个数据流,这个路由器复制数据流并发送到每个客户端。如果没有组播 socket,服务器就必须向路由器发出四个单独但相同的数据流,路由器再将每个流路由到客户端。
当然,实际的路由可能更为复杂,涉及多层冗余路由器。不过,组播 socket 的目标很简单:不管网络有多复杂,在任何指定网段上,相同的数据绝不应发送多次。幸运的是,我们不需要担心路由问题,只要创建一个 MulticastSocket,让这个 socket 加入组播组,并在要发送的 DatagramPacket 中填充该组播组的地址即可。路由器和 MulticastSocket 类会处理其余的所有工作。
MulticastSocket
public class MulticastSocket extends DatagramSocket
MulticastSocket 是 DatagramSocket 的一个子类,两者的行为十分相似:都将数据放在 DatagramPacket 对象中,然后通过 MulticastSocket 收发数据。下面我们就来看看具体的使用:
1. 构造函数
public MulticastSocket() throws IOException
public MulticastSocket(int port) throws IOException
public MulticastSocket(SocketAddress bindaddr) throws IOException
我们可以选择要监听的端口,或者让操作系统分配一个匿名端口。如果没有足够的权限绑定到端口,或者要绑定的端口已被占用,则无法创建 socket,此时会抛出一个 SocketException 异常。注意,对于操作系统而言,组播 socket 就是数据报 socket,所以 MulticastSocket 不能占用已被 DatagramSocket 占用的端口。
可以向构造函数传入 null 来创建一个未绑定的 socket,之后再用 bind() 方法进行连接。因为有些 socket 选项只能在绑定端口前设置,此时这个构造函数就很有用,如下示例:
MulticastSocket ms = new MulticastSocket(null);
ms.setReuseAddress(false);
SocketAddress address = new InetSocketAddress(4000);
ms.bind(address);
2. 与组播组通信
一旦创建了 MulticastSocket 就可以完成以下四种关键操作:
- 加入组播组
- 向组中成员发送数据
- 接收组中的数据
- 离开组播组
MulticastSocket 为操作 1 和 4 提供了相应的方法,操作 2 和 3 不需要新的方法,其父类 DatagramSocket 中的 send() 和 receive() 方法就足以完成相应操作。注意:必须在加入组之后才能从组接收数据,向组发送数据并不需要先加入组。
2.1 加入组
要加入一个组,可以将组播组的 InetAddress 或 SocketAddress 对象传递给 joinGroup 方法:
public void joinGroup(InetAddress mcastaddr) throws IOException
public void joinGroup(SocketAddress mcastaddr, NetworkInterface netIf) throws IOException
一个 MulticastSocket 可以加入多个组播组,组播组中的成员信息存储在组播路由器中。一旦加入组播组后,接收数据报就与接收 UDP 数据报的步骤完全一样。如果试图加入的地址不是一个组播地址,则该方法会抛出一个 IOException 异常。
第二个参数允许只加入指定本地网络接口上的组播组,如果指定的网络接口不存在,就加入所有可用网络接口上的这个组。
2.2 离开组
当不再希望接收来自指定组播组的数据报时可用调用 leaveGroup 方法离开组播组,该方法可以在所有网络接口上调用,也可以在指定的网络接口上调用:
public void leaveGroup(InetAddress mcastaddr) throws IOException
public void leaveGroup(SocketAddress mcastaddr, NetworkInterface netIf) throws IOException
该方法会通知本地组播路由器,告诉它停止向你发送数据报。如果试图离开的地址不是一个组播地址,则该方法会抛出一个 IOException 异常。不过,如果离开一个从未加入的组播组,则不会产生异常。
2.3 TTL
默认情况下,组播 socket 使用的 TTL 值为 1,即包不会传输到本地子网之外。可通过如下方法进行修改和查看:
public void setTimeToLive(int ttl) throws IOException
public int getTimeToLive() throws IOException
2.4 回送模式
一台主机能否接收它自己发送的组播包,也就是说组播包是否会回送,这取决于具体的平台。可以通过如下方法进行设置:
public void setLoopbackMode(boolean disable) throws SocketException
public boolean getLoopbackMode() throws SocketException
当向 setLoopbackMode 方法传入 true 时表示不希望接收自己发送的包,但这只是一个暗示,并不会要求具体实现确实按照你请求的那样去做,因为并非所有操作系统都采用同样的回送模式。因此在同时收发包时,应当检查回送模式是什么,如果包不回送则 getLoopbackMode() 方法返回 true,回送则返回 false。
如果系统回送包,而你不希望这样,就需要以某种方式识别出这些包并将它们丢弃。如果系统不回送而你希望回送,则要在发送包的同时手动存储你发送的包的副本。虽然我们可以通过 setLoopbackMode () 方法设置我们希望的回送模式,但不要指望一定能如你所愿。
public void setInterface(InetAddress inf) throws SocketException
public InetAddress getInterface() throws SocketException
public void setNetworkInterface(NetworkInterface netIf) throws SocketException
public NetworkInterface getNetworkInterface() throws SocketException
使用示例
客户端:
public void multicastReceive(int port, InetAddress groupAddress) {
try (MulticastSocket ms = new MulticastSocket(port)) {
ms.joinGroup(groupAddress);
byte[] buffer = new byte[1024];
while (true) {
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
ms.receive(packet);
System.out.println("Receive message: " + new String(packet.getData(), "UFT-8"));
}
} catch (IOException e) {
System.err.println(e.getMessage());
}
}
发送端:
public void multicastSender(InetAddress groupAddress, int groupPort) {
byte[] data = "Hello MulticastSocket!".getBytes();
DatagramPacket packet = new DatagramPacket(data, data.length, groupAddress, groupPort);
int ttl = 1;
try (MulticastSocket ms = new MulticastSocket()) {
ms.setTimeToLive(ttl);
ms.joinGroup(groupAddress);
ms.setLoopbackMode(false);
for (int i = 0; i < 10; i++) {
ms.send(packet);
}
ms.leaveGroup(groupAddress);
} catch (IOException e) {
System.err.println(e.getMessage());
}
}