上面程序虽然已经完成了粗略的通信功能,每个客户端可以看到其他客户端发送的信息,但无法知道是哪个客户端发送的信息,这是因为服务器端从未记录过用户信息,当客户端使用Socket
连接到服务器端之后,程序只是使用socketlist
集合保存了服务器端对应生成的Socket
,并没有保存该Socket
关联的客户信息。
下面程序将考虑使用Map
来保存用户状态信息,因为本程序将考虑实现私聊功能,也就是说,一个客户端可以将信息发送给另一个指定客户端。实际上,所有客户端只与服务器端连接,客户端之间并没有互相连接,也就是说,当一个客户端信息发送到服务器端之后,服务器端必须可以判断该信息到底是向所有用户发送,还是向指定用户发送,并需要知道向哪个用户发送。这里需要解决如下两个问题。
- 客户端发送来的信息必须有特殊的标识——让服务器端可以判断是公聊信息,还是私聊信息。
- 如果是私聊信息,客户端会发送该消息的目的用户(私聊对象)给服务器端,服务器端如何将该信息发送给该私聊对象。
协议
为了解决第一个问题,可以让客户端在发送不同信息之前,先对这些信息进行适当处理,比如在内容前后添加一些特殊字符——这种特殊字符被称为协议字符。本例提供了一个CrazyitProtocol
接口,该接口专门用于定义协议字符
协议接口CrazyitProtocol
1 2 3 4 5 6 7 8 9 10 11 12 13
| public interface CrazyitProtocol { int PROTOCOL_LEN = 2; String MSG_ROUND = "§γ"; String USER_ROUND = "∏∑"; String LOGIN_SUCCESS = "1"; String NAME_REP = "-1"; String PRIVATE_ROUND = "★【"; String SPLIT_SIGN = "※"; }
|
实际上,由于服务器端和客户端都需要使用这些协议字符串,所以程序需要在客户端和服务器端同时保留该接口对应的.class
文件。
用户的Socket的映射
为了解决第二个问题,可以考虑使用一个Map
来保存聊天室所有用户和对应Socket
之间的映射关系,这样服务器端就可以根据用户名来找到对应的Socket
。但实际上本程序并未这么做,程序仅仅是用Map
保存了聊天室所有用户名和对应输出流之间的映射关系,因为服务器端只要获取该用户名对应的输岀流即可。服务器端提供了一个HashMap
的子类,该类不允许value
重复,并提供了根据value
获取key
,根据value
删除key
等方法。
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| import java.util.*;
public class CrazyitMap<K, V> { public Map<K, V> map = Collections.synchronizedMap(new HashMap<K, V>());
public synchronized void removeByValue(Object value) { for (Object key : map.keySet()) { if (map.get(key) == value) { map.remove(key); break; } } }
public synchronized Set<V> valueSet() { Set<V> result = new HashSet<V>(); map.forEach((key, value) -> result.add(value)); return result; }
public synchronized K getKeyByValue(V val) { for (K key : map.keySet()) { if (map.get(key) == val || map.get(key).equals(val)) { return key; } } return null; }
public synchronized V put(K key, V value) { for (V val : valueSet()) { if (val.equals(value) && val.hashCode() == value.hashCode()) { throw new RuntimeException("MyMap实例中不允许有重复value!"); } } return map.put(key, value); } }
|
严格来讲,CrazyitMap
已经不是一个标准的Map
结构了,但程序需要这样一个数据结构来保存用户名和对应输岀流之间的映射关系,这样既可以通过用户名找到对应的输出流,也可以根据输出流找到对应的用户名。
服务器端的主类
服务器端的主类一样只是建立ServerSocket
来监听来自客户端Socket
的连接请求,但该程序增加了一些异常处理,可能看上去比上一节的程序稍微复杂一点。
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
| import java.net.*; import java.io.*;
public class Server { private static final int SERVER_PORT = 30000; public static CrazyitMap<String, PrintStream> clients = new CrazyitMap<>();
public void init() { try ( ServerSocket ss = new ServerSocket(SERVER_PORT)) { while (true) { Socket socket = ss.accept(); new ServerThread(socket).start(); } } catch (IOException ex) { System.out.println("服务器启动失败,是否端口" + SERVER_PORT + "已被占用?"); } }
public static void main(String[] args) { Server server = new Server(); server.init(); } }
|
该程序的关键代码只有三行:
1 2 3
| ServerSocket ss = new ServerSocket(SERVER_PORT) Socket socket = ss.accept(); new ServerThread(socket).start();
|
它们依然是完成建立ServerSocket
,监听客户端Socket
连接请求,并为已连接的Socket
启动单独的线程。
服务器端线程类
服务器端线程类比上一节的程序要复杂一点,因为该线程类要分别处理公聊、私聊两类聊天信息除此之外,还需要处理用户名是否重复的问题。服务器端线程类的代码如下
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| import java.net.*; import java.io.*;
public class ServerThread extends Thread { private Socket socket; BufferedReader br = null; PrintStream ps = null;
public ServerThread(Socket socket) { this.socket = socket; }
public void run() { try { br = new BufferedReader(new InputStreamReader(socket.getInputStream())); ps = new PrintStream(socket.getOutputStream()); String line = null; while ((line = br.readLine()) != null) { if (line.startsWith(CrazyitProtocol.USER_ROUND) && line.endsWith(CrazyitProtocol.USER_ROUND)) { String userName = getRealMsg(line); if (Server.clients.map.containsKey(userName)) { System.out.println("重复"); ps.println(CrazyitProtocol.NAME_REP); } else { System.out.println("成功"); ps.println(CrazyitProtocol.LOGIN_SUCCESS); Server.clients.put(userName, ps); } } else if (line.startsWith(CrazyitProtocol.PRIVATE_ROUND) && line.endsWith(CrazyitProtocol.PRIVATE_ROUND)) { String userAndMsg = getRealMsg(line); String user = userAndMsg.split(CrazyitProtocol.SPLIT_SIGN)[0]; String msg = userAndMsg.split(CrazyitProtocol.SPLIT_SIGN)[1]; Server.clients.map.get(user).println(Server.clients.getKeyByValue(ps) + "悄悄地对你说:" + msg); } else { String msg = getRealMsg(line); for (PrintStream clientPs : Server.clients.valueSet()) { clientPs.println(Server.clients.getKeyByValue(ps) + "说:" + msg); } } } } catch (IOException e) { Server.clients.removeByValue(ps); System.out.println(Server.clients.map.size()); try { if (br != null) { br.close(); } if (ps != null) { ps.close(); } if (socket != null) { socket.close(); } } catch (IOException ex) { ex.printStackTrace(); } } }
private String getRealMsg(String line) { return line.substring(CrazyitProtocol.PROTOCOL_LEN, line.length() - CrazyitProtocol.PROTOCOL_LEN); } }
|
上面程序比前一节的程序除增加了异常处理之外,主要增加了对读取数据的判断。程序读取到客户端发送过来的内容之后,会根据该内容前后的协议字符串对该内容进行相应的处理。
客户端
客户端主类
客户端主类增加了让用户输入用户名的代码,并且不允许用户名重复。除此之外,还可以根据用户的键盘输入来判断用户是否想发送私聊信息。客户端主类的代码如下。
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
| import java.net.*; import java.io.*; import javax.swing.*;
public class Client { private static final int SERVER_PORT = 30000; private Socket socket; private PrintStream ps; private BufferedReader brServer; private BufferedReader keyIn;
public void init() { try { keyIn = new BufferedReader(new InputStreamReader(System.in)); socket = new Socket("127.0.0.1", SERVER_PORT); ps = new PrintStream(socket.getOutputStream()); brServer = new BufferedReader(new InputStreamReader(socket.getInputStream())); String tip = ""; while (true) { String userName = JOptionPane.showInputDialog(tip + "输入用户名"); ps.println(CrazyitProtocol.USER_ROUND + userName + CrazyitProtocol.USER_ROUND); String result = brServer.readLine(); if (result.equals(CrazyitProtocol.NAME_REP)) { tip = "用户名重复!请重新"; continue; } if (result.equals(CrazyitProtocol.LOGIN_SUCCESS)) { break; } } } catch (UnknownHostException ex) { System.out.println("找不到远程服务器,请确定服务器已经启动!"); closeRs(); System.exit(1); } catch (IOException ex) { System.out.println("网络异常!请重新登录!"); closeRs(); System.exit(1); } new ClientThread(brServer).start(); }
private void readAndSend() { try { String line = null; while ((line = keyIn.readLine()) != null) { if (line.indexOf(":") > 0 && line.startsWith("//")) { line = line.substring(2); ps.println(CrazyitProtocol.PRIVATE_ROUND + line.split(":")[0] + CrazyitProtocol.SPLIT_SIGN + line.split(":")[1] + CrazyitProtocol.PRIVATE_ROUND); } else { ps.println(CrazyitProtocol.MSG_ROUND + line + CrazyitProtocol.MSG_ROUND); } } } catch (IOException ex) { System.out.println("网络通信异常!请重新登录!"); closeRs(); System.exit(1); } }
private void closeRs() { try { if (keyIn != null) { ps.close(); } if (brServer != null) { ps.close(); } if (ps != null) { ps.close(); } if (socket != null) { keyIn.close(); } } catch (IOException ex) { ex.printStackTrace(); } }
public static void main(String[] args) { Client client = new Client(); client.init(); client.readAndSend(); } }
|
上面程序使用JOptionPane
弹出一个输入对话框让用户输入用户名,如程序init()
方法中的①号粗体字代码所示。然后程序立即将用户输入的用户名发送给服务器端,服务器端会返回该用户名是否重复的提示,程序又立即读取服务器端提示,并根据服务器端提示判断是否需要继续让用户输入用户名。
与前一节的客户端主类程序相比,该程序还增加了对用户输入信息的判断——程序判断用户输入的内容是否以斜线()开头,并包含冒号(:),如果满足该特征,系统认为该用户想发送私聊信息,就会将冒号(:)之前的部分当成私聊用户名,冒号(:)之后的部分当成聊天信息,如readAndSend()
方法中粗体字代码所示。
客户端线程类
本程序中客户端线程类几乎没有太大的改变,仅仅添加了异常处理部分的代码。
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| import java.io.*;
public class ClientThread extends Thread { BufferedReader br = null; public ClientThread(BufferedReader br) { this.br = br; } public void run() { try { String line = null; while((line = br.readLine())!= null) { System.out.println(line);
} } catch (IOException ex) { ex.printStackTrace(); } finally { try { if (br != null) { br.close(); } } catch (IOException ex) { ex.printStackTrace(); } } } }
|
虽然上面程序非常简单,但正如程序注释中所指出的,如果服务器端可以返回更多丰富类型的数据,则该线程类的处理将会更复杂,那么该程序可以扩展到非常强大。
先运行上面的Server
类,启动服务器;再多次运行Client
类启动多个客户端,并输入不同的用户名,登录服务器后的聊天界面如图17.5所示。
本程序没有提供GUI
界面部分,直接使用DOS
窗口进行聊天——因为增加GUI
界面会让程序代码更多,从而引起读者的畏难心理。如果读者理解了本程序之后,相信读者一定乐意为该程序添加界面部分,因为整个程序的所有核心功能都已经实现了。不仅如此,读者完全可以在本程序的基础上扩展成一个仿QQ
游戏大厅的网络程序。