Spec-Zone.ru › Web APIs

Написание сервера WebSocket на C#

Если вы хотите использовать API WebSocket, вам пригодится сервер. В этой статье я покажу, как написать его на C#. Вы можете сделать это на любом языке серверной стороны, но для простоты и большей понятности я выбрал язык Microsoft.

Этот сервер соответствует RFC 6455, поэтому он будет обрабатывать подключения только из Chrome версии 16, Firefox 11, IE 10 и выше.

Первые шаги

WebSocket общаются по подключению TCP (Transmission Control Protocol). К счастью, C# имеет класс TcpListener, который делает именно то, что предполагает его название. Он находится в пространстве имён System.Net.Sockets.

Примечание: Хорошей идеей является включение пространства имён с ключевым словом using для сокращения кода. Это позволяет использовать классы пространства имён, не вводя полное имя пространства имён каждый раз.

TcpListener

Конструктор:

TcpListener(System.Net.IPAddress localAddr, int port)

localAddr указывает IP-адрес слушателя, а port — порт.

Примечание: Чтобы создать объект IPAddress из string, используйте статический метод Parse класса IPAddress.

Методы:

  • Start()
  • System.Net.Sockets.TcpClient AcceptTcpClient() Ожидает TCP-соединения, принимает его и возвращает его как объект TcpClient.

Вот базовая реализация сервера:

using System.Net.Sockets;
using System.Net;
using System;

class Server {
    public static void Main() {
        TcpListener server = new TcpListener(IPAddress.Parse("127.0.0.1"), 80);

        server.Start();
        Console.WriteLine("Server has started on 127.0.0.1:80.{0}Waiting for a connection…", Environment.NewLine);

        TcpClient client = server.AcceptTcpClient();

        Console.WriteLine("A client connected.");
    }
}

TcpClient

Методы:

  • System.Net.Sockets.NetworkStream GetStream() Получает поток, который является каналом связи. Обе стороны канала имеют возможность чтения и записи.

Свойства:

  • int Available Это свойство указывает, сколько байтов данных было отправлено. Значение равно нулю до тех пор, пока NetworkStream.DataAvailable не будет true.

NetworkStream

Методы:

  • Записывает байты из буфера. Смещение и размер определяют длину сообщения.

    Write(byte[] buffer, int offset, int size)
    
  • Считывает байты в buffer. offset и size определяют длину сообщения.

    Read(byte[] buffer, int offset, int size)
    

Давайте расширим наш пример.

TcpClient client = server.AcceptTcpClient();

Console.WriteLine("A client connected.");

NetworkStream stream = client.GetStream();

//enter to an infinite cycle to be able to handle every change in stream
while (true) {
    while (!stream.DataAvailable);

    byte[] bytes = new byte[client.Available];

    stream.Read(bytes, 0, bytes.Length);
}

Установление рукопожатия

Когда клиент подключается к серверу, он отправляет запрос GET для обновления соединения на WebSocket из простого HTTP-запроса. Это называется установлением рукопожатия.

Этот пример кода может распознать GET от клиента. Обратите внимание, что это заблокирует выполнение, пока не будут доступны первые 3 байта сообщения. Для производственных сред следует рассмотреть альтернативные решения.

using System.Text;
using System.Text.RegularExpressions;

while(client.Available < 3)
{
   // wait for enough bytes to be available
}

byte[] bytes = new byte[client.Available];

stream.Read(bytes, 0, bytes.Length);

//translate bytes of request to string
String data = Encoding.UTF8.GetString(bytes);

if (Regex.IsMatch(data, "^GET")) {

} else {

}

Ответ легко создать, но его может быть немного сложно понять. Полное объяснение рукопожатия сервера можно найти в RFC 6455, раздел 4.2.2. В наших целях мы просто создадим простой ответ.

Вы должны:

  1. Получить значение заголовка запроса "Sec-WebSocket-Key" без лидирующих и завершающих пробелов.
  2. Сконкатенировать его со значением "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" (специальный GUID, указанный в RFC 6455).
  3. Вычислить SHA-1 и Base64 хеш нового значения.
  4. Записать хеш в качестве значения заголовка ответа Sec-WebSocket-Accept в HTTP-ответе.
