什么是 Socket?
Socket 英文直譯為“孔或插座”,也稱為套接字。用於描述 IP 地址和端口號,是一種進程間的通信機制。你可以理解為 IP 地址確定了網內的唯一計算機,而端口號則指定了將消息發送給哪一個應用程序(大多應用程序啟動時會主動綁定一個端口,如果不主動綁定,操作系統自動為其分配一個端口)。
什么是端口?
一台主機一般運行了多個軟件並同時提供一些服務。每種服務都會打開一個 Socket,並綁定到一個端口號上,不同端口對應於不同的應用程序。例如 http 使用 80 端口;ftp 使用 21 端口;smtp 使用 23 端口。
Socket 的類型
- Stream:一種流式 Socket,針對於面向連接的 TCP 服務應用,安全,但效率低。(本文重點)
- Datagram:數據報式的 Socket,針對於無連接的 UDP 服務應用,不安全(丟失、順序混亂,往往在接收端要分析完整性、重排、或要求重發),但效率高。
Socket 程序一般應用模式及運行流程
- 服務器端會啟動一個 Socket,開始監聽端口,監聽客戶端的連接信息,我們稱之為 Watch Socket。
- 客戶端 Socket 連接服務器端的監聽 Socket,一旦成功連接,服務器端會立刻創建一個新的 Socket 負責與客戶端進行通信,之后,客戶端將不再與 Watch Socket 通信。
- 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; }
系統界面截圖



