在上篇博客簡單理解socket寫完之后我就希望寫出一個websocket的服務器了,但是一路困難重重,還是從基礎開始吧,先搞定C# socket編程基本知識,寫一個支持廣播的簡單server/client交互demo,然后再拓展為websocket服務器。想要搞定這個需要一些基本知識
線程與進程
進程與線程對CS的同學來說肯定耳聞能像了,再啰嗦兩句我個人的理解,每個運行在系統上的程序都是一個進程,進程就是正在執行的程序,把編譯好的指令放入特定一塊內存,順序執行,這就是一個進程,我們平時寫的if-else,for循環都按照我們預期,一步步順序執行,這是因為我們寫的是單線程的程序,所謂線程是一個進程的執行片段,我們寫的單線程程序,整個進程就一個主線程,所有代碼在這個線程內順序執行,但一個進程可以有多個線程同時執行,這就是多線程程序,利用多線程支持我們可以讓程序一邊監聽客戶端請求,一邊廣播消息。
同步與異步
熟悉web開發的同學肯定了解這個概念,在使用ajax中我們就會用到異步的請求,同步與異步正好和我們生活中的理解相反(我嘗試問過學管理的女朋友)
同步:下一個調用在上一個調用返回結果后執行,也可以理解為事情必須一件做完再去做另一件,我們經常編寫的語句都是同步調用
int a=dosomething(); a+=1;
a+=1; 這條指令必須在dosomething()方法執行完畢返回結果后才可以執行,否則就亂了套
異步:異步概念和同步相對,當一個異步過程調用發出后,調用者不能立刻得到結果。實際處理這個調用的部件在完成后,通過狀態、通知和回調來通知調用者(百度上抄的)。理解了同步概念后異步也就不難理解了,以javascript的ajax為例
ajax(arg1,arg2,function(){ //回調函數
a=3; });
a=4;
這個代碼段執行完成后一般情況會把a賦值為3而不是4,因為在ajax方法調用后,a=4;這條語句並沒有等待ajax()返回結果就執行了,也就是在ajax()執行完成調用回調函數之前,a=4;已經執行了,回調函數再把a賦值為3使之成為最后結果,為此在ajax調用中我們經常會使用回調函數,其實在很多異步處理中我們都會使用到回調函數。
阻塞
步驟
了解了上面知識我們就可以按照下圖來寫我們的服務器了
整體結構
關於怎么具體一步步使用socket我就不說了,有興趣同學可以看看你得學會並且學得會的Socket編程基礎知識,看看我們服務器的結構,我寫了一個TcpHelper類來處理服務器操作
首先定義 一個ClientInfo類存放Client信息

public class ClientInfo { public byte[] buffer; public string NickName { get; set; } public EndPoint Id { get; set; } public IntPtr handle { get; set; } public string Name { get { if (!string.IsNullOrEmpty(NickName)) { return NickName; } else { return string.Format("{0}#{1}", Id, handle); } } } }
然后是一個SocketMessage類,記錄客戶端發來的消息

