Siki_Unity_4-4_叢林戰爭_Socket/TCP網絡遊戲開發

Unity 4-4 叢林戰爭(Socket/TCP網絡遊戲開發)

任務1:素材、演示、Prerequisite

使用c#的有關TCP的底層API進行服務器端的開發(直接經過socket進行通訊)c#

功能:
  Third-Person Shooting Game
  建立房間、加入房間的聯機功能數組

Prerequisite:
  TCP基礎知識
  MySQL基礎知識
  UI框架服務器

任務2:IP和port端口號

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之間
    只要運行的程序向系統提出訪問網絡的申請,那麼系統就能夠從這些端口號中分配一個共該程序使用性能

任務3:TCP協議和三次握手

當一個通訊創建鏈接時,須要進行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狀態

任務4&5&6:建立TCP服務器端控制檯應用 (c#)

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();

任務7:建立TCP客戶端控制檯應用 (c#)

新建 -> 項目 -> 控制檯應用(.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()阻止自動關閉)

任務8:實現服務器端異步的消息接收

以前的程序在會在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); // 循環調用,繼續等待接收數據
}

任務9:服務器端開啓異步處理客戶端鏈接請求

任務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);
}

此時,能啓動多個客戶端並與服務器端鏈接。

任務10:服務器端處理客戶端鏈接的正常/非正常關閉

客戶端鏈接的非正常關閉:

任務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;
}

任務11&12:粘包和分包 及其實例

粘包和分包是利用Socket在TCP協議下內部的優化機制
  粘包和分包是因爲內部的優化機制所致使的

包:每次調用Send()所傳輸的數據就能夠算是一個包

粘包:發送數據很頻繁,且每個數據包都很小
  頻繁的發送是很耗費性能的,所以Tcp協議會在內部將多個數據包進行合併,產生一個粘包,在接收數據的終端用一條Receive()接收
  一個Receive()接收到的數據極可能包含多個消息

分包:當發送的一個數據包的數據量特別大時,會拆分開來經過多個數據包進行發送。
  由於若是這個數據量很大的包發送失敗時,須要從新發送,浪費了性能;並且傳輸時佔用的帶寬也較大
  一個Receive()接收到的數據極可能不是一個完整的消息

粘包和分包發送的數據

實例演示:

粘包:在客戶端 利用for循環將i發送出去
   在服務器端接收的次數遠少於客戶端發送的次數

粘包的大小不一樣的緣由應該是客戶端for循環運行的快慢致使的 

在遊戲開發中,粘包須要重點處理,由於遊戲同步的數據(好比位置信息等)很符合被粘包數據的特徵

分包:在客戶端發送很大的數據包。
  在服務器端的dataBuffer的長度會將該數據包進行分割。一個dataBuffer存放不下就會留給下一個buffer存放

任務13~17:粘包和分包問題的解決方案

解決方案思路:

給發送的數據添加一個前綴數據,用來表示該數據的長度。
在接收數據後解析數據時,經過讀取表示數據長度的數據,獲得實際數據。
若是實際獲得的數據的長度大於數據長度,則解析出完整數據,並用相同方法解析下一個數據長度數據和實際數據
若是實際獲得的數據的長度小於數據長度,則接收下一個數據包,直到接收夠完整數據,再進行一次性解析

注意:表示數據長度的前綴數據,它自己的長度必須是固定的

插入題外話:如何將字符串或值類型(好比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跳出循環

相關文章
相關標籤/搜索