if (new System.Text.RegularExpressions.Regex("^GET").IsMatch(data))
{
    const string eol = "\r\n"; // HTTP/1.1 defines the sequence CR LF as the end-of-line marker

    byte[] response = Encoding.UTF8.GetBytes("HTTP/1.1 101 Switching Protocols" + eol
        + "Connection: Upgrade" + eol
        + "Upgrade: websocket" + eol
        + "Sec-WebSocket-Accept: " + Convert.ToBase64String(
            System.Security.Cryptography.SHA1.Create().ComputeHash(
                Encoding.UTF8.GetBytes(
                    new System.Text.RegularExpressions.Regex("Sec-WebSocket-Key: (.*)").Match(data).Groups[1].Value.Trim() + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
                )
            )
        ) + eol
        + eol);

    stream.Write(response, 0, response.Length);
}

Декодирование сообщений

После успешного рукопожатия клиент отправляет закодированные сообщения на сервер.

Если мы отправим "MDN", получим следующие байты:

129 131 61 84 35 6 112 16 109

Давайте рассмотрим, что означают эти байты.

Первый байт, который в данный момент имеет значение 129, — это битовое поле, которое разбивается следующим образом:

FIN (Бит 0) RSV1 (Бит 1) RSV2 (Бит 2) RSV3 (Бит 3) Код операции (Бит 4:7)
1 0 0 0 0x1=0001
  • Бит FIN: Этот бит указывает, отправлено ли полное сообщение клиентом. Сообщения могут отправляться в кадрах, но пока мы будем сохранять всё просто.
  • RSV1, RSV2, RSV3: Эти биты должны быть 0, если не настроено расширение, которое предоставляет для них ненулевое значение.
  • Код операции: Эти биты описывают тип полученного сообщения. Код операции 0x1 означает, что это текстовое сообщение. Полный список кодов операций

Второй байт, который в данный момент имеет значение 131, — это другое битовое поле, которое разбивается следующим образом:

МАСКА (Бит 0) Длина полезной нагрузки (Бит 1:7)
1 0x83=0000011
  • Бит МАСКИ: Определяет, замаскирована ли «полезная нагрузка». Если установлен в 1, ключ маскирования присутствует в «Ключе маскирования», и он используется для размаскирования «полезной нагрузки». Все сообщения от клиента до сервера имеют этот бит установленным.
  • Длина полезной нагрузки: Если это значение находится в диапазоне от 0 до 125, то это длина сообщения. Если это 126, следующие 2 байта (16-битовое беззнаковое целое число) — это длина. Если это 127, следующие 8 байтов (64-битовое беззнаковое целое число) — это длина.

Примечание: Поскольку первый бит всегда равен 1 для сообщений клиент-сервер, вы можете вычесть 128 из этого байта, чтобы избавиться от бита МАСКИ.

Обратите внимание, что бит МАСКИ установлен в нашем сообщении. Это означает, что следующие четыре байта (61, 84, 35 и 6) — это байты маски, используемые для декодирования сообщения. Эти байты меняются с каждым сообщением.

Остальные байты — это кодированная полезная нагрузка сообщения.

Алгоритм декодирования

D_i = E_i XOR M_(i mod 4)

где D — массив декодированного сообщения, E — массив закодированного сообщения, M — массив байтов маски, а i — индекс байта сообщения для декодирования.

Пример на C#:

byte[] decoded = new byte[3];
byte[] encoded = new byte[3] {112, 16, 109};
byte[] mask = new byte[4] {61, 84, 35, 6};

for (int i = 0; i < encoded.Length; i++) {
    decoded[i] = (byte)(encoded[i] ^ mask[i % 4]);
}

Сборка воедино

ws-server.cs

//
// csc ws-server.cs
// ws-server.exe

using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;

