使用c#的有關TCP的底層API進行服務器端的開發(直接經過socket進行通訊)c#
功能:
Third-Person Shooting Game
建立房間、加入房間的聯機功能數組
Prerequisite:
TCP基礎知識
MySQL基礎知識
UI框架服務器
IP: 在網絡環境中,將數據包發給最終的目標地址網絡
路由器能夠理解爲數據的中轉站
鏈接同一個路由器的多是多臺設備,這部分構成了一個局域網
路由器會給每臺設備分配一個不重複的局域網IP
(cmd: ipconfig -- WLAN的IPv4地址,通常是192.168.x.x)
而這個局域網內的設備是共享一個公網IP的
經過百度搜索IP便可查到當前設備的公網IP框架
IP地址是由網絡供應商分配的異步
遊戲的服務器有一個公網IP,用於與客戶端之間的通訊
服務器購買:阿里雲 -> 雲服務器ECSsocket
Port: 端口號
數據通訊的實質是:在軟件之間的傳輸
端口號代表了是在跟該電腦上的哪一個軟件進行通訊
端口號是不會重複的,由操做系統進行分配函數
通常公認端口 (Well-known Ports)在0~1023之間
好比HTTP協議代理端口號經常使用80等
註冊端口 (Registered Ports)在1024~49151之間,多被一些服務綁定
動態/私有端口 (Dynamic/ Private Ports)則通常在1024~65535之間
只要運行的程序向系統提出訪問網絡的申請,那麼系統就能夠從這些端口號中分配一個共該程序使用性能
當一個通訊創建鏈接時,須要進行TCP的三次握手
當一個通訊鏈接斷開時,須要進行TCP的四次揮手大數據
TCP和UDP的優缺點:
TCP傳輸穩定,傳輸信息時會保證信息的完整性
-- 發出消息後會等待接收端的響應,若是等待時間到後沒有響應,會再次發送
UDP不穩定,可能丟失數據,可是速度快
-- 發出消息後不會驗證消息的接收狀態
詳見 https://blog.csdn.net/omnispace/article/details/52701752
TCP的三次握手 Three-Way Handshake:-- 鏈接的創建
1. 客戶端發送SYN (syn=j -- 隨機產生)包給服務器,並進入SYN_SENT狀態,請求創建鏈接,等待服務器確認
2. 服務器收到SYN包後,針對SYN進行應答ACK (ack = j+1),同時本身也發送一個SYN包 (syn=k -- 隨機產生),
即發送了SYN+ACK包給客戶端,服務器進入SYN_RECV狀態
3. 客戶端收到SYN+ACK後,向服務器發送確認包ACK (ack=k+1)
此時客戶端和服務器進入ESTABLISHED狀態,完成三次握手
自此鏈接創建成功,能夠開始發送數據
TCP的四次揮手 -- 鏈接終止協議
1. 客戶端發送FIN包給服務器,用來表示須要關閉客戶端到服務器的數據傳輸
客戶端進入FIN_WAIT_1狀態
2. 服務器收到FIN後,針對FIN進行確認應答ACK (確認序號爲收到序號+1),並將ACK發送給客戶端
服務器進入CLOSE_WAIT狀態
3. 服務器發送FIN包給客戶端,請求切斷鏈接
服務器進入LAST_ACK狀態
4. 客戶端收到FIN後,進入TIME_WAIT狀態,並針對FIN包進行確認應答ACK,並向服務器發送
服務器進入CLOSED狀態
VS -> 文件 -> 新建 -> 項目 -> 控制檯應用(.NET Framework) -> 命名Server
建立Socket並綁定IP和Port:
using System.Net.Sockets;
1. 建立socket -- Socket(AddressFamily, SocketType, ProtocolType);
Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
AddressFamily
.InterNetwork表示IPv4類型的地址
.InterNetworkV6表示IPv6
SocketType
.Dgram表示使用數據報文的形式,以投遞的方式進行數據傳輸,可能丟失 -- UDP可使用該形式
.Stream表示使用數據流的形式,在二者之間創建管道,數據在管道中進行傳輸 -- 數據傳輸穩定
2. 綁定IP和port:
IP:
由於設備可能有多個網卡,每一個網卡可能鏈接不一樣的網絡,所以一個設備可能出現對應多個IP地址
可是,做爲服務器端的部署,通常只會有一個外網IP
這裏,綁定局域網IP便可
// 經過ipconfig獲得局域網ip,或直接使用127.0.0.1 (本地localhost)
using System.Net;
IPAddress -- 表明ip -- xxx.xxx.xx.xx
IPEndPoint -- 表明ip: port -- xxx.xxx.xx.xx : xx
-- 由於過一段時間,路由器會給設備從新分配ip,基於路由器的ip管理策略
因此不該直接設置ip地址
建立ip地址
IPAddress ipAddress = new IPAddress(new byte[] {192,168, x, x});
不推薦這麼寫,改成 -->
IPAddress ipAddress = IPAddress.Parse("192.168.x.x");
Port:
建立port地址
IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, "65535");
綁定ip和端口號
serverSocket.Bind(ipEndPoint); // 包括了向操做系統申請端口號
發送和接收數據:
3. 開始監聽端口
serverSocket.Listen(50);
// 表示處理等待鏈接的隊列最大爲50,設置爲0表示不設置最大值,無限制
// 服務器只有一個,而客戶端有多個,等待隊列滿後將再也不接收客戶端鏈接
4. 等待接收一個客戶端來的鏈接
Socket clientSocket = serverSocket.Accept();
直到接收到鏈接後,纔會繼續執行下面的代碼
發送數據
string msg = "Hello client! 你好 ....";
byte[] data = System.Text.Encoding.UTF8.GetBytes(msg); // 將string轉換成byte[]
clientSocket.Send(data); // 須要傳輸的類型是byte[]
接收數據
byte[] dataBuffer = new byte[1024]; // 保證數組大小夠用便可
int count = clientSocket.Receive(dataBuffer); // 返回值int表示接收到byte[]數據的長度
string megReceived = System.Text.Encoding.UTF8.GetString(dataBuffer,0 , count);
// 表示把有內容的那部分bytes進行轉換, 從0開始,一直到第count字節
Console.WriteLine(msgReceive);
5. 關閉鏈接:
clientSocket.Close(); // 斷開客戶端的鏈接
serverSocket.Close();
新建 -> 項目 -> 控制檯應用(.Net Framework) -> 命名Client
建立socket:
Socket clientSocket = new Socket(AddressFamily.InnerNetwork, SocketType.Stream, ProtocolType.Tcp);
與服務器端創建鏈接:
clientSocket.Connect(new IPEndPoint(IPAddress.Parse("192.168.x.x"), 65535));
與遠程主機創建鏈接,服務器端的Accept()獲得了來自客戶端的鏈接,所以繼續執行它如下的代碼,向客戶端Send()消息
進行有關消息的操做:
從服務器端接收消息:
byte[] data = new byte[1024];
int count = clientSocket.Receive(data);
string msg = System.Text.Encoding.UTF8.GetString(data, 0, count);
Console.Write(msg);
// 調用完Receive()後,程序會暫停並等待,直到接收到信息後纔會繼續執行下面的代碼
發送消息給服務器端:
string input = Console.ReadLine();
clientSocket.Send(System.Text.Encoding.UTF8.GetBytes(input));
-- 此時server中的接收消息部分會接收這段發送過去的信息
關閉鏈接:
clientSocket.Close();
運行上面的服務器端和客戶端
如何同時運行呢?
在VS中不能同時運行兩個應用程序
1. 在VS中啓動服務器端
2. 在文件資源管理器中,右鍵對應的項目 -> 生成 -- 就會生成.exe文件
直接雙擊.exe程序,啓動客戶端
左側爲server,右側爲client
server打開,暫停在serverSocket.Accept()處等待客戶端鏈接
client打開,並進行clientSocket.Connect(),創建鏈接
鏈接創建成功,server代碼繼續執行,執行Send()後,在Receive()處暫停
而client創建鏈接後在Receive()處暫停,等待接收server消息,由於server執行了Send(),client接收到了消息
消息接收完,client代碼繼續執行,等待用戶輸入 Console.ReadLine();
輸入後,進行Send()操做並執行關閉鏈接
server在接收到client發送的消息,繼續執行代碼
(由於server接收到信息並打印以後,程序就結束自動關閉了(client也同樣)
爲了方便看清server接收到的信息,在server最後加上了一行Console.ReadKey()阻止自動關閉)
以前的程序在會在Receive()處一直等待;若要想持續不斷地發送或接收消息,有兩種方法:
1. 另起一個線程,好比聊天室功能單獨佔有的線程
2. 異步方法
clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, clientSocket);
開始監聽數據的傳遞
BeginReceive(buffer, int offset, int size, SocketFlags, AsyncCallback, object state);
offset: 從哪開始;size: 最大數據長度;AsyncCallback: 接收到消息後的回調函數;
state: 給回調函數傳遞的參數,在回調函數中的ar.AsyncState強制轉換成須要的類型便可
static byte[] s_Buffer = new byte[1024]; static void StartServerAsync() { Socket serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); IPAddress ipAddress = IPAddress.Parse("192.168.1.5"); IPEndPoint ipEndPoint = new IPEndPoint(ipAddress, 65535); serverSocket.Bind(ipEndPoint); serverSocket.Listen(0); Socket clientSocket = serverSocket.Accept(); string msg = "Hello client"; clientSocket.Send(System.Text.Encoding.UTF8.GetBytes(msg)); // 這裏開始進行異步接收消息 clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, clientSocket); } static void ReceiveCallBack(IAsyncResult ar) { Socket clientSocket = ar.AsyncState as Socket; int count = clientSocket.EndReceive(ar); Console.WriteLine(Encoding.UTF8.GetString(s_Buffer, 0, count)); clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallBack, clientSocket); // 循環調用,繼續等待接收數據 }
任務4~6中使用的socket.Accept()也會致使程序等待,當有客戶端鏈接過來時纔會繼續下面的代碼
如何異步地進行接受鏈接呢 -- 異步方式
BeginAccept(AsyncCallback callback, object state);
serverSocket.BeginAccept(AcceptCallback, serverSocket); // 開始異步等待鏈接 static void AcceptCallback(IAsyncResult ar) { // 異步接收的回調函數 Socket serverSocket = ar.AsyncState as Socket; Socket clientSocket = serverSocket.EndAccept(ar); byte[] data = Encoding.UTF8.GetBytes("....."); clientSocket.Send(data); clientSocket.BeginReceive(dataBuffer, 0, 1024, SocketFlags.None, ReceiveCallback, clientSocket); // 循環調用,不斷接收 serverSocket.BeginAccept(AcceptCallback, serverSocket); }
此時,能啓動多個客戶端並與服務器端鏈接。
客戶端鏈接的非正常關閉:
任務9中提到:
當客戶端關閉時,會發現服務器端報錯了: SocketException: 遠程主機強迫關閉了一個現有的鏈接。
緣由是客戶端窗口關閉時能夠被視爲非正常關閉,而服務器端執行clientSocket.BeginReceive()後調用EndReceive()接收消息時,客戶端鏈接已不存在。
須要進行異常捕獲處理
private static void ReceiveCallback(IAsyncResult ar) { Socket clientSocket = ar.AsyncState as Socket; try { int count = clientSocket.EndReceive(ar); string msg = Encoding.UTF8.GetString(buffer, 0, count); Console.WriteLine(msg); clientSocket.BeginReceive(buffer, 0, 1024, SocketFlags.None, ReceiveCallback, clientSocket); } catch(Exception e) { Console.WriteLine(e); if(clientSocket != null) { clientSocket.Close(); } } finally { } }
拋出異常,則關閉鏈接
客戶端鏈接的正常關閉:
假設在客戶端中輸入"c",則將socket關閉
string msg = Console.ReadLine(); if(msg == "c") { clientSocket.Close(); return; }
運行,會發現當客戶端輸入c執行socket.Close()後,服務器端不斷接收到空數據,且沒有報錯
緣由:在服務器端的ReceiveCallback()中的EndReceive()會不斷接收許多條空數據並繼續BeginReceive()
即便客戶端的鏈接已經斷掉了
(對上面的緣由頗有疑惑)
解決方法:在服務器端判斷EndReceive()返回值count的大小,若是count==0則關閉鏈接
if(count == 0) { clientSocket.Close(); return; }
粘包和分包是利用Socket在TCP協議下內部的優化機制
粘包和分包是因爲內部的優化機制所致使的
包:每次調用Send()所傳輸的數據就能夠算是一個包
粘包:發送數據很頻繁,且每個數據包都很小時
頻繁的發送是很耗費性能的,所以Tcp協議會在內部將多個數據包進行合併,產生一個粘包,在接收數據的終端用一條Receive()接收
一個Receive()接收到的數據極可能包含多個消息
分包:當發送的一個數據包的數據量特別大時,會拆分開來經過多個數據包進行發送。
由於若是這個數據量很大的包發送失敗時,須要從新發送,浪費了性能;並且傳輸時佔用的帶寬也較大
一個Receive()接收到的數據極可能不是一個完整的消息
粘包和分包發送的數據
實例演示:
粘包:在客戶端 利用for循環將i發送出去
在服務器端接收的次數遠少於客戶端發送的次數
粘包的大小不一樣的緣由應該是客戶端for循環運行的快慢致使的
在遊戲開發中,粘包須要重點處理,由於遊戲同步的數據(好比位置信息等)很符合被粘包數據的特徵
分包:在客戶端發送很大的數據包。
在服務器端的dataBuffer的長度會將該數據包進行分割。一個dataBuffer存放不下就會留給下一個buffer存放
解決方案思路:
給發送的數據添加一個前綴數據,用來表示該數據的長度。
在接收數據後解析數據時,經過讀取表示數據長度的數據,獲得實際數據。
若是實際獲得的數據的長度大於數據長度,則解析出完整數據,並用相同方法解析下一個數據長度數據和實際數據
若是實際獲得的數據的長度小於數據長度,則接收下一個數據包,直到接收夠完整數據,再進行一次性解析
注意:表示數據長度的前綴數據,它自己的長度必須是固定的
插入題外話:如何將字符串或值類型(好比int)轉換爲byte[]字節數據
字符串是引用類型
1. 以前使用的方法是用UTF8編碼格式將字符串轉換爲byte[]
byte[] data = System.Text.Encoding.UTF8.GetBytes("1a 中");
嘗試輸出該字節數組:49 97 32 228 184 173
其中49對應1,97爲a的ascii碼,32對應空格,以後三個字節對應的是一個漢字
那麼,經過這種方法的轉換爲何不適用在表示數據長度的前綴數據上呢?
由於數字位數的不一樣,會致使轉換後的字節數不一樣。
好比長度數據=4,轉換後爲一個字節;而長度數據=1000,則轉換後爲四個字節
2. 另外一種方法能夠將值類型的數據轉換爲字節數據
int count = 1;
byte[] data = BitConverter.GetBytes(count);
輸出data後,爲四個字節 0 0 0 1, 由於int爲Int32類型,佔4個字節
即便count = 100000(只要不溢出Int32),都是4個字節
相對應的,BitConverter.ToInt32(data)能夠將字節數據轉換成int值
BitConverter中有不少方法,都是用來轉換值類型的數據
解決方案實現:
客戶端算出數據的長度,並將數據長度信息加到數據包前
public static byte[] GetDataBytesWithLengthInfo(string data) { // 獲得data的字節數據 byte[] dataBytes = Encoding.UTF8.GetBytes(data); // 字節數據的長度 int dataLength = dataBytes.Length; // 長度信息的字節數據 byte[] lengthBytes = BitConverter.GetBytes(dataLength); // 合併數據 return lengthBytes.Concat(dataBytes).ToArray(); }
服務器端收到消息後,進行數據包的解析 -- 幾條消息
用Message類實現相關功能
須要注意的地方:
1. 須要一個數組用來存儲接收到的byte[]
Message.data
2. 須要一個flag來跟蹤當前已經讀取到的位置
Message.startIndex
3. 將存儲的byte[]解析成消息
在Server中定義static Message msg = new Message();
接收數據的時候clientSocket.BeginReceive(msg.data, msg.startIndex, msg.RemainSize, SocketFlag.None, ReceiveCallback, clientSocket);
// data表示存儲的byte[]; startIndex爲接下來開始存儲的位置,也表明已經存儲了的字節數;
// RemainSize = data.Length-startIndex, 表示可存儲的最大字節數,避免讀取太多數據致使msg.data空間不足溢出
每讀取一次完(EndReceive()),須要更新msg.startIndex += count;
讀取完數據,開始解析:
1. 判斷是否有足夠數據以解析
if(startIndex <= 4) return; // 若是已經存儲在data中的字節數據長度小於4,則沒有存儲數據(長度數據已經佔了4個字節)
2. 數據長度 --
int length = BitConverter.ToInt32(data, 0); // 從0開始讀取4個字節的數據,解析成長度數據
3. 判斷是否有足夠數據,沒有的話等待下一次數據的讀取,並須要再次調用本方法
if(startIndex - 4 >= length) {
4. 解析數據
Encoding.UTF8.ToString(data, 4, length); // 從4開始,讀取出完整的一條數據,多餘的不讀取
5. 循環讀取多條,直到讀取完
startIndex -= (4 + length); // 更新startIndex
Array.Copy(data, 4 + count, 0, startIndex); // 刪除已經解析完的數據 用while(true)進行循環,直到數據不足startIndex<=4或startIndex-4<length跳出循環