Socket 通信(基礎原理、實時聊天系統雛形)


什么是 Socket

Socket 英文直譯為“孔或插座”,也稱為套接字。用於描述 IP 地址和端口號,是一種進程間的通信機制。你可以理解為 IP 地址確定了網內的唯一計算機,而端口號則指定了將消息發送給哪一個應用程序(大多應用程序啟動時會主動綁定一個端口,如果不主動綁定,操作系統自動為其分配一個端口)。

 

什么是端口?

一台主機一般運行了多個軟件並同時提供一些服務。每種服務都會打開一個 Socket,並綁定到一個端口號上,不同端口對應於不同的應用程序。例如 http 使用 80 端口;ftp 使用 21 端口;smtp 使用 23 端口

 

Socket 的類型

  • Stream:一種流式 Socket,針對於面向連接的 TCP 服務應用,安全,但效率低。(本文重點)
  • Datagram:數據報式的 Socket,針對於無連接的 UDP 服務應用,不安全(丟失、順序混亂,往往在接收端要分析完整性、重排、或要求重發),但效率高。

 

Socket 程序一般應用模式及運行流程

  1. 服務器端會啟動一個 Socket,開始監聽端口,監聽客戶端的連接信息,我們稱之為 Watch Socket。
  2. 客戶端 Socket 連接服務器端的監聽 Socket,一旦成功連接,服務器端會立刻創建一個新的 Socket 負責與客戶端進行通信,之后,客戶端將不再與 Watch Socket 通信。
  3. Watch Socket 繼續監聽可能會來自其他客戶端的連接。

上述過程就像是實現了一次三方會談。服務器端的 Socket 至少會有 2 個。一個是 Watch Socket,每成功接收到一個客戶端的連接,便在服務器端創建一個通信 Socket。客戶端 Socket 指定要連接的服務器端地址和端口,創建一個 Socket 對象來初始化一個到服務器的 TCP 連接。

 

通信的雛形

下面就看一個最簡單的 Socket 示例,實現了網絡聊天通信的雛形。

服務器端

