WebSocket - WebSocket Server Console實作

在上一篇(WebSocket - 新一代網路傳輸技術WebSocket簡介)中,限量大略簡單介紹了WebSocket的原理與背景,接下來在篇文章中,限量要來根據WebSocket標準(RFC-6455)來時做一套簡單的WebSocket Server Console,來接收Client端的訊息並進行處理。


使用WebSocket的先決條件不僅需要支援WebSocket的瀏覽器,還需要一個實作WebSocket協定的WebSocket ServerWebSocket Server詳細的實作方式必須根據RFC-6455規範來實作,WebSocket Server主要實作重點在於三個功能:(1)建立HandShake(2)接收資料;(3)傳送資料。接下來限量以一個由C#撰寫的WebSocket範例來說明整個WebSocket協定的基本收送功能的實作過程:

在範例中,實作了兩個類別,WebSocketServerWebSocketConnectionWebSocketServer為主要Server核心,負責管理整個Server運作(包含建立HandShake管理每個Client連線等)WebSocketConnection為一個Client連線,負責處理該Client連線的訊息收發與生命週期,接著將在以下講解WebSocket Server的實作方式:


WebSocketServer

(1) 相關欄位與屬性:

_serverSocketServer必須有一個持續監聽的Socket等待Client端進行連線,和一般Socket不同的是這裡的ProtocolType需要設定為IP

_sha1WebSocket協定中使用SHA1(Security Hash Algorithm)Socket-Accept-Key進行加密。

GUID:在WebSocket協定中,規定進行HandShake時,在Client RequestSec-WebSocket-Key後方加上固定的GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),經過SHA1加密後產生Sec-WebSocket-Accept字串。

_connections:儲存與Server連線的所有Client,以便控制Client之間的資料傳輸與生命週期管理。

OnConnected:當ClientServer建立連線後,會觸發Connnected事件。

Port:建立Server連線所使用的通訊埠。


// Server端的Socket
private Socket _serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP);
// SHA1加密
private SHA1 _sha1 = SHA1CryptoServiceProvider.Create();
// WebSocket專用GUID                          
private static readonly String GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
// 儲存所有Client連線的佇列             
private List<WebSocketConnection> _connections = new List<WebSocketConnection>();
// 建立連線後觸發的事件                       
public event ClientConnectedHandler OnConnected;                                                                
// 通訊埠
public Int32 Port { get; private set; }


(2) 相關方法

Start:Start()Server啟動的方法,當Server啟動後,_serverSocket會開始監聽連線該通訊埠的Client,並開始接收Client的連線資訊。
public void Start()
{
    // 啟動Server Socket並監聽
    _serverSocket.Bind(new IPEndPoint(IPAddress.Any, Port));
    _serverSocket.Listen(128);
    // Server Socket準備接收Client端連線
    _serverSocket.BeginAccept(new AsyncCallback(onConnect), null);
}

onConnect:Client連線上後,進入onConnect()內產生一個Client Socket連線,接著ServerClient開始進行HandShake動作。
private void onConnect(IAsyncResult result)
{
    var clientSocket = _serverSocket.EndAccept(result);
    // 進行ShakeHand動作
    ShakeHands(clientSocket);
}

ShakeHand:ShakeHand()中進行Client-ServerHandShake動作,首先Server會從Client接收Request訊息,取出Sec-WebSocket-Key的值後方加上GUID加密後,再組成HandShake訊息字串後以Byte陣列型式回傳給Client完成HandShake。我們在HandShake後產生一個WebSocketConnection實體並加入_connection集合中,並註冊WebSocketConnectionDisconnected事件,最後_serverSocket再持續監聽是否有其他Client連上。
private void ShakeHands(Socket socket)
{
    // 存放Request資料的Buffer
    byte[] buffer = new byte[1024];
    // 接收的Request長度
    var length = socket.Receive(buffer);
    // 將buffer中的資料解碼成字串
    var data = Encoding.UTF8.GetString(buffer, 0, length);
    Console.WriteLine(data);

    // 將資料字串中的空白位元移除
    var dataArray = data.Split(Environment.NewLine.ToCharArray(), StringSplitOptions.RemoveEmptyEntries);
    // 從Client傳來的Request Header訊息中取
    var key = dataArray.Where(s => s.Contains("Sec-WebSocket-Key: ")).Single().Replace("Sec-WebSocket-Key: ", String.Empty).Trim();
    var acceptKey = CreateAcceptKey(key);
    // WebSocket Protocol定義的ShakeHand訊息
    var handShakeMsg =
 "HTTP/1.1 101 Switching Protocols\r\n" +
 "Upgrade: websocket\r\n" +
 "Connection: Upgrade\r\n" +
 "Sec-WebSocket-Accept: " + acceptKey + "\r\n\r\n";

    socket.Send(Encoding.UTF8.GetBytes(handShakeMsg));

    Console.WriteLine(handShakeMsg);
    // 產生WebSocketConnection實體並加入佇列中管理
    var clientConn = new WebSocketConnection(socket);
    _connections.Add(clientConn);
    // 註冊Disconnected事件
    clientConn.OnDisconnected += new ClientDisconnectedEventHandler(DisconnectedWork);

    // 確認Connection是否繼續存在,並持續監聽
    if (OnConnected != null)
        OnConnected(clientConn, EventArgs.Empty);
    _serverSocket.BeginAccept(new AsyncCallback(onConnect), null);
}