class Server {
    public static void Main() {
        string ip = "127.0.0.1";
        int port = 80;
        var server = new TcpListener(IPAddress.Parse(ip), port);

        server.Start();
        Console.WriteLine("Server has started on {0}:{1}, Waiting for a connection…", ip, port);

        TcpClient client = server.AcceptTcpClient();
        Console.WriteLine("A client connected.");

        NetworkStream stream = client.GetStream();

        // enter to an infinite cycle to be able to handle every change in stream
        while (true) {
            while (!stream.DataAvailable);
            while (client.Available < 3); // match against "get"

            byte[] bytes = new byte[client.Available];
            stream.Read(bytes, 0, bytes.Length);
            string s = Encoding.UTF8.GetString(bytes);

            if (Regex.IsMatch(s, "^GET", RegexOptions.IgnoreCase)) {
                Console.WriteLine("=====Handshaking from client=====\n{0}", s);

                // 1. Obtain the value of the "Sec-WebSocket-Key" request header without any leading or trailing whitespace
                // 2. Concatenate it with "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" (a special GUID specified by RFC 6455)
                // 3. Compute SHA-1 and Base64 hash of the new value
                // 4. Write the hash back as the value of "Sec-WebSocket-Accept" response header in an HTTP response
                string swk = Regex.Match(s, "Sec-WebSocket-Key: (.*)").Groups[1].Value.Trim();
                string swkAndSalt = swk + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
                byte[] swkAndSaltSha1 = System.Security.Cryptography.SHA1.Create().ComputeHash(Encoding.UTF8.GetBytes(swkAndSalt));
                string swkAndSaltSha1Base64 = Convert.ToBase64String(swkAndSaltSha1);

                // HTTP/1.1 defines the sequence CR LF as the end-of-line marker
                byte[] response = Encoding.UTF8.GetBytes(
                    "HTTP/1.1 101 Switching Protocols\r\n" +
                    "Connection: Upgrade\r\n" +
                    "Upgrade: websocket\r\n" +
                    "Sec-WebSocket-Accept: " + swkAndSaltSha1Base64 + "\r\n\r\n");

                stream.Write(response, 0, response.Length);
            } else {
                bool fin = (bytes[0] & 0b10000000) != 0,
                    mask = (bytes[1] & 0b10000000) != 0; // must be true, "All messages from the client to the server have this bit set"
                int opcode = bytes[0] & 0b00001111; // expecting 1 - text message
                ulong offset = 2,
                      msglen = bytes[1] & (ulong)0b01111111;

                if (msglen == 126) {
                    // bytes are reversed because websocket will print them in Big-Endian, whereas
                    // BitConverter will want them arranged in little-endian on windows
                    msglen = BitConverter.ToUInt16(new byte[] { bytes[3], bytes[2] }, 0);
                    offset = 4;
                } else if (msglen == 127) {
                    // To test the below code, we need to manually buffer larger messages — since the NIC's autobuffering
                    // may be too latency-friendly for this code to run (that is, we may have only some of the bytes in this
                    // websocket frame available through client.Available).
                    msglen = BitConverter.ToUInt64(new byte[] { bytes[9], bytes[8], bytes[7], bytes[6], bytes[5], bytes[4], bytes[3], bytes[2] },0);
                    offset = 10;
                }

                if (msglen == 0) {
                    Console.WriteLine("msglen == 0");
                } else if (mask) {
                    byte[] decoded = new byte[msglen];
                    byte[] masks = new byte[4] { bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3] };
                    offset += 4;

                    for (ulong i = 0; i < msglen; ++i)
                        decoded[i] = (byte)(bytes[offset + i] ^ masks[i % 4]);

                    string text = Encoding.UTF8.GetString(decoded);
                    Console.WriteLine("{0}", text);
                } else
                    Console.WriteLine("mask bit not set");

                Console.WriteLine();
            }
        }
    }
}

client.html

<!doctype html>
<html lang="en">
  <style>
    textarea {
      vertical-align: bottom;
    }
    #output {
      overflow: auto;
    }
    #output > p {
      overflow-wrap: break-word;
    }
    #output span {
      color: blue;
    }
    #output span.error {
      color: red;
    }
  </style>
  <body>
    <h2>WebSocket Test</h2>
    <textarea cols="60" rows="6"></textarea>
    <button>send</button>
    <div id="output"></div>
  </body>
  <script>
    // http://www.websocket.org/echo.html
    const button = document.querySelector("button");
    const output = document.querySelector("#output");
    const textarea = document.querySelector("textarea");
    const wsUri = "ws://127.0.0.1/";
    const websocket = new WebSocket(wsUri);

    button.addEventListener("click", onClickButton);

    websocket.onopen = (e) => {
      writeToScreen("CONNECTED");
      doSend("WebSocket rocks");
    };

    websocket.onclose = (e) => {
      writeToScreen("DISCONNECTED");
    };

    websocket.onmessage = (e) => {
      writeToScreen(`<span>RESPONSE: ${e.data}</span>`);
    };

    websocket.onerror = (e) => {
      writeToScreen(`<span class="error">ERROR:</span> ${e.data}`);
    };

    function doSend(message) {
      writeToScreen(`SENT: ${message}`);
      websocket.send(message);
    }

    function writeToScreen(message) {
      output.insertAdjacentHTML("afterbegin", `<p>${message}</p>`);
    }

    function onClickButton() {
      const text = textarea.value;

      text && doSend(text);
      textarea.value = "";
      textarea.focus();
    }
  </script>
</html>

Связанное

  • Написание WebSocket-серверов

© 2005–2024 MDN contributors.
Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later.
https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_server

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API