Написание сервера 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. В наших целях мы просто создадим простой ответ.
Вы должны:
- Получить значение заголовка запроса "Sec-WebSocket-Key" без лидирующих и завершающих пробелов.
- Сконкатенировать его со значением "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" (специальный GUID, указанный в RFC 6455).
- Вычислить SHA-1 и Base64 хеш нового значения.
- Записать хеш в качестве значения заголовка ответа
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>
Связанное
© 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