public partial class ChatServer : Form
    {
        public ChatServer()
        {
            InitializeComponent();
            ListBox.CheckForIllegalCrossThreadCalls = false;
        }

        /// <summary>
        /// 監聽 Socket 運行的線程
        /// </summary>
        Thread threadWatch = null;

        /// <summary>
        /// 監聽 Socket
        /// </summary>
        Socket socketWatch = null;

        /// <summary>
        /// 服務器端通信套接字集合
        /// 必須在每次客戶端連接成功之后,保存新建的通訊套接字,這樣才能和后續的所有客戶端通信
        /// </summary>
        Dictionary<string, Socket> dictCommunication = new Dictionary<string, Socket>();

        /// <summary>
        /// 通信線程的集合,用來接收客戶端發送的信息
        /// </summary>
        Dictionary<string, Thread> dictThread = new Dictionary<string, Thread>();

        private void btnBeginListen_Click(object sender, EventArgs e)
        {
            // 創建服務器端監聽 Socket (IP4尋址協議,流式連接,TCP協議傳輸數據)
            socketWatch = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

            // 監聽套接字綁定指定端口
            IPAddress address = IPAddress.Parse(txtIP.Text.Trim());
            IPEndPoint endPoint = new IPEndPoint(address, int.Parse(txtPort.Text.Trim()));
            socketWatch.Bind(endPoint);

            // 將監聽套接字置於偵聽狀態,並設置連接隊列的最大長度
            socketWatch.Listen(20);

            // 啟動監聽線程開始監聽客戶端請求
            threadWatch = new Thread(Watch);
            threadWatch.IsBackground = true;
            threadWatch.Start();
            ShowMsg("服務器啟動完成!");
        }

        Socket socketCommunication = null;
        private void Watch()
        {
            while (true)
            {
                // Accept() 會創建新的通信 Socket,且會阻斷當前線程,因此應置於非主線程上使用
                // Accept() 與線程上接受的委托類型不符,因此需另建一方法做橋接
                socketCommunication = socketWatch.Accept();

                // 將新建的通信套接字存入集合中,以便服務器隨時可以向指定客戶端發送消息
                // 如不置於集合中,每次 new 出的通信線程都是一個新的套接字,那么原套接字將失去引用
                dictCommunication.Add(socketCommunication.RemoteEndPoint.ToString(), socketCommunication);
                lbSocketOnline.Items.Add(socketCommunication.RemoteEndPoint.ToString());

                // Receive 也是一個阻塞方法,不能直接運行在 Watch 中,否則監聽線程會阻塞
                // 另外,將每一個通信線程存入集合,方便今后的管理(如關閉、或掛起)
                Thread thread = new Thread(() =>
                {
                    while (true)
                    {
                        byte[] bytes = new byte[1024 * 1024 * 2];
                        int length =  socketCommunication.Receive(bytes);
                        string msg = Encoding.UTF8.GetString(bytes, 0, length);
                        ShowMsg("接收到來自" + socketCommunication.RemoteEndPoint.ToString() + "的數據:" + msg);
                    }
                });
                thread.IsBackground = true;
                thread.Start();
                dictThread.Add(socketCommunication.RemoteEndPoint.ToString(), thread);

                ShowMsg("客戶端連接成功!通信地址為:" + socketCommunication.RemoteEndPoint.ToString());
            }
        }

        delegate void ShowMsgCallback(string msg);
        private void ShowMsg(string msg)
        {
            if (this.InvokeRequired) // 也可以啟動時修改控件的 CheckForIllegalCrossThreadCalls 屬性
            {
                this.Invoke(new ShowMsgCallback(ShowMsg), new object[] { msg });
            }
            else
            {
                this.txtMsg.AppendText(msg + "\r\n");
            }
        }

        private void btnSendMsg_Click(object sender, EventArgs e)
        {
            if (lbSocketOnline.Text.Length == 0)
                MessageBox.Show("至少選擇一個客戶端才能發送消息!");
            else
            {
                // Send() 只接受字節數組
                string msg = txtSendMsg.Text.Trim();
                dictCommunication[lbSocketOnline.Text].Send(Encoding.UTF8.GetBytes(msg));
                ShowMsg("發送數據:" + msg);
            }
        }

        private void btnSendToAll_Click(object sender, EventArgs e)
        {
            string msg = txtSendMsg.Text.Trim();
            foreach (var socket in dictCommunication.Values)
            {
                socket.Send(Encoding.UTF8.GetBytes(msg));
            }
            ShowMsg("群發數據:" + msg);
        }
    }

客戶端

public partial class ChatClient : Form
    {
        public ChatClient()
        {
            InitializeComponent();
        }

        /// <summary>
        /// 此線程用來接收服務器發送的數據
        /// </summary>
        Thread threadRecive = null;

        Socket socketClient = null;

        private void btnConnect_Click(object sender, EventArgs e)
        {
            // 客戶端創建通訊套接字並連接服務器、開始接收服務器傳來的數據
            socketClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            socketClient.Connect(IPAddress.Parse(txtIP.Text.Trim()), int.Parse(txtPort.Text.Trim()));
            ShowMsg(string.Format("連接服務器({0}:{1})成功!", txtIP.Text.Trim(), txtPort.Text.Trim()));

            threadRecive = new Thread(new ThreadStart(() =>
            {
                while (true)
                {
                    // Receive 方法從套接字中接收數據,並存入接收緩沖區
                    byte[] bytes = new byte[1024 * 1024 * 2];
                    int length = socketClient.Receive(bytes);
                    string msg = Encoding.UTF8.GetString(bytes, 0, length);
                    ShowMsg("接收到數據:" + msg);
                }
            }));
            threadRecive.IsBackground = true;
            threadRecive.Start();
        }

        delegate void ShowMsgCallback(string msg);
        private void ShowMsg(string msg)
        {
            if (this.InvokeRequired) // 也可以啟動時修改控件的 CheckForIllegalCrossThreadCalls 屬性
            {
                this.Invoke(new ShowMsgCallback(ShowMsg), new object[] { msg });
            }
            else
            {
                this.txtMsg.AppendText(msg + "\r\n");
            }
        }

        private void btnSend_Click(object sender, EventArgs e)
        {
            string msg = txtSendMsg.Text.Trim();
            socketClient.Send(Encoding.UTF8.GetBytes(msg));
            ShowMsg("發送數據:" + msg);
        }
    }

