http://book.51cto.com/art/200809/89225.htm
17.4.3 使用MulticastSocket实现多点广播
DatagramSocket只允许数据报发送给指定的目标地址,而MulticastSocket可以将数据报以广播方式发送到数量不等的多个客户端。
若要使用多点广播时,则需要让一个数据报标有一组目标主机地址,当数据报发出后,整个组的所有主机都能收到该数据报。IP多点广播(或多点发送)实现了将单一信息发送到多个接收者的广播,其思想是设置一组特殊网络地址作为多点广播地址,每一个多点广播地址都被看做一个组,当客户端需要发送、接收广播信息时,加入到该组即可。
IP协议为多点广播提供了这批特殊的IP地址,这些IP地址的范围是224.0.0.0至239.255.255.255。多点广播的示意图如图17.7所示:
从图17.7中可以看出,通过Java实现多点广播时,MulticastSocket类是实现这一功能的关键,当MulticastSocket把一个DatagramPacket发送到多点广播IP地址,该数据报将被自动广播到加入该地址的所有MulticastSocket。MulticastSocket类既可以将数据报发送到多点广播地址,也可以接收其他主机的广播信息。
MulticastSocket有点像DatagramSocket,事实上MulticastSocket是DatagramSocket的一个子类,也就是说MulticastSocket是特殊的DatagramSocket。若要发送一个数据报时,可使用随机端口创建MulticastSocket,也可以在指定端口来创建MulticastSocket。
MulticastSocket提供了如下三个构造器:
public MulticastSocket():使用本机默认地址、随机端口来创建一个MulticastSocket对象。
public MulticastSocket(int portNumber):使用本机默认地址、指定端口来创建一个MulticastSocket对象。
public MulticastSocket(SocketAddress bindaddr):使用本机指定IP地址、指定端口来创建一个MulticastSocket对象。
创建一个MulticastSocket对象后,还需要将该MulticastSocket加入到指定的多点广播地址,MulticastSocket使用jionGroup()方法来加入指定组;使用leaveGroup()方法脱离一个组。
joinGroup(InetAddress multicastAddr):将该MulticastSocket加入指定的多点广播地址。
leaveGroup(InetAddress multicastAddr):让该MulticastSocket离开指定的多点广播地址。
在某些系统中,可能有多个网络接口。这可能会对多点广播带来问题,这时候程序需要在一个指定的网络接口上监听,通过调用setInterface可选择MulticastSocket所使用的网络接口;也可以使用getInterface方法查询MulticastSocket监听的网络接口。
如果创建仅用于发送数据报的MulticastSocket对象,则使用默认地址、随机端口即可。但如果创建接收用的MulticastSocket对象,则该MulticastSocket对象必须具有指定端口,否则发送方无法确定发送数据报的目标端口。
MulticastSocket用于发送、接收数据报的方法与DatagramSocket的完全一样。但MulticastSocket比DatagramSocket多一个setTimeToLive(int ttl)方法,该ttl参数设置数据报最多可以跨过多少个网络,当ttl为0时,指定数据报应停留在本地主机;当ttl的值为1时,指定数据报发送到本地局域网;当ttl的值为32时,意味着只能发送到本站点的网络上;当ttl为64时,意味着数据报应保留在本地区;当ttl的值为128时,意味着数据报应保留在本大洲;当ttl为255时,意味着数据报可发送到所有地方;默认情况下,该ttl的值为1。
从图17.7中可以看出,使用MulticastSocket进行多点广播时所有通信实体都是平等的,它们都将自己的数据报发送到多点广播IP地址,并使用MulticastSocket接收其他人发送的广播数据报。下面程序使用MulticastSocket实现了一个基于广播的多人聊天室,程序只需要一个MulticastSocket,两条线程,其中MulticastSocket既用于发送,也用于接收,其中一条线程分别负责接受用户键盘输入,并向MulticastSocket发送数据,另一条线程则负责从MulticastSocket中读取数据。
程序清单:codes/17/17-4/MulticastSocketTest.java
//让该类实现Runnable接口,该类的实例可作为线程的target public class MulticastSocketTest implements Runnable { //使用常量作为本程序的多点广播IP地址 private static final String BROADCAST_IP = "230.0.0.1"; //使用常量作为本程序的多点广播目的的端口 public static final int BROADCAST_PORT = 30000; //定义每个数据报的最大大小为4K private static final int DATA_LEN = 4096; //定义本程序的MulticastSocket实例 private MulticastSocket socket = null; private InetAddress broadcastAddress = null; private Scanner scan = null; //定义接收网络数据的字节数组 byte[] inBuff = new byte[DATA_LEN]; //以指定字节数组创建准备接受数据的DatagramPacket对象 private DatagramPacket inPacket = new DatagramPacket(inBuff , inBuff.length); //定义一个用于发送的DatagramPacket对象 private DatagramPacket outPacket = null; public void init()throws IOException { try { //创建用于发送、接收数据的MulticastSocket对象 //因为该MulticastSocket对象需要接收,所以有指定端口 socket = new MulticastSocket(BROADCAST_PORT); broadcastAddress = InetAddress.getByName(BROADCAST_IP); //将该socket加入指定的多点广播地址 socket.joinGroup(broadcastAddress); //设置本MulticastSocket发送的数据报被回送到自身 socket.setLoopbackMode(false); //初始化发送用的DatagramSocket,它包含一个长度为0的字节数组 outPacket = new DatagramPacket(new byte[0] , 0 , broadcastAddress , BROADCAST_PORT); //启动以本实例的run()方法作为线程体的线程 new Thread(this).start(); //创建键盘输入流 scan = new Scanner(System.in); //不断读取键盘输入 while(scan.hasNextLine()) { //将键盘输入的一行字符串转换字节数组 byte[] buff = scan.nextLine().getBytes(); //设置发送用的DatagramPacket里的字节数据 outPacket.setData(buff); //发送数据报 socket.send(outPacket); } } finally { socket.close(); } } public void run() { try { while(true) { //读取Socket中的数据,读到的数据放在inPacket所封装的字节数组里。 socket.receive(inPacket); //打印输出从socket中读取的内容 System.out.println("聊天信息:" + new String(inBuff , 0 , inPacket.getLength())); } } //捕捉异常 catch (IOException ex) { ex.printStackTrace(); try { if (socket != null) { //让该Socket离开该多点IP广播地址 socket.leaveGroup(broadcastAddress); //关闭该Socket对象 socket.close(); } System.exit(1); } catch (IOException e) { e.printStackTrace(); } } } public static void main(String[] args) throws IOException { new MulticastSocketTest().init(); } } |
上面程序中init()方法里的前三行粗体字代码先创建了一个MulticastSocket对象,由于需要使用该对象接收数据报,所以为该Socket对象设置使用固定端口。第二行粗体字代码将该Socket对象添加到指定的多点广播IP地址,第三行粗体字代码设置该Socket发送的数据报会被回送到自身(即该Socket可以接受到自己发送的数据报)。至于程序中使用MulticastSocket发送、接收数据报的代码与使用DatagramSocket并没有区别,故此处不再赘述。
下面将结合MulticastSocket和DatagramSocket开发一个简单的局域网内的即时通信工具,局域网内每个用户启动该工具后,就可以看到该局域网内所有在线用户,他也会被其他用户看到。即看到如图17.8所示的窗口:
在图17.8的用户列表中双击任意一个用户,即可启动一个如图17.9所示的交谈窗口:
如果双击图17.8所示用户列表窗口中“所有人”列表项,即可启动一个与图17.9相似的交谈窗口,不同的是通过该窗口发送的消息将会被所有人看到。
该程序的实现思路是每个用户都启动2个Socket,一个MulticastSocket,一个DatagramSocket。
其中MulticastSocket会周期性地向230.0.0.1发送在线信息,且所有用户的MulticastSocket都会加入到230.0.0.1这个多点广播IP中,这样每个用户都可以收到其他用户广播的在线信息,如果系统经过一段时间没有收到某个用户广播的在线信息,则从用户列表中删除该用户。除此之外,该MulticastSocket还用于向所有用户发送广播信息。
DatagramSocket主要用于发送私聊信息,当用户收到其他用户广播来的DatagramPacket时,即可获取该用户MulticastSocket对应的SocketAddress,这个SocketAddress将作为发送私聊信息的重要依据:本程序让MulticastSocket在30000端口监听,而DatagramSocket在30001端口监听,这样程序就可以根据其他用户广播来的DatagramPacket得到他的DatagramSocket所在的地址。
本系统提供了一个UserInfo类,该类封装了用户名、图标、对应的SocketAddress以及该用户对应的交谈窗口、失去联系的次数等信息,该类的代码片段如下:
程序清单:codes/17/17-4/LanTalk/UserInfo.java
public class UserInfo { //该用户的图标 private String icon; //该用户的名字 private String name; //该用户的MulitcastSocket所在的IP和端口 private SocketAddress address; //该用户失去联系的次数 private int lost; //该用户对应的交谈窗口 private ChatFrame chatFrame; public UserInfo(){} //有参数的构造器 public UserInfo(String icon , String name , SocketAddress address , int lost) { this.icon = icon; this.name = name; this.address = address; this.lost = lost; } //此处省略了该类所有属性的setter和getter方法 ... //使用address作为该用户的标识,所以根据address作为 //重写hashCode()和equals方法的标准 public int hashCode() { return address.hashCode(); } public boolean equals(Object obj) { if (obj != null && obj.getClass()==UserInfo.class) { return ((UserInfo)obj).getAddress().equals(address); } return false; } } |
通过该UserInfo类的封装,这样所有客户端只需要维护该UserInfo类的列表,程序就可以实现广播、发送私聊信息等功能。本程序底层通信的工具类则需要一个MulticastSocket和一个DatagramSocket,该工具类的代码如下:
程序清单:codes/17/17-4/LanTalk/ComUtil.java
//聊天交换信息的工具类 public class ComUtil { //使用常量作为本程序的多点广播IP地址 private static final String BROADCAST_IP = "230.0.0.1"; //使用常量作为本程序的多点广播目的的端口 //DatagramSocket所用的的端口为该端口-1。 public static final int BROADCAST_PORT = 30000; //定义每个数据报的最大大小为4K private static final int DATA_LEN = 4096; //定义本程序的MulticastSocket实例 private MulticastSocket socket = null; //定义本程序私聊的Socket实例 private DatagramSocket singleSocket = null; //定义广播的IP地址 private InetAddress broadcastAddress = null; //定义接收网络数据的字节数组 byte[] inBuff = new byte[DATA_LEN]; //以指定字节数组创建准备接受数据的DatagramPacket对象 private DatagramPacket inPacket = new DatagramPacket(inBuff , inBuff.length); //定义一个用于发送的DatagramPacket对象 private DatagramPacket outPacket = null; //聊天的主界面 private LanChat lanTalk; //构造器,初始化资源 public ComUtil(LanChat lanTalk)throws IOException , InterruptedException { this.lanTalk = lanTalk; //创建用于发送、接收数据的MulticastSocket对象 //因为该MulticastSocket对象需要接收,所以有指定端口 socket = new MulticastSocket(BROADCAST_PORT); //创建私聊用的DatagramSocket对象 singleSocket = new DatagramSocket(BROADCAST_PORT + 1); broadcastAddress = InetAddress.getByName(BROADCAST_IP); //将该socket加入指定的多点广播地址 socket.joinGroup(broadcastAddress); //设置本MulticastSocket发送的数据报被回送到自身 socket.setLoopbackMode(false); //初始化发送用的DatagramSocket,它包含一个长度为0的字节数组 outPacket = new DatagramPacket(new byte[0] , 0 , broadcastAddress , BROADCAST_PORT); //启动两个读取网络数据的线程 new ReadBroad().start(); Thread.sleep(1); new ReadSingle().start(); } //广播消息的工具方法 public void broadCast(String msg) { try { //将msg字符串转换字节数组 byte[] buff = msg.getBytes(); //设置发送用的DatagramPacket里的字节数据 outPacket.setData(buff); //发送数据报 socket.send(outPacket); } //捕捉异常 catch (IOException ex) { ex.printStackTrace(); if (socket != null) { //关闭该Socket对象 socket.close(); } JOptionPane.showMessageDialog(null, "发送信息异常,请确认30000端口空闲,且网络连接正常!" , "网络异常", JOptionPane.ERROR_MESSAGE); System.exit(1); } } //定义向单独用户发送消息的方法 public void sendSingle(String msg , SocketAddress dest) { try { //将msg字符串转换字节数组 byte[] buff = msg.getBytes(); DatagramPacket packet = new DatagramPacket( buff , buff.length , dest); singleSocket.send(packet); } //捕捉异常 catch (IOException ex) { ex.printStackTrace(); if (singleSocket != null) { //关闭该Socket对象 singleSocket.close(); } JOptionPane.showMessageDialog(null, "发送信息异常,请确认30001端口空闲,且网络连接正常!" , "网络异常", JOptionPane.ERROR_MESSAGE); System.exit(1); } } //不断从DatagramSocket中读取数据的线程 class ReadSingle extends Thread { //定义接收网络数据的字节数组 byte[] singleBuff = new byte[DATA_LEN]; private DatagramPacket singlePacket = new DatagramPacket(singleBuff , singleBuff.length); public void run() { while (true) { try { //读取Socket中的数据,读到的数据放在inPacket所封装的字节数组里。 singleSocket.receive(singlePacket); //处理读到的信息 lanTalk.processMsg(singlePacket , true); } //捕捉异常 catch (IOException ex) { ex.printStackTrace(); if (singleSocket != null) { //关闭该Socket对象 singleSocket.close(); } JOptionPane.showMessageDialog(null, "接收信息异常,请确认30001端口空闲,且网络连接正常!" , "网络异常", JOptionPane.ERROR_MESSAGE); System.exit(1); } } } } //持续读取MulticastSocket的线程 class ReadBroad extends Thread { public void run() { while (true) { try { //读取Socket中的数据,读到的数据放在inPacket所封装的字节数组里。 socket.receive(inPacket); //打印输出从socket中读取的内容 String msg = new String(inBuff , 0 , inPacket.getLength()); //读到的内容是在线信息 if (msg.startsWith(YeekuProtocol.PRESENCE) && msg.endsWith(YeekuProtocol.PRESENCE)) { String userMsg = msg.substring(2 , msg.length() - 2); String[] userInfo = userMsg.split(YeekuProtocol.SPLITTER); UserInfo user = new UserInfo(userInfo[1] , userInfo[0] , inPacket.getSocketAddress(), 0); //控制是否需要添加该用户的旗标 boolean addFlag = true; ArrayList<Integer> delList = new ArrayList<Integer>(); //遍历系统中已有的所有用户,该循环必须循环完成 for (int i = 1 ; i < lanTalk.getUserNum() ; i++ ) { UserInfo current = lanTalk.getUser(i); //将所有用户失去联系的次数加1 current.setLost(current.getLost() + 1); //如果该信息由指定用户发送过来 if (current.equals(user)) { current.setLost(0); //设置该用户无须添加 addFlag = false; } if (current.getLost() > 2) { delList.add(i); } } //删除delList中的所有索引对应的用户 for (int i = 0; i < delList.size() ; i++) { lanTalk.removeUser(delList.get(i)); } if (addFlag) { //添加新用户 lanTalk.addUser(user); } } //读到的内容是公聊信息 else { //处理读到的信息 lanTalk.processMsg(inPacket , false); } } //捕捉异常 catch (IOException ex) { ex.printStackTrace(); if (socket != null) { //关闭该Socket对象 socket.close(); } JOptionPane.showMessageDialog(null, "接收信息异常,请确认30000端口空闲,且网络连接正常!" , "网络异常", JOptionPane.ERROR_MESSAGE); System.exit(1); } } } } } |
该类主要实现底层的网络通信功能,在该类中提供了一个broadCast方法,该方法使用MulticastSocket将指定字符串广播到所有客户端,还提供了sendSingle方法,该方法使用DatagramSocket将指定字符串发送到指定SocketAddress,如程序中前两行粗体字代码所示。除此之外,该类里还提供了2个内部线程类:ReadSingle和ReadBroad,这两个线程类采用循环不断读取DatagramSocket和MulticastSocket中的数据,如果读到的信息是广播来的在线信息,则保持该用户在线;如果读到的是用户的聊天信息,则直接将该信息显示出来。
在该类中用到了本程序的一个主类:LanChat,该类使用DefaultListModel来维护用户列表,该类里的每个列表项就是一个UserInfo。该类还提供了一个ImageCellRenderer,该类用于将列表项绘制出用户图标和用户名字。
程序清单:codes/17/17-4/LanChat/LanChat.java
public class LanChat extends JFrame { private DefaultListModel listModel = new DefaultListModel(); //定义一个JList对象 private JList friendsList = new JList(listModel); //定义一个用于格式化日期的格式器 private DateFormat formatter = DateFormat.getDateTimeInstance(); public LanChat() { super("局域网聊天"); //设置该JList使用ImageCellRenderer作为单元格绘制器 friendsList.setCellRenderer(new ImageCellRenderer()); listModel.addElement(new UserInfo("all" , "所有人" , null , -2000)); friendsList.addMouseListener(new ChangeMusicListener()); add(new JScrollPane(friendsList)); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setBounds(2, 2, 160 , 600); } //向用户列表中添加用户 public void addUser(UserInfo user) { listModel.addElement(user); } //从用户列表中删除用户 public void removeUser(int pos) { listModel.removeElementAt(pos); } //根据地址来查询用户 public UserInfo getUserBySocketAddress(SocketAddress address) { for (int i = 1 ; i < getUserNum() ; i++) { UserInfo user = getUser(i); if (user.getAddress() != null && user.getAddress().equals(address)) { return user; } } return null; } //—————————下面两个方法是对ListModel的包装————————— //获取该聊天窗口的用户数量 public int getUserNum() { return listModel.size(); } //获取指定位置的用户 public UserInfo getUser(int pos) { return (UserInfo)listModel.elementAt(pos); } //实现JList上的鼠标双击监听器 class ChangeMusicListener extends MouseAdapter { public void mouseClicked(MouseEvent e) { //如果鼠标的击键次数大于2 if (e.getClickCount() >= 2) { //取出鼠标双击时选中的列表项 UserInfo user = (UserInfo)friendsList.getSelectedValue(); //如果该列表项对应用户的交谈窗口为null if (user.getChatFrame() == null) { //为该用户创建一个交谈窗口,并让该用户引用该窗口 user.setChatFrame(new ChatFrame(null , user)); } //如果该用户的窗口没有显示,则让该用户的窗口显示出来 if (!user.getChatFrame().isShowing()) { user.getChatFrame().setVisible(true); } } } } /** * 处理网络数据报,该方法将根据聊天信息得到聊天者, * 并将信息显示在聊天对话框中。 * @param packet 需要处理的数据报 * @param single 该信息是否为私聊信息 */ public void processMsg(DatagramPacket packet , boolean single) { //获取该发送该数据报的SocketAddress InetSocketAddress srcAddress = (InetSocketAddress)packet.getSocket Address(); //如果是私聊信息,则该Packet获取的是DatagramSocket的地址,将端口减1才是 //对应的MulticastSocket的地址 if (single) { srcAddress = new InetSocketAddress(srcAddress.getHostName(), srcAddress.getPort() - 1); } UserInfo srcUser = getUserBySocketAddress(srcAddress); if (srcUser != null) { //确定消息将要显示到哪个用户对应窗口上。 UserInfo alertUser = single ? srcUser : getUser(0); //如果该用户对应的窗口为空,显示该窗口 if (alertUser.getChatFrame() == null) { alertUser.setChatFrame(new ChatFrame(null , alertUser)); } //定义添加的提示信息 String tipMsg = single ? "对您说:" : "对大家说:"; //显示提示信息 alertUser.getChatFrame().addString(srcUser.getName() + tipMsg + "......................(" + formatter.format(new Date()) + ")\n" + new String(packet.getData() , 0 , packet.getLength()) + "\n"); if (!alertUser.getChatFrame().isShowing()) { alertUser.getChatFrame().setVisible(true); } } } //主方法,程序的入口 public static void main(String[] args) { LanChat lc = new LanChat(); new LoginFrame(lc , "请输入用户名、头像后登录"); } } //定义用于改变JList列表项外观的类 class ImageCellRenderer extends JPanel implements ListCellRenderer { private ImageIcon icon; private String name; //定义绘制单元格时的背景色 private Color background; //定义绘制单元格时的前景色 private Color foreground; public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { UserInfo userInfo = (UserInfo)value; icon = new ImageIcon("ico/" + userInfo.getIcon() + ".gif"); name = userInfo.getName(); background = isSelected ? list.getSelectionBackground() : list.getBack ground(); foreground = isSelected ? list.getSelectionForeground() : list. getForeground(); //返回该JPanel对象作为单元格绘制器 return this; } //重写paintComponent方法,改变JPanel的外观 public void paintComponent(Graphics g) { int imageWidth = icon.getImage().getWidth(null); int imageHeight = icon.getImage().getHeight(null); g.setColor(background); g.fillRect(0, 0, getWidth(), getHeight()); g.setColor(foreground); //绘制好友图标 g.drawImage(icon.getImage() , getWidth() / 2 - imageWidth / 2 , 10 , null); g.setFont(new Font("SansSerif" , Font.BOLD , 18)); //绘制好友用户名 g.drawString(name, getWidth() / 2 - name.length() * 10 , imageHeight + 30 ); } //通过该方法来设置该ImageCellRenderer的最佳大小 public Dimension getPreferredSize() { return new Dimension(60, 80); } } |
上面类中提供的addUser和removeUser方法用于暴露给通信类ComUtil使用,用于向用户列表中添加、删除用户。除此之外,该类还提供了一个processMsg方法,该方法用于处理网络中读取的数据报,将数据报中的内容取出,并显示在特定的窗口中。
上面讲解的只是本程序的关键类,本程序还涉及YeekuProtocol、ChatFrame、LoginFrame等类,由于篇幅关系,此处不再给出这些类的源代码,读者可以参考codes/17/17-4/LanTalk路径下的源代码。