DisconnectedWork:Disconnected事件被觸發後,DisconnectedWork會在_connection中移除中斷連線的Client
private void DisconnectedWork(WebSocketConnection sender, EventArgs ev)
{
    _connections.Remove(sender);
    sender.Close();
}

CreateAcceptKey:CreateAcceptKey為進行Sec-WebSocket-Key轉換成Base64字串格式的方法。
private String CreateAcceptKey(String key)
{
    String keyStr = key + GUID;
    byte[] hashBytes = ComputeHash(keyStr);
    return Convert.ToBase64String(hashBytes);
}

ComputeHash:ComputeHash為使用SHA1加密成ASCII Byte陣列。
private byte[] ComputeHash(String str)
{
    return _sha1.ComputeHash(System.Text.Encoding.ASCII.GetBytes(str));
}

WebSocketConnection

(1) 相關欄位與屬性:

_connection_connection_serverSocket所接收到的ClientSocket

_dataBuffer:主要作為Client端接收過來的資料緩衝區。

OnDataReceived:當此連線收到Client端所傳送的資料時,會觸發DataReceived事件。

OnDisconnected:當該Client連線中斷時,會觸發Disconnected事件,將連線中斷。


private Socket _connection = null;
// 存放資料的buffter
private Byte[] _dataBuffer = new Byte[256];
public event DataReceivedEventHandler OnDataReceived;
public event ClientDisconnectedEventHandler OnDisconnected;

(2) 相關方法

listenlisten()方法會監聽Client是否有傳送資料,如果有收到資料,就將資料暫存到_dataBuffer裡,然後呼叫Read()方法進行資料的讀取。
private void listen()
{
    _connection.BeginReceive(_dataBuffer, 0, _dataBuffer.Length, SocketFlags.None, Read, null);
}

ReadRead()_dataBuffer中的資料根據RFC-6455所規定的訊息格式進行解析,首先判斷傳入的訊息是否為正常長度(程式碼48),正常的訊息長度最少包含了訊息格式的前2byte。接著要判斷第一個byte內的FIN bit是否為1FIN1代表此Data Frame為該訊息串的最後一個Frame,在這裡我們使用&運算子與0x80(10000000),因為本範例為簡易的Server範例,僅簡單處理Single Data Frame的狀況,故暫不處理超過一個Frame的訊息串。下一步判斷Client傳送過來的訊息串是否有Mask,在WebSocket協定中規定Client端傳給Server端的資料需要進行Mask,而Server傳給Client的資料不須進行Mask,這裡我們將第二個Byte0x80進行&運算,如果Mask bit不為1代表Client端傳送的資料沒有Mask,是個錯誤的訊息格式,依照標準需要中斷連線。再來要由第二個Byte中取出資料長度與Mask Key,由計算出的長度從訊息串中取出等長的byteClient Mask後的實際訊息,然後在透過Mask Key與協定中所提供的解碼演算法將訊息進行解碼,最後再將解碼後的Byte陣列轉換為UTF8字串,即為實際訊息。
private void Read(IAsyncResult result)
{
    var receivedSize = _connection.EndReceive(result);
    if (receivedSize > 2)
    {
        // 判斷是否為最後一個Frame(第一個bit為FIN若為1代表此Frame為最後一個Frame),超過一個Frame暫不處理
        if (!((_dataBuffer[0] & 0x80) == 0x80))     
        {
            Console.WriteLine("Exceed 1 Frame. Not Handle");     
            return;
        }
        // 是否包含Mask(第一個bit為1代表有Mask),沒有Mask則不處理
        if (!((_dataBuffer[1] & 0x80) == 0x80))     
        {
            Console.WriteLine("Exception: No Mask");
            OnDisconnected(this, EventArgs.Empty);
            return;
        }
        // 資料長度 = dataBuffer[1] - 127
        var payloadLen = _dataBuffer[1] & 0x7F;    
        var masks = new Byte[4];
        var payloadData = filterPayloadData(ref payloadLen, ref masks);
        // 使用WebSocket Protocol中的公式解析資料
        for (var i = 0; i < payloadLen; i++)
            payloadData[i] = (Byte)(payloadData[i] ^ masks[i % 4]);

        // 解析出的資料
        var content = Encoding.UTF8.GetString(payloadData);
        Console.WriteLine("Received Data: {0}", content);

        // 確認是否繼續接收資料,並持續監聽
        if (OnDataReceived != null)
            OnDataReceived(this, new DataReceivedEventArgs(content));
        listen();
    }
    else
    {
        Console.WriteLine("Receive Error Data Frame");
        if (OnDisconnected != null)
            OnDisconnected(this, EventArgs.Empty);
    }
}

