下面将基于TCP协议用JAVA写一个非常简单的聊天室程序, 聊天室具有以下功能,
- 在服务器端,可以接受客户端注册(用户名),可以显示注册成功的账户
- 在客户端,可以注册一个账号,并用这个账号发送信息
- 发送信息有两种模式,一种是群聊,所有在线用户都可以看到消息,另一种是私聊,只针对指定账户发送消息
下面是主要的实现思路,
- 首先是服务器端, 需要使用多线程实现。 主线程用来循环监听客户端的连接请求, 一旦接收到一个请求,就为这个客户端创建一个专用通信线程。
- 服务器端依靠一个经过重写的map保存在线的客户端账户以及建立连接后的通信句柄(inputStream/outputStream)
- 服务器端和客户端通信使用约定好的自定义协议,将在双方发送的消息中添加固定消息头和消息尾。 通信双发都使用socket的inputStream和outStream读和写消息,与本地IO区别不大。
- 当服务器端接收到客户端的请求的时候,先解析出消息头和尾,根据约定的通信协议来判断消息类型,是注册账户,还是群发消息,还是私聊消息
- 对于群发消息,服务器端将遍历在线的所有用户(线程),然后将消息广播出去
- 对于私聊消息,服务器端根据客户端发来的目的地址(收信账户),去map中查找到通信线程句柄(outputStream),然后将信息发送给指定账户
- 对于每个客户端,都创建两个线程。 主线程用来做键盘输入, 辅线程用来接收服务器发回的消息
- 客户端的主线程中,所有消息都是先发送到服务器端,再由服务器端决定分发策略。
- 包括注册账户在内,服务器和客户端双方所有消息都是经过约定协议包装过的,这样服务器才能读取消息的属性,进行指定操作。
服务器端实现如下,
首先我们要自定义一个通信协议,服务器端和客户端需要使用同一种协议,用来描述消息的属性,
1 package chat; 2 3 public interface ChatProtocol { 4 int PROTOCOL_LEN = 2; 5 6 //协议字符串,会加入数据包中 7 String MSG_ROND = "##"; 8 String USER_ROND = "@@"; 9 String LOGIN_SUCCESS = "1"; 10 String NAME_REP = "-1"; 11 String PRIVATE_ROND = "%%"; 12 String SPLIT_SIGN = "}"; 13 14 }
服务器端的监听线程(主线程)
1 package chat; 2 3 import java.io.IOException; 4 import java.io.PrintStream; 5 import java.net.ServerSocket; 6 import java.net.Socket; 7 8 public class Server { 9 private static final int SERVER_PORT = 33000; 10 public static ChatMap<String, PrintStream> clients = new ChatMap(); 11 public void init() { 12 try { 13 ServerSocket ss = new ServerSocket(SERVER_PORT) ; 14 while(true) { 15 Socket socket = ss.accept(); 16 new ServerThread(socket).start(); 17 } 18 } catch (IOException e) { 19 e.printStackTrace(); 20 } 21 } 22 public static void main(String[] args) { 23 Server server = new Server(); 24 server.init(); 25 } 26 }
服务器端需要使用一个重写的map来存放用户名和对应的通信句柄, 这样才能实现私聊功能,
1 package chat; 2 3 import java.util.Collections; 4 import java.util.HashMap; 5 import java.util.HashSet; 6 import java.util.Map; 7 import java.util.Set; 8 9 //用map来保存用户和socket输出流的对应关系, 10 //K将会是String类型的用户名,不允许重复 11 //V是从socket返回的outputStream对象,也不允许重复 12 public class ChatMap<K,V> { 13 public Map<K,V> map = Collections.synchronizedMap(new HashMap<K,V>()); 14 15 //根据outputStream对象删除制定项 16 public synchronized void removeByValue(Object value) { 17 for (Object key : map.keySet()) { 18 if(map.get(key) == value) { 19 map.remove(key); 20 break; 21 } 22 } 23 } 24 25 //获取outputStream对象组成的Set 26 public synchronized Set<V> valueSet() { 27 Set<V> result = new HashSet<V>(); 28 //遍历map,将map的value存入Set 29 for(K key : map.keySet()) { 30 result.add(map.get(key)); 31 } 32 /* 33 for (Map.Entry<K, V> entry : map.entrySet()) { 34 result.add(entry.getValue()); 35 } 36 */ 37 return result; 38 } 39 40 //根据ouputStream对象查找用户名 41 public synchronized K getKeyByValue(V val) { 42 for(K key : map.keySet()) { 43 if (map.get(key) == val || map.get(key).equals(val)) { 44 return key; 45 } 46 } 47 return null; 48 } 49 50 //实现put,key和value都不允许重复 51 public synchronized V put(K key, V value) { 52 for (V val : valueSet() ) { 53 if (val.equals(value) && val.hashCode() == value.hashCode()) { 54 throw new RuntimeException("此输入流已经被使用"); 55 } 56 } 57 return map.put(key, value); 58 } 59 }
对每一个客户端请求创建一个通信子线程
1 package chat; 2 3 import java.io.BufferedReader; 4 import java.io.IOException; 5 import java.io.InputStreamReader; 6 import java.io.PrintStream; 7 import java.net.Socket; 8 9 public class ServerThread extends Thread{ 10 private Socket socket; 11 BufferedReader br = null; 12 PrintStream ps = null; 13 14 public ServerThread(Socket socket) { 15 this.socket = socket; 16 } 17 18 public void run() { 19 try { 20 br = new BufferedReader(new InputStreamReader(socket.getInputStream())); 21 //一个客户端的输出流对象 22 ps = new PrintStream(socket.getOutputStream()); 23 String line = null; 24 while((line = br.readLine()) != null) { 25 //如果消息以ChatProtocol.USER_ROND开始,并以其结束 26 //则可以确定读到的是用户登录的用户名 27 if(line.startsWith(ChatProtocol.USER_ROND) && 28 line.endsWith(ChatProtocol.USER_ROND)) { 29 String userName = getRealMsg(line); 30 //用户名不允许重复 31 if(Server.clients.map.containsKey(userName)) { 32 System.out.println("用户名重复"); 33 ps.println(ChatProtocol.NAME_REP); 34 } else { 35 System.out.println("["+userName+"] 注册成功,你可以开始聊天了!"); 36 ps.println(ChatProtocol.LOGIN_SUCCESS); 37 //将用户名和输出流对象组成的键值关联对存入前面经过改造的map 38 Server.clients.map.put(userName, ps); 39 } 40 } //如果消息以ChatProtocol.PRIVATE_ROND开头并以ChatProtocol.PRIVATE_ROND结尾 41 //则可以确定是私聊信息 42 else if (line.startsWith(ChatProtocol.PRIVATE_ROND ) && 43 line.endsWith(ChatProtocol.PRIVATE_ROND)) { 44 String userAndMsg = getRealMsg(line); 45 46 //以SPILT_SIGN分割字符串,前半是用户名,后半是聊天信息 47 String user = userAndMsg.split(ChatProtocol.SPLIT_SIGN)[0]; 48 String msg = userAndMsg.split(ChatProtocol.SPLIT_SIGN)[1]; 49 //根据用户名在map中找出输出流对象,进行私聊信息发送 50 Server.clients.map.get(user).println("[私聊信息] [来自 "+Server.clients.getKeyByValue(ps)+"] : " + msg); 51 52 } 53 // 群聊信息,广播消息 54 else { 55 String msg = getRealMsg(line); 56 for(PrintStream clientPs : Server.clients.valueSet()) { 57 clientPs.println("[群发信息] [来自 "+Server.clients.getKeyByValue(ps)+"] : " + msg); 58 } 59 } 60 } 61 } catch (IOException e) { 62 //e.printStackTrace(); 63 Server.clients.removeByValue(ps); 64 System.out.println(Server.clients.map.size()); 65 try { 66 if (br != null) { 67 br.close(); 68 } 69 70 if (ps != null) { 71 ps.close(); 72 } 73 74 if (socket != null) { 75 socket.close(); 76 } 77 } catch (IOException ex) { 78 ex.printStackTrace(); 79 } 80 } 81 } 82 83 private String getRealMsg(String line) { 84 return line.substring(ChatProtocol.PROTOCOL_LEN, line.length() - ChatProtocol.PROTOCOL_LEN); 85 } 86 87 88 }
下面开始写客户端, 客户端和服务器端是两个完全独立的应用, 可以新建工程写一个客户端,
为了简单起见,我将服务器端和客户端放在了同一个工程的同一个包下, 这样可以共享一下协议接口 ChatProtocol.java
首先是客户端主程序,用来完成键盘输入操作, 其中注册用户名的地方调用了一点点java的gui编程接口swi,弹出对话框输入用户名,
1 package chat; 2 3 import java.io.BufferedReader; 4 import java.io.IOException; 5 import java.io.InputStreamReader; 6 import java.io.PrintStream; 7 import java.net.Socket; 8 9 import javax.swing.JOptionPane; 10 11 public class Client { 12 private static final int SERVER_PORT = 33000; 13 private Socket socket; 14 private PrintStream ps; 15 private BufferedReader brServer; 16 private BufferedReader keyIn; 17 18 public void init() { 19 try { 20 keyIn = new BufferedReader(new InputStreamReader(System.in)); 21 socket = new Socket("127.0.0.1", SERVER_PORT); 22 ps = new PrintStream(socket.getOutputStream()); 23 brServer = new BufferedReader(new InputStreamReader(socket.getInputStream())); 24 String tip = ""; 25 while(true) { 26 String userName = JOptionPane.showInputDialog(tip + "输入用户名"); 27 ps.println(ChatProtocol.USER_ROND + userName + ChatProtocol.USER_ROND); 28 29 //服务器端响应 30 String result = brServer.readLine(); 31 if(result.equals(ChatProtocol.NAME_REP)) { 32 tip = "用户名重复,请重新输入"; 33 continue; 34 } 35 //登录成功 36 if(result.equals(ChatProtocol.LOGIN_SUCCESS)) { 37 System.out.println("登录成功,账号: ["+ userName +"]"); 38 break; 39 } 40 } 41 } catch (IOException ex) { 42 ex.printStackTrace(); 43 } 44 45 new ClientThread(brServer).start(); 46 } 47 48 private void readAndSend() { 49 try { 50 String line = null; 51 while((line = keyIn.readLine()) != null) { 52 //如果发送的消息中带有冒号,且以//开头,则认为是私聊信息 53 if(line.indexOf(":") > 0 && line.startsWith("//")) { 54 line = line.substring(2); 55 ps.println(ChatProtocol.PRIVATE_ROND 56 + line.split(":")[0] 57 + ChatProtocol.SPLIT_SIGN 58 + line.split(":")[1] 59 + ChatProtocol.PRIVATE_ROND); 60 } else { 61 ps.println(ChatProtocol.MSG_ROND + line + ChatProtocol.MSG_ROND); 62 } 63 } 64 } catch (IOException ex) { 65 ex.printStackTrace(); 66 } 67 } 68 69 private void closeRs() { 70 try { 71 if (keyIn != null) { 72 keyIn.close(); 73 } 74 75 if (brServer != null) { 76 brServer.close(); 77 } 78 79 if (ps != null) { 80 ps.close(); 81 } 82 83 if (socket != null) { 84 socket.close(); 85 } 86 } catch (IOException ex) { 87 ex.printStackTrace(); 88 } 89 } 90 91 public static void main(String[] args) { 92 Client client = new Client(); 93 client.init(); 94 client.readAndSend(); 95 } 96 }
键盘操作中,区分群发消息和私聊消息是看消息以什么开头,以//开头就是私聊,否则是群发,
私聊时,用冒号隔开收信人和消息内容, 一条私聊消息格式是这样的 //b:hi, i'm a
下面是客户端的子线程,专门用来回显服务器端发回来的消息,
1 package chat; 2 3 import java.io.BufferedReader; 4 import java.io.IOException; 5 6 public class ClientThread extends Thread { 7 8 BufferedReader br = null; 9 10 public ClientThread(BufferedReader brServer) { 11 this.br = brServer; 12 } 13 14 public void run() { 15 try { 16 String line = null; 17 while((line = br.readLine()) != null) { 18 System.out.println(line); 19 } 20 } catch (IOException ex) { 21 ex.printStackTrace(); 22 } finally { 23 try { 24 if (br != null) { 25 br.close(); 26 } 27 } catch (IOException e) { 28 e.printStackTrace(); 29 } 30 } 31 } 32 33 }
下面是执行结果, 先启动一个Server端的进程,可以看到启动之后Server端处于监听阻塞状态,
接着分别启动两个Client端进程,每次启动Client进程的时候都会要求输入用户名,要保证用户名不能重复,
接着就可以发送消息了,普通消息将会发送给所有人,即群发, 指定格式的消息将是私聊,例如 //bbb:hi I'm aaa (这是发给账户bbb)的私人消息。