先談談咱們要實現的效果:客戶端能夠選擇要聊天的對象,或者直接廣播消息(相似QQ的私聊和羣消息)編程
那麼,該如何實現呢?服務器
首先明確的是,要分客戶端和服務器端兩個部分(廢話)多線程
客戶端:選擇要發送的對象,發送信息。同時有一個線程在監聽是否收到新的信息。tcp
服務器端:負責轉發收到的消息,並負責管理全部接入的鏈接ide
好了有了大致思路後,開始編程吧~函數
客戶端要提供的信息主要是發送對象、發送信息內容,故設計以下:優化
其中用戶名必須提供(這裏考慮的比較簡單,不須要驗證用戶名是否重複),發送信息時須要選擇目標用戶。this
鏈接服務器和正常的tcp鏈接沒什麼區別,因爲要考慮到 目標用戶 選項刷新的問題,這裏必須在創建鏈接後向服務器發送一條信息告知服務器本身的身份,服務器接收後會再返回一條信息來告知客戶端目前服務器在線用戶的名稱。編碼
由於請求的信息內容、做用不同,這裏使用自定義的「信息格式」,使用$符號來分割,請求格式爲 code$messagespa
如下是請求的說明表
故咱們能夠根據該表寫出一個Encode函數:
private String EncodeMessage(String message, int code,String goalName) { switch (code) { case 1://彙報用戶名 return "1$" + message; case 2://發送信息 return "2$" + message+"$"+goalName; case 3://斷開鏈接 return "3$" + message; default: return "-1$錯誤"; } }
緊接着對其進行發送信息功能進行封裝:
public void SendMessage(String message, int code, String goalName) { String sendmessage = EncodeMessage(message, code, goalName); try { bw.Write(sendmessage); bw.Flush(); log = DateUtil.getTime() + "發送信息:" + message;//日誌 if (code != 1)//1是第一次創建鏈接的時候發送的本身用戶名,因此不必打印出來,故這裏加了一個判斷 { textbox_chatbox.AppendText(log); } else { flag_open = true;//該標誌是用來控制接收信息的循環的,下面再講 } } catch//捕獲異常是爲了防止服務器意外斷開鏈接 { log = DateUtil.getTime() + "服務器已斷開鏈接"; return; } }
好了下面開始主體tcp鏈接代碼:
//全局變量聲明 private const int port = 8848; private TcpClient tcpClient; private NetworkStream networkStream; private BinaryReader br; private BinaryWriter bw; private String log = ""; private Boolean flag_open = false; //初始化 private void button_connect_Click(object sender, EventArgs e) { //開始鏈接服務器,同步方式阻塞進行 IPHostEntry remoteHost = Dns.GetHostEntry(textbox_ip.Text); tcpClient = new TcpClient(); tcpClient.Connect(remoteHost.HostName, port);//阻塞啦!!! if (tcpClient != null) { String username = textBox_name.Text; log = DateUtil.getTime() + "以用戶名爲 "+username+"鏈接服務器"; textbox_chatbox.AppendText(log); networkStream = tcpClient.GetStream(); br = new BinaryReader(networkStream); bw = new BinaryWriter(networkStream); SendMessage(username, 1,"");//向服務器發送信息,告訴服務器本身的用戶名 Thread thread = new Thread(ReceiveMessage);//開一個新的線程來接收信息 thread.Start(); thread.IsBackground = true;//線程自動關閉 } else { log = DateUtil.getTime() + "鏈接服務器失敗,請重試"; textbox_chatbox.AppendText(log); } }
爲了程序的人性化,接收信息必定是自動接收,這裏使用線程來實現。由於接收信息也是阻塞,故新開一個線程並使用while循環一直監聽,有消息進來就更新。
所以咱們也須要規定服務器發過來的信息的格式,以下圖所示:
所以一樣咱們能夠寫出解析函數:
private void DecodeMessage(String message) { String[] results = message.Split('$'); int code = int.Parse(results[0]); switch (code) { case 1://更新的是用戶 comboBox1.Invoke(updateComboBox, message);//委託,更新下拉框內容 break; case 2://收到信息 String rev = message.Substring(message.IndexOf('$')+1); textbox_chatbox.Invoke(showLog,DateUtil.getTime()+rev);//打印在日誌 break; } }
接收信息函數:
public void ReceiveMessage() { while (flag_open) { try { string rcvMsgStr = br.ReadString(); DecodeMessage(rcvMsgStr); } catch { log = DateUtil.getTime() + "服務器已斷開鏈接"; textbox_chatbox.Invoke(showLog,log); return; } } }
對應的委託函數本身根據你的命名寫就能夠啦~這裏就再也不贅述
終止鏈接的思路也很簡單:向服務器發送消息通知服務器我要下線了,而後關閉相應的流便可。
private void button_stop_Click(object sender, EventArgs e) { SendMessage(textBox_name.Text, 3,""); log = DateUtil.getTime() + "已發起下線請求"; textbox_chatbox.Invoke(showLog, log); flag_open = false; if (bw != null) { bw.Close(); } if (br != null) { br.Close(); } if (tcpClient != null) { tcpClient.Close(); } }
至此客戶端基本完成,細節大家能夠再優化優化~
服務器端是挺複雜的,個人思路是
線程1:循環監聽是否有新的客戶端鏈接加入,如有則加入容器中,並向容器中全部的鏈接廣播一下目前在線的客戶。
線程n:每個鏈接都應該有一個線程循環監聽是否有新的消息到來,有則回調給主線程去處理(這樣不是很高效但基本知足需求)
由於服務器只負責啓動、暫停和轉發消息,界面只須要日誌窗口、狀態口和兩個按鈕便可。(不是我懶)
啓動服務器,就須要開啓一個新的線程來循環監聽,來一個鏈接就要存入容器中去管理。
由於寫習慣Java了,因此這裏容器也選擇List<>,首先咱們先建立一個Client類來封裝一些方法。
在編寫客戶端的時候咱們知道,每個客戶端都應該有相應的名稱,因此Client類必定要包括一個名稱以及相應的鏈接類。
public String userName; public TcpClient tcpClient; public BinaryReader br; public BinaryWriter bw;
發送信息函數相似客戶端,直接調用bw便可。但接收信息必須是一個線程循環監聽,故須要設計一個接口來實現新消息來臨就回調傳給主線程操做。
public interface ReceiveMessageListener { void getMessage(String accountName,String message); }
順便把名字傳過來能夠知道究竟是誰發送的消息。
Client類的整體代碼以下:
class Client { public String userName; public TcpClient tcpClient; public BinaryReader br; public BinaryWriter bw; public ReceiveMessageListener listener; public bool flag = false; public Client(String userName,TcpClient client,ReceiveMessageListener receiveMessageListener) { this.userName = userName; this.tcpClient = client; this.listener = receiveMessageListener; NetworkStream networkStream = tcpClient.GetStream(); br = new BinaryReader(networkStream); bw = new BinaryWriter(networkStream); Thread thread = new Thread(receiveMessage); thread.Start(); flag = true; thread.IsBackground = true; } public override bool Equals(object obj) { return obj is Client client && userName == client.userName; } public bool sendMessage(String ecodeMessage) { try { bw.Write(ecodeMessage); bw.Flush(); return true; }catch { return false; } } public void receiveMessage() { while (true) { try { String temp = br.ReadString(); listener.getMessage(userName, temp); } catch { return; } } } public void stop() { flag = false; if (bw != null) { bw.Close(); } if (br != null) { br.Close(); } if (tcpClient != null) { tcpClient.Close(); } } public interface ReceiveMessageListener { void getMessage(String accountName,String message); } }
寫好Client之後咱們就能夠準備編寫啓動服務器的代碼了,步驟:啓動服務器->監聽->新客戶來->加入List->更新(廣播)用戶表->繼續監聽
private void StartServer() { log = getTime() + "開始啓動服務器中。。。"; textBox_log.Invoke(showLog, log); tcpListener = new TcpListener(localAddress, port); tcpListener.Start(); log = getTime() + "IP:" + localAddress + " 端口號:" + port + " 已啓用監聽"; textBox_log.Invoke(showLog, log); while (true) { try { tcpClient = tcpListener.AcceptTcpClient(); networkStream = tcpClient.GetStream(); br = new BinaryReader(networkStream); bw = new BinaryWriter(networkStream); String accountName =br.ReadString(); accountName = decodeUserName(accountName); log = getTime() + "用戶:"+accountName+"已上線"; count++; label_status.Invoke(showNumber); textBox_log.Invoke(showLog, log); clientList.Add(new Client(accountName,tcpClient,listener)); notifyUpdateUserList(); } catch { log = getTime() + "已終止監聽"; textBox_log.Invoke(showLog, log); return; } } }
啓動服務器只須要開啓新線程就好了~
Thread thread = new Thread(StartServer); thread.Start(); thread.IsBackground = true;
更新名稱函數:
private void notifyUpdateUserList() { String message = "1" + getCurUserName(); foreach (Client i in clientList) { i.sendMessage(message); } }
private String getCurUserName() { String aa = ""; foreach(Client i in clientList) { aa = aa + "$" + i.userName; } return aa; }
在建立Client的時候須要傳入一個監聽接口,咱們本身建立一個類來實現:
根據以前設置的信息傳送格式,寫出對應的處理函數
public class MyListener : Client.ReceiveMessageListener { public Form1 f; public MyListener(Form1 form) { f = form; } public void getMessage(String accountname,string message) { //TODO string []results = message.Split('$'); if (int.Parse(results[0]) == 2)//發送信息 { String content = results[1]; String goalName = results[2]; f.SendMessageToClient(content,goalName,accountname); }else if (int.Parse(results[0]) ==3)//終止鏈接 { String content = results[1]; f.stopClientByName(content); } else { //請求add } } }
轉發信息的邏輯:拿到目標用戶名稱,判斷是否是全部人(廣播)如果則廣播,若不是則再去遍歷尋找對應的客戶再發送。
private void SendMessageToClient(String content,String goalName,String userName) { bool flag = false; if (goalName.Equals("全部人")) { flag = true; } foreach(Client i in clientList) { if (flag) { i.sendMessage("2$廣播:" + userName+"說: "+content); } else { if (i.userName.Equals(goalName)) { i.sendMessage("2$" + userName + "說: "+content); return; } } } }
關閉對應客戶端鏈接的思路:遍歷
public void stopClientByName(String name) { foreach(Client i in clientList){ if (i.userName.Equals(name)) { i.stop(); count--; label_status.Invoke(showNumber); textBox_log.Invoke(showLog, getTime() + name + "已下線"); clientList.Remove(i); } } }
先斷開全部在線客戶端的鏈接,再斷開總的。
private void button_stop_Click(object sender, EventArgs e) { CloseAllClients(); if (bw != null) { bw.Close(); } if (br != null) { br.Close(); } if (tcpClient != null) { tcpClient.Close(); } if (tcpListener != null) { tcpListener.Stop(); } log = getTime() + "已中止服務器"; textBox_log.Invoke(showLog, log); }
public void CloseAllClients() { foreach(Client i in clientList) { i.stop(); } clientList.Clear(); }
完成。
由於代碼是我在很短期內敲出來的,若是有不妥或者不足之處歡迎指正。
當你掌握了一對一(一個客戶端和一個服務器端鏈接)這種形式之後再去看多人聊天,也是很簡單的,關鍵是多線程的使用以及回調。接口返回數據這種形式真的過重要了,在這裏用的也很是方便。
同時消息傳送格式也很關鍵,尤爲是當你在服務器端加入一些功能後,通訊之間傳輸的是指令仍是消息,都必須很好地區別出來。
我在文中的寫法不是特別建議,最好是單獨抽出來寫成一個類,這樣之後維護方便、看起來簡潔明瞭,不像我這個都雜在一塊兒了。。。
寫本文章主要是總結一下本身編碼實現的思路,關鍵代碼都已經放在上面了,相信你按照個人步驟和思路來應該都能作出來,不本身作只是複製粘貼是沒用的(並且也沒啥專業代碼嗯,本身寫寫唄),固然大佬請繞路。
下面放一張運行截圖(人格分裂):