filterPayLoadDataWebSocket協定中,規定如果Payload Len126127時,代表Payload Data裡含有Extend Data(一般Single Data Frame中只含有Application Data),若為126,則Payload Len_dataBuffer第二個Byte的後7bit加上_dataBuffer第二個Byte後一個Byte,共7+16 bit;若為127,則加上後8Byte,共7+64 bit
private Byte[] filterPayloadData(ref int length, ref Byte[] masks)
{
    Byte[] payloadData;
    switch (length)
    {
        // 包含16 bit Extend Payload Length
        case 126:               
            Array.Copy(_dataBuffer, 4, masks, 0, 4);
            length = (UInt16)(_dataBuffer[2] << 8 | _dataBuffer[3]);
            payloadData = new Byte[length];
            Array.Copy(_dataBuffer, 8, payloadData, 0, length);
            break;
        // 包含 64 bit Extend Payload Length
        case 127:               
            Array.Copy(_dataBuffer, 10, masks, 0, 4);
            var uInt64Bytes = new Byte[8];
            for (int i = 0; i < 8; i++)
            {
                uInt64Bytes[i] = _dataBuffer[9 - i];
            }
            UInt64 len = BitConverter.ToUInt64(uInt64Bytes, 0);

            payloadData = new Byte[len];
            for (UInt64 i = 0; i < len; i++)
                payloadData[i] = _dataBuffer[i + 14];
            break;
        // 沒有 Extend Payload Length
        default:                
            Array.Copy(_dataBuffer, 2, masks, 0, 4);
            payloadData = new Byte[length];
            Array.Copy(_dataBuffer, 6, payloadData, 0, length);
            break;
    }
    return payloadData;
}

SendServer要傳送資料至Client端時,我們需要將傳送的訊息編組成標準的訊息字串Byte陣列送出,Client端的瀏覽器收到資料後才能解析出訊息。首先我們先分析訊息長度,如果訊息長度大於等於126且小於等於65535(0xFFFF)則需要將長度加長到2 Bytes且長度前須加上一個值為126Byte;若訊息長度大於65535時,需要將長度加長到8 Bytes且長度前須加上一個值為127Byte
public void Send(Object data)
{
    if (_connection.Connected)
    {
        try
        {
            // 將資料字串轉成Byte
            var contentByte = Encoding.UTF8.GetBytes(data.ToString());
            var dataBytes = new List();

            if (contentByte.Length < 126)   // 資料長度小於126,Type1格式
            {
                // 未切割的Data Frame開頭
                dataBytes.Add((Byte)0x81);              
                dataBytes.Add((Byte)contentByte.Length);
                dataBytes.AddRange(contentByte);
            }
            else if (contentByte.Length <= 65535)       // 長度介於126與65535(0xFFFF)之間,Type2格式
            {
                dataBytes.Add((Byte)0x81);
                dataBytes.Add((Byte)0x7E);              // 126
                // Extend Data 加長至2Byte
                dataBytes.Add((Byte)((contentByte.Length >> 8) & 0xFF));
                dataBytes.Add((Byte)((contentByte.Length) & 0xFF));
                dataBytes.AddRange(contentByte);
            }
            else                 // 長度大於65535,Type3格式
            {
                dataBytes.Add((Byte)0x81);
                dataBytes.Add((Byte)0x7F);              // 127
                // Extned Data 加長至8Byte
                dataBytes.Add((Byte)((contentByte.Length >> 56) & 0xFF));
                dataBytes.Add((Byte)((contentByte.Length >> 48) & 0xFF));
                dataBytes.Add((Byte)((contentByte.Length >> 40) & 0xFF));
                dataBytes.Add((Byte)((contentByte.Length >> 32) & 0xFF));
                dataBytes.Add((Byte)((contentByte.Length >> 24) & 0xFF));
                dataBytes.Add((Byte)((contentByte.Length >> 16) & 0xFF));
                dataBytes.Add((Byte)((contentByte.Length >> 8) & 0xFF));
                dataBytes.Add((Byte)((contentByte.Length) & 0xFF));
                dataBytes.AddRange(contentByte);
            }
            _connection.Send(dataBytes.ToArray());
        }
        catch(Exception ex)
        {
            Console.WriteLine(ex.Message);
            if (OnDisconnected != null)
                OnDisconnected(this, EventArgs.Empty);
        }
    }
}

