写在前面
在团队内部的hackweek中实现了一个在局域网环境中(同一个wifi下)进行的卡片收发小游戏,踩了一些关于局域网内通信的坑,这篇博文就用来整理一下整个过程的思路,完整代码地址。
实现思路
在整个过程中利用到了UDP与TCP两种传输层协议,两者的特性决定了使用上的不同。
简单地说,UDP非面向连接,不需要先与目标建立连接,所以UDP不提供可靠的数据传输,也不能保证数据准确无误地到达目的地,但UDP的优势在于它可以迅速传送大量信息,传输性能比较好。
而TCP是面向连接的协议,需要经过三次握手与目的地址建立一个稳定的连接,可以保证数据准确、完整地到达。但是它的传输效率就没有UDP那么高。
首先,为了数据传输的稳定和准确性,在传送主要数据部分我们必需使用TCP来建立一个点对点的稳定的连接来传输主要数据。
但是,为了建立一个TCP连接,请求的一方必须要知道被请求一方(下面简称服务方)的IP地址。而在局域网中,如果我们想要实现每个人连接局域网以后马上可以收发信息,由于每次加入时分配到的IP地址并不是固定的也无法提前得知,所以我们需要使用其他的办法先获取到服务方的IP地址。
这时就要利用到UDP协议的组播特性了,组播可以让设备都加入一个预设好的组,然后就可以向这个组中发送数据包,只要加入了这个组的设备都可以收到这个数据包。这样只要所有的设备都提前加入了同一个组,不需要互相知道IP地址就可以交换数据,那么我们应该如何利用这样的特性呢?
结合我们的实际需求,游戏过程是每个人可以向所有人发送一个只有标题的匿名卡片(这个过程就符合UDP组播的特性),如果感兴趣的人就可以点击收到的卡片来打开这个卡片查看具体内容(这个过程就需要我们建立TCP连接来传输数据)。
所以我们就有了思路,向所有人发送卡片的过程使用UDP进行组播,数据包中除了包含标题信息还要包含一个发送人的IP地址以及一个Mac地址作为ID(考虑到重新连接后地址发生改变的问题),当所有人收到这个卡片以后需要建立连接的时候就可以得到发送人的IP来进行TCP连接。
下面我们来实现这个过程。
具体实现
组播
我们定义一个ComUtil
类来处理组播
加入组
1 2 3 4 5 6 7 8 9 10 11 12 13
| public static final String CHARSET = "utf-8"; private static final String BROADCAST_IP = "224.0.1.2"; public static final int BROADCAST_PORT = 7816; private static final int DATA_LEN = 4096; private MulticastSocket socket = null; private InetAddress broadcastAddress = null; byte[] inBuff = new byte[DATA_LEN]; private DatagramPacket inPacket = new DatagramPacket(inBuff, inBuff.length); private DatagramPacket outPacket = null; private Handler handler; public ComUtil(Handler handler) { this.handler = handler; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| public void startReceiveMsg() { try { socket = new MulticastSocket(BROADCAST_PORT); broadcastAddress = InetAddress.getByName(BROADCAST_IP); socket.joinGroup(broadcastAddress); outPacket = new DatagramPacket(new byte[0], 0, broadcastAddress, BROADCAST_PORT); } catch (IOException e) { e.printStackTrace(); } Thread thread = new Thread(new ReadBroad()); thread.start(); }
|
注释应该讲得比较清楚了,这里要注意的是UDP数据的收发需要使用一个DatagramPacket
来进行。可以理解为一个数据包。
接收组播信息
上面的代码最后两行新建了一个线程用于接收组播信息,具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class ReadBroad implements Runnable { public void run() { while (true) { try { socket.receive(inPacket); Message message = new Message(); message.what = BROADCAST_PORT; message.obj = inBuff; handler.sendMessage(message); } catch (IOException e) { e.printStackTrace(); } } } }
|
进行了一个无限循环,进行到第5行时如果没有收到广播的DatagramPacket
会一直处于阻塞状态,收到一个DatagramPacket
后就会通过Handler
来转发出去,在Handler
所在线程来处理这个数据包。之后再进行循环不断地接收并处理数据包。
发送组播信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| public void broadCast(final byte[] msg) { Thread thread = new Thread(new Runnable() { @Override public void run() { try { outPacket.setData(msg); socket.send(outPacket); } catch (IOException ex) { ex.printStackTrace(); if (socket != null) { socket.close(); } } } }); thread.start(); }
|
这个方法由外部调用,传入一个二进制数组数据通过setData()
放在数据包中向组中的所有成员发送。成员通过上一节的接收方法接收到的就会是同样的数据包。
数据处理
建立了组播的工具,下一步就要建立一个数据对象来进行信息的交换。由于数据包中的数据只能是以字节码的形式存在,所以我们设计的数据对象一定要是可序列化的(也就是实现了Serializable
接口的),再通过流工具进行转换。
1 2 3 4 5 6 7
| public class UDPDataPackage implements Serializable { private String ipAddress; private String macAddress; private String title; private String id; ... }
|
在这个简单的JavaBean中只定义了四个简单数据。
我们将自己的信息设置后就可以通过如下方法转换成一个字节数组再通过上面的广播方法来发送:
1
| comUtil.broadCast(ConvertData.objectToByte(new UDPDataPackage(...)));
|
1 2 3 4 5 6 7 8 9 10 11 12
| public static byte[] objectToByte(Object object) { ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); ObjectOutputStream outputStream; try { outputStream = new ObjectOutputStream(byteArrayOutputStream); outputStream.writeObject(object); } catch (IOException e) { e.printStackTrace(); } return byteArrayOutputStream.toByteArray(); }
|
同样的,在接收到数据以后可以反序列化来得到原对象:
1 2 3 4 5 6 7 8 9 10 11
| public static Object byteToObject(byte[] bytes) { ByteArrayInputStream byteInputStream = new ByteArrayInputStream(bytes); Object object = null; try { ObjectInputStream objectInputStream = new ObjectInputStream(byteInputStream); object = objectInputStream.readObject(); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } return object; }
|
这样,我们就可以从这个对象中获取想到的IP地址等信息了。
建立TCP连接传输数据
想要建立TCP连接,需要客户端与服务端两端的配合,我们现在已经获取到了需要建立连接的IP地址,下面我们要做的是与这个地址的服务端建立连接再传输数据。服务端需要一直运行来随时准备接受可能的请求。
由于我们同一个设备既要作为客户端,也要作为服务端,所以要编写两个类。
服务端
1 2 3 4 5
| public void startServer(Handler handler) { this.handler = handler; thread = new Thread(new RunServer()); thread.start(); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| class RunServer implements Runnable { @Override public void run() { ServerSocket serverSocket = null; try { serverSocket = new ServerSocket(); serverSocket.setReuseAddress(true); serverSocket.bind(new InetSocketAddress(SERVER_PORT)); } catch (IOException e) { e.printStackTrace(); } while (true) { try { Socket socket = serverSocket.accept(); InputStream inputStream; inputStream = socket.getInputStream(); ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
UDPDataPackage udpDataPackage = (UDPDataPackage) objectInputStream.readObject(); OutputStream outputStream = socket.getOutputStream(); ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream); objectOutputStream.writeObject(udpDataPackage); objectOutputStream.flush();
objectOutputStream.close(); outputStream.close(); objectInputStream.close(); inputStream.close(); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } } } }
|
解释见注释。
客户端
1 2 3 4 5 6 7
| public void sendRequest(String ipAddress, UDPDataPackage udpDataPackage, Handler handler) { this.ipAddress = ipAddress; this.udpDataPackage = udpDataPackage; this.handler = handler; thread = new Thread(new SendData()); thread.start(); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| class SendData implements Runnable { @Override public void run() { Socket socket = null; try { socket = new Socket(ipAddress, SERVER_PORT); socket.setReuseAddress(true); socket.setKeepAlive(true); socket.setSoTimeout(5000); OutputStream outputStream = socket.getOutputStream(); ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream); objectOutputStream.writeObject(udpDataPackage); InputStream inputStream = socket.getInputStream(); ObjectInputStream objectInputStream = new ObjectInputStream(inputStream); udpDataPackage dataPackage = (udpDataPackage) objectInputStream.readObject(); Message msg = new Message(); msg.what = SERVER_PORT; msg.obj = dataPackage; handler.sendMessage(msg); } catch (SocketTimeoutException e) { try { if (socket != null) socket.close(); } catch (IOException e1) { e1.printStackTrace(); } sendRequest(ipAddress, udpDataPackage, handler); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } } }
|
解释见注释。
可以看见TCP连接还是比较简单的,设置好socket
并获取到输入输出流以后就可以把服务端当作本地流一样操作,具体的网络通信实现过程被隐藏了,有了流以后就可以进行所有能对流进行的操作了。到这里,我们要实现的局域网数据传输已经完成了。