Socket 程式設計是網路通訊的基礎。本文完整實作一個多執行緒 TCP 聊天室,包含伺服器端(支援廣播、私訊、上下線通知)與客戶端(WinForms 介面),展示 `TcpListener`/`Socket` 非同步通訊、執行緒同步、訊息協定設計等核心技術。
採用簡單的 管線分隔文字協定,格式:指令|參數1|參數2|...
| 指令 | 方向 | 格式 | 說明 |
|---|---|---|---|
| CONN | C→S | CONN|暱稱 | 客戶端連線註冊 |
| LIST | S→C | LIST|名單1|名單2|... | 伺服器回傳線上名單 |
| JOIN | S→C | JOIN|暱稱 | 廣播新用戶上線 |
| CHAT | C→S/S→C | CHAT|發送者: 訊息 | 公開頻道訊息 |
| PRIV | C→S/S→C | PRIV|發送者|接收者|訊息 | 私訊 |
| GONE | C→S/S→C | GONE|暱稱 | 用戶離線通知 |
| QUIT | S→C | QUIT | 伺服器關閉通知 |
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Windows.Forms;
namespace ChatServer
{
public class ClientInfo
{
public string Name { get; set; }
public Socket Socket { get; set; }
public EndPoint EndPoint { get; set; }
public Thread Thread { get; set; }
}
public partial class ServerForm : Form
{
private const int Port = 6666;
private TcpListener _listener;
private Thread _listenThread;
private readonly List _clients = new List();
private readonly object _clientsLock = new object();
public ServerForm()
{
InitializeComponent();
this.FormClosing += ServerForm_FormClosing;
}
private void btnStart_Click(object sender, EventArgs e)
{
_listener = new TcpListener(IPAddress.Any, Port);
_listener.Start();
_listenThread = new Thread(ListenLoop) { IsBackground = true };
_listenThread.Start();
AppendLog($"伺服器已啟動,監聽埠 {Port}...");
btnStart.Enabled = false;
btnStop.Enabled = true;
}
private void btnStop_Click(object sender, EventArgs e)
{
StopServer();
}
private void ListenLoop()
{
try
{
while (true)
{
Socket clientSocket = _listener.AcceptSocket();
var clientThread = new Thread(() => HandleClient(clientSocket)) { IsBackground = true };
clientThread.Start();
}
}
catch (SocketException)
{
// 伺服器停止時會觸發
}
}
private void HandleClient(Socket socket)
{
ClientInfo clientInfo = null;
byte[] buffer = new byte[4096];
try
{
while (true)
{
int received = socket.Receive(buffer);
if (received == 0) break; // 客戶端正常關閉
string message = Encoding.UTF8.GetString(buffer, 0, received);
string[] tokens = message.Split('|');
if (tokens.Length == 0) continue;
switch (tokens[0])
{
case "CONN":
string name = tokens[1];
clientInfo = new ClientInfo { Name = name, Socket = socket, EndPoint = socket.RemoteEndPoint };
lock (_clientsLock)
{
// 廣播新用戶加入
Broadcast($"JOIN|{name}", null);
_clients.Add(clientInfo);
}
// 回傳目前線上名單
string list = "LIST|" + string.Join("|", _clients.ConvertAll(c => c.Name));
SendToClient(clientInfo, list);
AppendLog($"{name} ({socket.RemoteEndPoint}) 已連線");
break;
case "CHAT":
Broadcast(message, clientInfo);
break;
case "PRIV":
if (tokens.Length >= 4)
{
string targetName = tokens[2];
SendPrivateMessage(tokens[1], targetName, message);
}
break;
case "GONE":
RemoveClient(clientInfo);
return; // 結束處理迴圈
}
}
}
catch (SocketException ex)
{
AppendLog($"Socket 錯誤: {ex.Message}");
}
catch (Exception ex)
{
AppendLog($"處理錯誤: {ex.Message}");
}
finally
{
RemoveClient(clientInfo);
}
}
private void Broadcast(string message, ClientInfo exclude)
{
lock (_clientsLock)
{
foreach (var client in _clients)
{
if (client != exclude)
SendToClient(client, message);
}
}
}
private void SendPrivateMessage(string from, string to, string fullMessage)
{
lock (_clientsLock)
{
var target = _clients.Find(c => c.Name == to);
var sender = _clients.Find(c => c.Name == from);
if (target != null) SendToClient(target, fullMessage);
if (sender != null) SendToClient(sender, fullMessage); // 回送給發送者確認
}
}
private void SendToClient(ClientInfo client, string message)
{
try
{
if (client?.Socket?.Connected == true)
{
byte[] data = Encoding.UTF8.GetBytes(message);
client.Socket.Send(data);
}
}
catch { /* 忽略發送失敗 */ }
}
private void RemoveClient(ClientInfo client)
{
if (client == null) return;
lock (_clientsLock)
{
if (_clients.Remove(client))
{
Broadcast($"GONE|{client.Name}", null);
AppendLog($"{client.Name} 已離線");
}
}
try { client.Socket?.Close(); } catch { }
}
private void AppendLog(string text)
{
if (txtLog.InvokeRequired)
{
txtLog.Invoke(new Action(AppendLog), text);
}
else
{
txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {text}\r\n");
}
}
private void StopServer()
{
_listener?.Stop();
lock (_clientsLock)
{
foreach (var client in _clients)
{
SendToClient(client, "QUIT");
client.Socket?.Close();
}
_clients.Clear();
}
_listenThread?.Join(1000);
AppendLog("伺服器已停止");
btnStart.Enabled = true;
btnStop.Enabled = false;
}
private void ServerForm_FormClosing(object sender, FormClosingEventArgs e)
{
StopServer();
}
}
}
using System;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Windows.Forms;
namespace ChatClient
{
public partial class ClientForm : Form
{
private TcpClient _client;
private NetworkStream _stream;
private Thread _receiveThread;
private string _userName;
private bool _connected = false;
public ClientForm()
{
InitializeComponent();
this.FormClosing += ClientForm_FormClosing;
}
private void btnConnect_Click(object sender, EventArgs e)
{
_userName = txtName.Text.Trim();
if (string.IsNullOrEmpty(_userName))
{
MessageBox.Show("請輸入暱稱");
return;
}
try
{
_client = new TcpClient("127.0.0.1", 6666);
_stream = _client.GetStream();
_connected = true;
// 發送連線註冊
SendMessage($"CONN|{_userName}");
// 啟動接收執行緒
_receiveThread = new Thread(ReceiveLoop) { IsBackground = true };
_receiveThread.Start();
btnConnect.Enabled = false;
btnDisconnect.Enabled = true;
txtName.ReadOnly = true;
this.Text = $"{_userName} - 已連線";
AppendChat("系統", "連線成功!");
}
catch (Exception ex)
{
MessageBox.Show($"連線失敗: {ex.Message}");
}
}
private void btnDisconnect_Click(object sender, EventArgs e)
{
Disconnect();
}
private void btnSend_Click(object sender, EventArgs e)
{
string msg = txtMessage.Text.Trim();
if (string.IsNullOrEmpty(msg)) return;
// 簡單判斷:@目標 暱稱 視為私訊
if (msg.StartsWith("@"))
{
int spaceIndex = msg.IndexOf(' ');
if (spaceIndex > 1)
{
string target = msg.Substring(1, spaceIndex - 1);
string content = msg.Substring(spaceIndex + 1);
SendMessage($"PRIV|{_userName}|{target}|{content}");
}
else
{
AppendChat("系統", "私訊格式: @暱稱 訊息內容");
}
}
else
{
SendMessage($"CHAT|{_userName}: {msg}");
}
txtMessage.Clear();
}
private void SendMessage(string message)
{
try
{
if (_connected && _stream?.CanWrite == true)
{
byte[] data = Encoding.UTF8.GetBytes(message);
_stream.Write(data, 0, data.Length);
}
}
catch (Exception ex)
{
AppendChat("系統", $"發送失敗: {ex.Message}");
}
}
private void ReceiveLoop()
{
byte[] buffer = new byte[4096];
try
{
while (_connected)
{
int read = _stream.Read(buffer, 0, buffer.Length);
if (read == 0) break; // 伺服器關閉
string message = Encoding.UTF8.GetString(buffer, 0, read);
ProcessServerMessage(message);
}
}
catch (IOException)
{
// 連線中斷
}
catch (Exception ex)
{
AppendChat("系統", $"接收錯誤: {ex.Message}");
}
finally
{
Disconnect();
}
}
private void ProcessServerMessage(string message)
{
string[] tokens = message.Split('|');
if (tokens.Length == 0) return;
switch (tokens[0])
{
case "LIST":
// tokens[1..] 為線上名單
UpdateUserList(tokens.Skip(1).ToArray());
break;
case "JOIN":
AppendChat("系統", $"{tokens[1]} 加入聊天室");
AddUserToList(tokens[1]);
break;
case "CHAT":
// 格式: CHAT|發送者: 訊息
string chatContent = string.Join("|", tokens.Skip(1));
int colonIdx = chatContent.IndexOf(':');
if (colonIdx > 0)
{
string from = chatContent.Substring(0, colonIdx);
string content = chatContent.Substring(colonIdx + 1).TrimStart();
AppendChat(from, content);
}
break;
case "PRIV":
// PRIV|發送者|接收者|訊息
if (tokens.Length >= 4)
{
AppendChat($"[私訊] {tokens[1]}", tokens[3]);
}
break;
case "GONE":
AppendChat("系統", $"{tokens[1]} 離開聊天室");
RemoveUserFromList(tokens[1]);
break;
case "QUIT":
AppendChat("系統", "伺服器已關閉");
Disconnect();
break;
}
}
private void AppendChat(string from, string message)
{
if (rtbChat.InvokeRequired)
{
rtbChat.Invoke(new Action(AppendChat), from, message);
}
else
{
rtbChat.AppendText($"[{DateTime.Now:HH:mm:ss}] {from}: {message}\r\n");
rtbChat.ScrollToCaret();
}
}
private void UpdateUserList(string[] users)
{
if (lbUsers.InvokeRequired)
{
lbUsers.Invoke(new Action(UpdateUserList), users);
}
else
{
lbUsers.Items.Clear();
lbUsers.Items.AddRange(users);
}
}
private void AddUserToList(string name)
{
if (lbUsers.InvokeRequired)
{
lbUsers.Invoke(new Action(AddUserToList), name);
}
else if (!lbUsers.Items.Contains(name))
{
lbUsers.Items.Add(name);
}
}
private void RemoveUserFromList(string name)
{
if (lbUsers.InvokeRequired)
{
lbUsers.Invoke(new Action(RemoveUserFromList), name);
}
else
{
lbUsers.Items.Remove(name);
}
}
private void Disconnect()
{
if (!_connected) return;
_connected = false;
try
{
SendMessage($"GONE|{_userName}");
_stream?.Close();
_client?.Close();
}
catch { }
if (this.InvokeRequired)
{
this.Invoke(new Action(Disconnect));
}
else
{
btnConnect.Enabled = true;
btnDisconnect.Enabled = false;
txtName.ReadOnly = false;
lbUsers.Items.Clear();
this.Text = "聊天客戶端 - 未連線";
AppendChat("系統", "已斷線");
}
}
private void ClientForm_FormClosing(object sender, FormClosingEventArgs e)
{
Disconnect();
}
}
}
// 現代寫法:使用 SocketAsyncEventArgs 或 Task-based
public async Task StartServerAsync()
{
var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
listener.Bind(new IPEndPoint(IPAddress.Any, Port));
listener.Listen(100);
while (true)
{
Socket client = await listener.AcceptAsync();
_ = HandleClientAsync(client); // 不等待,平行處理
}
}
// 長度前綴協定:[4 bytes 長度][JSON/Protobuf 內容]
public class MessageFrame
{
public int Length { get; set; }
public byte[] Payload { get; set; }
public static byte[] Encode(string json) =>
BitConverter.GetBytes(json.Length).Concat(Encoding.UTF8.GetBytes(json)).ToArray();
}
// 伺服器定期發送 PING,客戶端回 PONG
// 若 N 次無回應視為斷線,主動清理資源
| 現象 | 可能原因 | 解決方向 |
|---|---|---|
| 客戶端收到亂碼 | 編碼不一致 | 統一使用 UTF-8,避免 ASCII/Default |
| 訊息不完整/合併 | TCP 黏包/拆包 | 實作訊息框架(長度前綴/分隔符) |
| 跨執行緒 UI 例外 | 非 UI 執行緒存取控制項 | 使用 Control.Invoke/BeginInvoke |
| 伺服器記憶體遞增 | 斷線客戶端未清理 | 在 finally 區塊確實 Close/Dispose |
| 防火牆阻擋 | 埠未開放 | 防火牆新增輸入規則允許 TCP 6666 |
本範例展示了經典的「每連線一執行緒」模型,適合中小規模連線數(<500)。大規模高併發場景建議改用 IOCP (IO Completion Port) 或 async/await 配合 SocketAsyncEventArgs。核心關鍵點:協定設計、執行緒同步、資源釋放、異常處理、UI 執行緒安全。掌握這些基礎,才能進階建構高效能的網路應用系統。