DataReceivedEventArgs

為了在觸發事件中取得Server接收到Client傳送的資料進行處理,在這裡實作了DataReceivedEventArgs取得接收資料,並根據需求可傳送到其他Client端。
public class DataReceivedEventArgs : EventArgs
{
    // OnReceive事件發生時傳入的資料字串
    public String Data { get; private set; }

    public DataReceivedEventArgs(String data)
    {
        Data = data;
    }
}


WebSocket Client

WebSocket Client的實作方式非常簡單,只要在HTML5網頁Java Script區段中使用WebSocket API,並指定WebSocket Server的位址就可以連線。在範例程式碼中,要先在<html> tag上加上<!DOCTYPE html>表示該網頁為HTML5,接著再Java Script區段中使用if ("WebSocket" in window)可以判斷該瀏覽器是否有支援WebSocket,在建立WebSocket時要先宣告WebSockt物件並指定位址,以下JavaScript區段的範例程式碼。
    $(function() {
        if ("WebSocket" in window)
        {
            var url = "ws://localhost:1104";
            var ws = new WebSocket(url);
            ws.onopen = function()
            {
                alert("Connected");
            };
            ws.onmessage = function (evt) 
            { 
                var received_msg = evt.data;
                alert("Received " + received_msg)
            };
            ws.onclose = function()
            { 
                alert("Closed"); 
            }
            ws.onerror = function()
            {
                alert("Error");
            }
     
            $('#SendMsgBtn').click(function(event){
                ws.send($('#MyInput').val());
            });
     
            $('#CloseBtn').click(function(event){
                ws.close();
            });
        }
        else
        {
            alert("WebSocket NOT supported by your Browser!");
        }
    });

以上範例解說說明了WebSocket協定中所制定的基本收送Data Frame格式,在協定中還有許多進階的Data Frame參數功能說明,在此便不多作說明。另外,在.Net Framework中已有許多開發團隊已開發了許多WebSocket套件,如Alchemy WebSocketSuperWebSocketWebSocket4Net等,Java則有JWebSocketWebSocket4J等函式庫,這些套件都是為了讓開發者能夠更簡單快速的使用WebSocket進行開發,在下圖中,我可以看到範例執行的結果,透過WebSocket,來自不同Client端的訊息能夠快速的Broadcast到其他Client的視窗上,連線的生命週期也能有效的控管。








參考來源:
WebSocket – 新一代網路傳輸技術

站內相關文章:
SignalR - .Net Real Time Web傳輸技術
WebSocket - 新一代網路傳輸技術WebSocket簡介 
HTML5 - Server-Send Event (SSE)



留言

  1. 您好,我利用範例code試寫OK了,
    但當Send資料長度大於65535,瀏覽器會收不到資料,約幾秒後C#程式就會斷訊.
    不知該如何解?

    回覆刪除
    回覆
    1. 大大比較厲害..小弟是連編譯都有問題
      比方說 public event ClientConnectedHandler OnConnected;
      直接紅色一條線 = =

      刪除
    2. https://stackoverflow.com/questions/17693956/websocket-sending-messages-over-65535-bytes-fails

      contentByte.Length 轉型為long

      刪除

張貼留言