public class SocketMessage { public bool isLogin { get; set; } public ClientInfo Client { get; set; } public string Message { get; set; } public DateTime Time { get; set; } }
然后定義兩個全局變量記錄所有客戶端及所有客戶端發來的消息
private Dictionary<Socket, ClientInfo> clientPool = new Dictionary<Socket, ClientInfo>(); private List<SocketMessage> msgPool = new List<SocketMessage>();
然后就是幾個主要方法的定義
/// <summary> /// 啟動服務器,監聽客戶端請求 /// </summary> /// <param name="port">服務器端進程口號</param> public void Run(int port); /// <summary> /// 在獨立線程中不停地向所有客戶端廣播消息 /// </summary> private void Broadcast(); /// <summary> /// 把客戶端消息打包處理(拼接上誰什么時候發的什么消息) /// </summary> /// <returns>The message.</returns> /// <param name="sm">Sm.</param> private byte[] PackageMessage(SocketMessage sm); /// <summary> /// 處理客戶端連接請求,成功后把客戶端加入到clientPool /// </summary> /// <param name="result">Result.</param> private void Accept(IAsyncResult result); /// <summary> /// 處理客戶端發送的消息,接收成功后加入到msgPool,等待廣播 /// </summary> /// <param name="result">Result.</param> private void Recieve(IAsyncResult result);
逐個分析一下把
void run(int port)
這是該類唯一提供的共有方法,供外界調用,來根據port參數創建一個socket
public void Run(int port) { Thread serverSocketThraed = new Thread(() => { Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); server.Bind(new IPEndPoint(IPAddress.Any, port)); server.Listen(10); server.BeginAccept(new AsyncCallback(Accept), server); }); serverSocketThraed.Start(); Console.WriteLine("Server is ready"); Broadcast(); }
代碼很簡單,需要注意的有幾點
1.在一個新線程中創建服務器socket,最多允許10個客戶端連接。
2.在方法最后調用Broadcast()方法用於向所有客戶端廣播消息
3.BeginAccept方法,MSDN上有權威解釋,但是覺得不夠接地氣,簡單說一下我的理解,首先這個方法是異步的,用於服務器接受一個客戶端的連接,第一個參數實際上是回調函數,在C#中使用委托,在回調函數中通過調用EndAccept就可以獲得嘗試連接的客戶端socket,第二個參數是包含請求state的對象,傳入server socket對象本身就可以了
void Accept(IAsyncResult result)
方法用於處理客戶端連接請求
private void Accept(IAsyncResult result) { Socket server = result.AsyncState as Socket; Socket client = server.EndAccept(result); try { //處理下一個客戶端連接 server.BeginAccept(new AsyncCallback(Accept), server); byte[] buffer = new byte[1024]; //接收客戶端消息 client.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, new AsyncCallback(Recieve), client); ClientInfo info = new ClientInfo(); info.Id = client.RemoteEndPoint; info.handle = client.Handle; info.buffer = buffer; //把客戶端存入clientPool this.clientPool.Add(client, info); Console.WriteLine(string.Format("Client {0} connected", client.RemoteEndPoint)); } catch (Exception ex) { Console.WriteLine("Error :\r\n\t" + ex.ToString()); } }
BeginRecieve方法的MSDN有解釋,和Accept一樣也是異步處理,接收客戶端消息,放入第一個參數中,它也傳入了一個回調函數的委托,和帶有socket state的對象,用於處理下一次接收。我們把接收成功地客戶端socket及其對應信息存放到clientPool中
void Recieve(IAsyncResult result)
方法用於接收客戶端消息,並把所有消息及其發送者信息存入msgInfo,等待廣播
private void Recieve(IAsyncResult result) { Socket client = result.AsyncState as Socket; if (client == null || !clientPool.ContainsKey(client)) { return; } try { int length = client.EndReceive(result); byte[] buffer = clientPool[client].buffer; //接收消息 client.BeginReceive(buffer, 0, buffer.Length, SocketFlags.None, new AsyncCallback(Recieve), client); string msg = Encoding.UTF8.GetString(buffer, 0, length); SocketMessage sm = new SocketMessage(); sm.Client = clientPool[client]; sm.Time = DateTime.Now; Regex reg = new Regex(@"{<(.*?)>}"); Match m = reg.Match(msg); if (m.Value != "") //處理客戶端傳來的用戶名 { clientPool[client].NickName = Regex.Replace(m.Value, @"{<(.*?)>}", "$1"); sm.isLogin = true; sm.Message = "login!"; Console.WriteLine("{0} login @ {1}", client.RemoteEndPoint,DateTime.Now); } else //處理客戶端傳來的普通消息 { sm.isLogin = false; sm.Message = msg; Console.WriteLine("{0} @ {1}\r\n {2}", client.RemoteEndPoint,DateTime.Now,msg); } msgPool.Add(sm); } catch { //把客戶端標記為關閉,並在clientPool中清除 client.Disconnect(true); Console.WriteLine("Client {0} disconnet", clientPool[client].Name); clientPool.Remove(client); } }
這個的代碼都很簡單,就不多解釋了,我加入了用戶名處理用於廣播客戶端消息的時候顯示客戶端自定義的昵稱而不是生硬的ip地址+端口號,當然這里需要客戶端配合
Broadcast()
服務器已經和客戶端連接成功,並且接收到了客戶端消息,我們就可以看看該怎么廣播消息了,Broadcast()方法已經在run()方法內調用,看看它是怎么運作廣播客戶端消息的
private void Broadcast() { Thread broadcast = new Thread(() => { while (true) { if (msgPool.Count > 0) { byte[] msg = PackageMessage(msgPool[0]); foreach (KeyValuePair<Socket, ClientInfo> cs in clientPool) { Socket client = cs.Key; if (client.Connected) { client.Send(msg, msg.Length, SocketFlags.None); } } msgPool.RemoveAt(0); } } }); broadcast.Start(); }
Broadcast()方法啟用了一個新線程,循環檢測msgPool是否為空,當不為空的時候遍歷所有客戶端,調用send方法發送msgPool里面的第一條消息,然后清除該消息繼續檢測,直到消息廣播完,其實這就是一個閹割版的觀察者模式 ,順便看一下打包數據方法
private byte[] PackageMessage(SocketMessage sm) { StringBuilder packagedMsg = new StringBuilder(); if (!sm.isLogin) //消息是login信息 { packagedMsg.AppendFormat("{0} @ {1}:\r\n ", sm.Client.Name, sm.Time.ToShortTimeString()); packagedMsg.Append(sm.Message); } else //處理普通消息 { packagedMsg.AppendFormat("{0} login @ {1}", sm.Client.Name, sm.Time.ToShortTimeString()); } return Encoding.UTF8.GetBytes(packagedMsg.ToString()); }
如何使用
static void Main(string[] args) { TcpHelper helper = new TcpHelper(); helper.Run(8080); }
這樣我們就啟用了server,看看簡單的客戶端實現,原理類似,不再分析了

1 class Program 2 { 3 private static byte[] buf = new byte[1024]; 4 static void Main(string[] args) 5 { 6 Console.Write("Enter your name: "); 7 string name = Console.ReadLine(); 8 Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); 9 client.Connect(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 8080)); 10 Console.WriteLine("Connected to server, enter $q to quit"); 11 name = "{<" + name.Trim() + ">}"; 12 byte[] nameBuf = Encoding.UTF8.GetBytes(name); 13 client.BeginSend(nameBuf, 0, nameBuf.Length, SocketFlags.None, null, null); 14 client.BeginReceive(buf, 0, buf.Length, SocketFlags.None, new AsyncCallback(Recieve), client); 15 while (true) 16 { 17 string msg = Console.ReadLine(); 18 if (msg == "$q") 19 { 20 client.Close(); 21 break; 22 } 23 byte[] output = Encoding.UTF8.GetBytes(msg); 24 client.BeginSend(output, 0, output.Length, SocketFlags.None, null, null); 25 } 26 Console.Write("Disconnected. Press any key to exit... "); 27 Console.ReadKey(); 28 } 29 30 private static void Recieve(IAsyncResult result) 31 { 32 try 33 { 34 Socket client = result.AsyncState as Socket; 35 int length = client.EndReceive(result); 36 string msg = Encoding.UTF8.GetString(buf, 0, length); 37 Console.WriteLine(msg); 38 client.BeginReceive(buf, 0, buf.Length, SocketFlags.None, new AsyncCallback(Recieve), client); 39 } 40 catch 41 { 42 } 43 } 44 }
有圖有真相
這樣一個簡單的支持廣播地socket就完成了,我們可以進行多個客戶端聊天了,看看運行效果吧
最后
其實socket編程沒有一開始我想象的那么難,重要的還是搞明白原理,接下來事情就迎刃而解了,這個簡單的server還有不少待完善之處,主要是展示一下C# socket編程基本使用,為下一步做websocket server做准備,實習兩者很相似,只是websocket server 添加了協議處理部分,這兩天會盡快分享出來
感興趣的同學可以看看源碼 (注釋是我寫博客的時候加上的,源碼中沒有,不管看過博客的人應該沒問題)