1. 程式人生 > >【C#】寫一個支援多人聊天的TCP程式

【C#】寫一個支援多人聊天的TCP程式

碎碎念

先談談我們要實現的效果:客戶端可以選擇要聊天的物件,或者直接廣播訊息(類似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();
        }

完成。

總結

因為程式碼是我在很短時間內敲出來的,如果有不妥或者不足之處歡迎指正。

當你掌握了一對一(一個客戶端和一個伺服器端連線)這種形式以後再去看多人聊天,也是很簡單的,關鍵是多執行緒的使用以及回撥。介面返回資料這種形式真的太重要了,在這裡用的也非常方便。

同時訊息傳送格式也很關鍵,尤其是當你在伺服器端加入一些功能後,通訊之間傳輸的是指令還是訊息,都必須很好地區別出來。

我在文中的寫法不是特別建議,最好是單獨抽出來寫成一個類,這樣以後維護方便、看起來簡潔明瞭,不像我這個都雜在一起了。。。

寫本文章主要是總結一下自己編碼實現的思路,關鍵程式碼都已經放在上面了,相信你按照我的步驟和思路來應該都能做出來,不自己做只是複製貼上是沒用的(而且也沒啥專業程式碼嗯,自己寫寫唄),當然大佬請繞路。

下面放一張執行截圖(人格分裂):

&n