下面將基於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)的私人消息。