現在所有客戶都能和服務器進行通信,服務器也能和所有客戶進行通信。那么,客戶端之間互相通信呢?

顯然,在客戶端界面也應創建在線列表,每次有人登錄后,服務器端除了刷新自身在線列表外,還需將新客戶端的套接字信息發送給其他在線客戶端,以便它們更新自己的在線列表。

客戶端發送消息給服務器,服務器轉發此消息給另一個客戶端。當然,這個消息需要進行一些處理,至少要包含目標套接字和發送內容。

更為完善的是,服務器必須定時按制定的規則檢測列表中套接字通信的有效性,通過發送響應信號,並接收客戶端應答信號以確認客戶端的連接性是真實的(否則,需剔除無效客戶端)。

 

客戶端上傳文件

客戶端

private void btnChooseFile_Click(object sender, EventArgs e)
        {
            OpenFileDialog ofd = new OpenFileDialog();
            if (ofd.ShowDialog() == DialogResult.OK)
            {
                txtFilePath.Text = ofd.FileName;
            }
        }

        private void btnSendFile_Click(object sender, EventArgs e)
        {
            using (FileStream fs = new FileStream(txtFilePath.Text, FileMode.Open))
            {
                byte[] bytes = new byte[1024 * 1024 * 2];
                
                // 假設第一個字節為標志位:0 表示傳送文件

                // 方式一:整體向后偏移 1 個字節;但這樣有潛在缺點,
                // 有時在通信時會非常准確的按照約定的字節長度來傳遞,
                // 那么這種偏移方案顯然是不可靠的
                // bytes[0] = 0; 
                // int length = fs.Read(bytes, 1, bytes.Length);

                // 方式二:創建多出 1 個字節的數組發送
                int length = fs.Read(bytes, 0, bytes.Length);
                byte[] newBytes = new byte[length + 1];
                newBytes[0] = 0;
                // BlockCopy() 會比你自己寫for循環賦值更為簡單合適
                Buffer.BlockCopy(bytes, 0, newBytes, 1, length);
                socketClient.Send(newBytes);
            }
        }

服務器端(Receive 方法中修改成這樣)

Thread thread = new Thread(() =>
                {
                    while (true)
                    {
                        byte[] bytes = new byte[1024 * 1024 * 2];
                        int length =  socketCommunication.Receive(bytes);

                        if (bytes[0] == 0) // File
                        {
                            SaveFileDialog sfd = new SaveFileDialog();
                            if (sfd.ShowDialog() == DialogResult.OK)
                            {
                                using (FileStream fs = new FileStream(sfd.FileName, FileMode.Create))
                                {
                                    fs.Write(bytes, 1, length - 1);
                                    fs.Flush();
                                    ShowMsg("文件保存成功,路徑為:" + sfd.FileName);
                                }
                            }
                        }
                        else // Msg
                        {
                            string msg = Encoding.UTF8.GetString(bytes, 0, length);
                            ShowMsg("接收到來自" + socketCommunication.RemoteEndPoint.ToString() + "的數據:" + msg);
                        }
                    }
                });

 

異常捕捉

Socket 通信屬於網絡通信程序,會有許多的意外,必須進行異常處理以便程序不會被輕易的擊垮。不管是客戶端還是服務器端,只要和網絡交互的環節(Connect、Accept、Send、Receive 等)都要做異常處理。

本例中對服務器端 Receive 方法環節做了一些異常處理,並移除了相應的資源,例如下面:

try
{
    length = socketCommunication.Receive(bytes);
}
catch (SocketException ex)
{
    ShowMsg("出現異常:" + ex.Message);
    string key = socketCommunication.RemoteEndPoint.ToString();
    lbSocketOnline.Items.Remove(key);
    dictCommunication.Remove(key);
    dictThread.Remove(key);
    break;
}

 

系統界面截圖

image

 

image image 9


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM