碎碎念
先談談我們要實現的效果:客戶端可以選擇要聊天的對象,或者直接廣播消息(類似QQ的私聊和群消息),支持圖片發送(簡單的)
那么,該如何實現呢?
首先明確的是,要分客戶端和服務器端兩個部分(廢話)
客戶端:選擇要發送的對象,發送信息。同時有一個線程在監聽是否收到新的信息。
服務器端:負責轉發收到的消息,並負責管理所有接入的連接
好了有了大體思路后,開始編程吧~
客戶端
界面設計
客戶端要提供的信息主要是發送對象、發送信息內容,故設計如下:

其中用戶名必須提供(這里考慮的比較簡單,不需要驗證用戶名是否重復),發送信息時需要選擇目標用戶。
編碼實現
連接服務器部分
連接服務器和正常的tcp連接沒什么區別,由於要考慮到 目標用戶 選項刷新的問題,這里必須在建立連接后向服務器發送一條信息告知服務器自己的身份,服務器接收后會再返回一條信息來告知客戶端目前服務器在線用戶的名稱。
因為請求的信息內容、作用不一樣,這里使用自定義的“信息格式”,使用$符號來分割,請求格式為 code$message
以下是請求的說明表

故我們可以根據該表寫出一個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(); }
完成。
補充:圖片接收發送(二進制數據)
在網上搜尋資料的時候看到有大佬對圖片進行Base64編碼然后生成字符串來收發,不知道可行與否。如果可行那么直接在原來的規則增加一條圖片規則即可。具體方法看這個:點我跳轉。
我選擇的是直接發送byte數組
設計一下收發規則,在原來的基礎上增加:
發送格式:

服務器返回格式:

思路
發送string類型信息給服務器,通知服務器我要發送圖片了,並且直接把圖片的byte大小傳過去
緊接着直接把這個byte數組發過去
服務器接收string信息后根據拿到得大小去讀這個byte數組,轉發即可。
實現
圖片編碼
//把byte轉圖片,支持gif public Image SetByteToImage(byte[] mybyte) { MemoryStream ms = new MemoryStream(mybyte); Image outputImg = Image.FromStream(ms); return outputImg; } //把圖片轉byte[] 設置讀取文件為允許修改 private byte[] SetImageToByteArray(string fileName) { FileStream fs = new FileStream(fileName, FileMode.Open, System.IO.FileAccess.Read, FileShare.ReadWrite); byte[] byteData = new byte[fs.Length]; fs.Read(byteData, 0, byteData.Length); fs.Close(); return byteData; }
選擇圖片
OpenFileDialog fileDialog = new OpenFileDialog(); fileDialog.Filter = "圖片文件(*.jpg,*.gif,*.bmp,*.png)|*.jpg;*.gif;*.bmp;*.png"; DialogResult result = fileDialog.ShowDialog(); if (result == DialogResult.OK) { Pic_dir = fileDialog.FileName;//Pic_dir就是一個string來存放圖片地址 pic_show.Image = Image.FromFile(Pic_dir); }
發送圖片
byte[] datas =SetImageToByteArray(Pic_dir); bw.Write(datas, 0, datas.Length); bw.Flush();
接收圖片
br.ReadBytes(傳過來的長度)
服務器端
服務器端改動不大,主要是Client里面要加一個直接讀byte的方法
或者修改接口把br返回回來:
public interface ReceiveMessageListener { void getMessage(String accountName,String message, BinaryReader br, BinaryWriter bw); }
效果圖

總結
因為代碼是我在很短時間內敲出來的,如果有不妥或者不足之處歡迎指正。
當你掌握了一對一(一個客戶端和一個服務器端連接)這種形式以后再去看多人聊天,也是很簡單的,關鍵是多線程的使用以及回調。接口返回數據這種形式真的太重要了,在這里用的也非常方便。
同時消息傳送格式也很關鍵,尤其是當你在服務器端加入一些功能后,通信之間傳輸的是指令還是消息,都必須很好地區別出來。
我在文中的寫法不是特別建議,最好是單獨抽出來寫成一個類,這樣以后維護方便、看起來簡潔明了,不像我這個都雜在一起了。。。
寫本文章主要是總結一下自己編碼實現的思路,關鍵代碼都已經放在上面了,相信你按照我的步驟和思路來應該都能做出來,不自己做只是復制粘貼是沒用的(而且也沒啥專業代碼嗯,自己寫寫唄),當然大佬請繞路。
源代碼:ChatBoxDemo
下面放一張運行截圖(人格分裂):

