Имплементируем WebSocket протокол на Go
Начнем с написания простого веб-сервера.
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", wsHandler)
http.ListenAndServe(":8000", nil)
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
fmt.Println(r.Header)
fmt.Fprintln(w, "Hello, World!")
}
Благодаря стандартной библиотеке написать многопоточный веб-сервер на Go проще чем на любом другом языке.
Для тех, кто незнаком с Go
Код на Go организован в виде пакетов. Пакет состоит из одного или нескольких файлов в одной директории. Каждый исходный файл начинается с объявления пакета, которому принадлежит данный файл. Затем должен следовать список пакетов, которые этот файл импортирует, а после — объявления программы. Порядок объявления функций, переменных, констант, типов по большой части значения не имеет.
Пакет main
определяет исполняемый файл, а не библиотеку. С функции main
начинается программа.
Пакет fmt
содержит функции форматированного вывода.
Пакет в пакете net/http
содержит имплементацию сервера. Обращаемся к пакету по имени последнего компонента. Функция HandleFunc
связывает функцию-обработчик с входящим URL. ListenAndServe
запускает сервер, прослушивающий порт 8000
в ожидании входящих запросов, является блокирующим вызовом. Каждый запрос обрабатывается в собственном легковесном потоке (горутине).
*http.Request — конкретный тип. В данном случае — указатель на структуру.
http.ResponseWriter — интерфейсный тип. Интерфейс — абстрактный тип. Скрывает внутреннюю структуру своих значений. Определяет какое поведение предоставляется своими методами. Внутри интерфейса может быть любой конкретный тип, поддерживающий методы интерфейса.
Откроем браузер и проверим результат.
Hello, World from "/"!
Откроем консоль браузера и попытаемся установить WebSocket-соединение с нашим сервером.
const ws = new WebSocket("ws://127.0.0.1:8000");
Что неудивительно — попытка провалилась.
WebSocket connection to 'ws://127.0.0.1:8000/' failed:
Настало время поближе познакомиться с протоколом WebSocket. И начать нужно с чтения стандарта RFC 6455.
WebSocket — протокол поверх единственного TCP-соединения, предназначенный для двустороннего обмена сообщениями. Подходит для написания приложений реального времени. Поддерживается в каждом современном браузере.
Протокол состоит из двух частей: открытия соединения (handshake) и обмена данными.
Клиент Сервер
| |
| HTTP Upgrade Request |
+------------------------------------>|
| |
| Открытие соединения |
| |
|<------------------------------------+
| HTTP Response |
| |
| |
| |
| |
| |
| Обмен данными |
|<----------------------------------->|
| (двунаправленный, полнодуплексный) |
| |
| |
Клиент отправляет запрос на открытие, сервер отвечает. Если открытие соединения прошло успешно, то клиент и сервер могут начать обмениваться сообщениями (messages) по двустороннему каналу связи.
Открытие соединения (handshake)
Протокол WebSocket использует существующую HTTP-инфраструктуру и технологии (прокси, аутентификация). Поддерживает работу поверх стандартных HTTP-портов 80, 443. Поэтому открытие соединения происходит в HTTP среде и сервер на единственном порту может обслуживать HTTP-запросы и WebSocket-клиентов. Открытие соединения начинается с HTTP Upgrade запроса.
Немного модифицированный вывод сервера из предыдущего примера кода:
map[
Accept-Encoding:[gzip, deflate, br]
Accept-Language:[en-US,en;q=0.9]
Cache-Control:[no-cache]
Connection:[Upgrade]
Origin:[http://127.0.0.1:8000]
Pragma:[no-cache]
Sec-Websocket-Extensions:[permessage-deflate; client_max_window_bits]
Sec-Websocket-Key:[dGhlIHNhbXBsZSBub25jZQ==]
Sec-Websocket-Version:[13]
Upgrade:[websocket]
User-Agent:[Mozilla/5.0 (X11; Linux x86_64) ...]
]
Обратите внимание на поля Connection: Upgrade
и Upgrade: websocket
. Клиент явным образом заявляет, что хочет сменить протокол.
Самым важным является Sec-WebSocket-Key
. В доказательство, что сервер получил запрос на открытие соединения сервер должен сложить значение ключа с Глобальным Уникальным Идентификатором (Globally Unique Identifier, GUID) "258EAFA5-E914-47DA- 95CA-C5AB0DC85B11" в строковой форме (конкатенировать), вычислить SHA-1 хэш-сумму и приложить к ответу закодировав с помощью base64.
base64(SHA-1(Sec-WebSocket-Key + GUID))
Sec-WebSocket-Key
dGhlIHNhbXBsZSBub25jZQ==
GUID
258EAFA5-E914-47DA-95CA-C5AB0DC85B11
+
GhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
SHA-1
b3 7a 4f 2c c0 62 4f 16 90 f6 46 06 cf 38 59 45 b2 be c4 ea
base64
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Пример ответа сервера:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Модифицируем нашу функцию-обработчик.
func wsHandle(w http.ResponseWriter, r *http.Request) {
// проверяем заголовки
if r.Header.Get("Upgrade") != "websocket" {
return
}
if r.Header.Get("Connection") != "Upgrade" {
return
}
k := r.Header.Get("Sec-Websocket-Key")
if k == "" {
return
}
// вычисляем ответ
sum := k + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
hash := sha1.Sum([]byte(sum))
str := base64.StdEncoding.EncodeToString(hash[:])
// Берем под контроль соединение https://pkg.go.dev/net/http#Hijacker
hj, ok := w.(http.Hijacker)
if !ok {
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
defer conn.Close()
// формируем ответ
bufrw.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
bufrw.WriteString("Upgrade: websocket\r\n")
bufrw.WriteString("Connection: Upgrade\r\n")
bufrw.WriteString("Sec-Websocket-Accept: " + str + "\r\n\r\n")
bufrw.Flush()
// выводим все, что пришло от клиента
buf := make([]byte, 1024)
for {
n, err := bufrw.Read(buf)
if err != nil {
return
}
fmt.Println(buf[:n])
}
}
Для тех, кто незнаком с Go
Внутри функций для объявления и инициализации переменных может использоваться краткая форма объявления переменной вида name := expression
. Тип переменной name выводится из expression
.
Общий вид объявления переменной имеет вид var name type = expression
. Часть type
или = expression
может быть опущена, но не обе. Тип может выводиться из выражения. Если опущено выражение, то начальным значением является нулевое значение.
В одном объявлении можно объявить и инициализировать несколько переменных.
Если некоторые переменные уже объявлены, то для этих переменных краткие объявления работают как присваивания.
Функции в Go могут возвращать несколько значений.
Декларация типа (type assertion) — операция применяемая к значению-интерфейсу. Выглядит как x.(T)
, где x
— выражение интерфейсного типа, а T
является типом, именуемым "декларируемым" (asserted). В данном случае декларация типов проверяет, соответствует ли динамический тип x интерфейсу T
. Если проверка прошла успешно результат будет иметь тип интерфейса T
. Дополнительный второй результат булева типа указывает на успех операции.
Инструкция defer
является обычным вызовом функции или метода, вызов которого откладывается до завершения функции, содержащей инструкцию.
Цикл for
является единственной инструкцией цикла.
for инициализация; условие; последействие {
// ...
}
Любая из частей может быть опущена. В данном случае образуется бесконечный цикл.
[]T
— объявление слайса, среза (slice) в Go. Слайс — динамический массив.
Слайс может быть создан с помощью встроенной функции make
.
func make([]T, len, cap) []T
— сигнатура функции. Функция принимает тип, длину и опциональную емкость. Если емкость опущена, то емкость равна длине.
Снова попытаемся установить WebSocket-соединение.
const ws = new WebSocket("ws://127.0.0.1:8000");
ws.readyState; // 1
ws.send("Hello, World!");
Соединение установлено о чем свидетельствует свойство readyState
со значением 1
— "OPEN"
.
В терминале мы тоже можем наблюдать полученные данные.
[129 141 ...]
Теперь попробуем их расшифровать.
Обмен данными
Клиент и сервер обмениваются сообщениями (messages) по двустороннему каналу связи. Внутри сообщения состоят из одного или нескольких фрагментов, фреймов (frames).
Фреймы могут содержать текстовые, бинарные данные или служебную информацию.
Структура фрейма:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
Секция FIN размером 1 бит указывает:
является ли фрейм последним в сообщении.
RSV1, RSV2, RSV3: 1 бит на каждую секцию:
используются расширениями протокола.
Opcode: 4 бита
определяют как интерпретировать передаваемые данные (Payload Data).
0x0
фрейм-продолжение для фрагментированного сообщения0x1
фрейм с текстовыми данными0x2
фрейм с бинарными данными0x8
фрейм для закрытия соединения...
Mask: 1 бит
Замаскированы ли данные. Все сообщения от клиента маскируются.
Payload length: 7 битов, 7+16 битов, 7+64 бита
Размер данных Payload Data
. Если значение находится в интервале 0 — 125, то это оно является размером. Если значение равно 126, то следующие два байта интерпретируются как 16-битное беззнаковое целое (16-bit unsigned integer) и содержат размер. Если значение равно 127, то следующие восемь байт интерпретируются как 64-битное беззнаковое целое (64-bit unsigned integer) и содержат размер.
Masking-key: 0 или 4 байта
Если бит маски Mask
равен 1. То секция содержит 32-битное значение маскирующее данные Payload Data
. Все данные в теле фрейма, отправленные клиентом, маскируются.
Payload data: payload length
байт
Размер данных должен быть равен указанному в заголовке.
Каждый фрейм имеет заголовок размером 2 — 14 байт.
Алгоритм расшифровки таков:
Прочитать первые два байта. Узнать является ли фрейм фрагментированным, опкод, замаскированы ли данные, размер оставшегося заголовка.
Прочитать оставшийся заголовок. Узнать размер данных и маскировочный ключ.
Прочитать данные равные размеру и размаскировать.
Разберем вывод из предыдущего примера.
последний фрейм в сообщении
|
| фрейм содержит текстовые данные
| |
| | данные замаскированы
| ++-+ |
| | | |
10000001 10001101
| |
+--+--+
|
размер данных - 13 байт
В нашем случае за первыми двумя байтами следует маска размером четыре байта и замаскированные данные размером тринадцать байт.
Для маскировки данных применяется XOR (исключающее "или"). Чтобы размаскировать данные, каждый i
байт данных мы XOR-им с i MOD 4 (i%4)
байтом маски.
исходное сообщение
Hello.
72 101 108 108 111 46
маска
0 1 2 3
замаскированное сообщение
72^0 101^1 108^2 108^3 111^0 46^1
72 100 110 111 111 47
Маскирование данных применяется для защиты кэширующих прокси-серверов от атаки "отравленный кэш" (cache poisoning). Подробнее в спецификации.
Перепишем функцию-обработчик.
func wsHandle(w http.ResponseWriter, r *http.Request) {
conn, bufrw, err := acceptHandshake(w, r)
if err != nil {
return
}
defer conn.Close()
// сообщение состоит из одного или нескольких фреймов
var message []byte
for {
// заголовок состоит из 2 — 14 байт
buf := make([]byte, 2, 12)
// читаем первые 2 байта
_, err := bufrw.Read(buf)
if err != nil {
return
}
finBit := buf[0] >> 7 // фрагментированное ли сообщение
opCode := buf[0] & 0xf // опкод
maskBit := buf[1] >> 7 // замаскированы ли данные
// оставшийся размер заголовка
extra := 0
if maskBit == 1 {
extra += 4 // +4 байта маскировочный ключ
}
size := uint64(buf[1] & 0x7f)
if size == 126 {
extra += 2 // +2 байта размер данных
} else if size == 127 {
extra += 8 // +8 байт размер данных
}
if extra > 0 {
// читаем остаток заголовка extra <= 12
buf = buf[:extra]
_, err = bufrw.Read(buf)
if err != nil {
return
}
if size == 126 {
size = uint64(binary.BigEndian.Uint16(buf[:2]))
buf = buf[2:] // подвинем начало буфера на 2 байта
} else if size == 127 {
size = uint64(binary.BigEndian.Uint64(buf[:8]))
buf = buf[8:] // подвинем начало буфера на 8 байт
}
}
// маскировочный ключ
var mask []byte
if maskBit == 1 {
// остаток заголовка, последние 4 байта
mask = buf
}
// данные фрейма
payload := make([]byte, int(size))
// читаем полностью и ровно size байт
_, err = io.ReadFull(bufrw, payload)
if err != nil {
return
}
// размаскировываем данные с помощью XOR
if maskBit == 1 {
for i := 0; i < len(payload); i++ {
payload[i] ^= mask[i%4]
}
}
// складываем фрагменты сообщения
message = append(message, payload...)
if opCode == 8 { // фрейм закрытия
return
} else if finBit == 1 { // конец сообщения
fmt.Println(string(message))
message = message[:0]
}
}
}
// func acceptHandshake(w http.ResponseWriter, r *http.Request)
// (net.Conn, *bufio.ReadWriter, error)
Для тех, кто незнаком с Go
T(val)
— конвертация типа.
Чтобы отправить сообщение, нам не потребуется маскировать данные.
func wsHandle(w http.ResponseWriter, r *http.Request) {
conn, bufrw, err := acceptHandshake(w, r)
if err != nil {
return
}
defer conn.Close()
var message []byte
for {
f, err := readFrame(bufrw)
if err != nil {
return
}
message = append(message, f.payload...)
buf := make([]byte, 2)
buf[0] |= f.opCode
if f.isFin {
buf[0] |= 0x80
}
if f.length < 126 {
buf[1] |= byte(f.length)
} else if f.length < 1<<16 {
buf[1] |= 126
size := make([]byte, 2)
binary.BigEndian.PutUint16(size, uint16(f.length))
buf = append(buf, size...)
} else {
buf[1] |= 127
size := make([]byte, 8)
binary.BigEndian.PutUint64(size, f.length)
buf = append(buf, size...)
}
buf = append(buf, f.payload...)
bufrw.Write(buf)
bufrw.Flush()
if f.opCode == 8 {
fmt.Println(buf)
return
} else if f.isFin {
fmt.Println(string(message))
message = message[:0]
}
}
}
type frame struct {
isFin bool
opCode byte
length uint64
payload []byte
}
// func readFrame(bufrw *bufio.ReadWriter) (frame, error)
// func acceptHandshake(w http.ResponseWriter, r *http.Request)
// (net.Conn, *bufio.ReadWriter, error)
Любая из сторон может инициировать закрытие соединения. Инициатор отправляет close frame
(опкод = 8). В данных может приложить close status
(uint16). Также может приложить причину закрытия (текстовое сообщение UTF-8, следующее за ). Оба компонента опциональны.
1000
нормальное закрытие1001
конечная сторона "ушла" (клиент закрыл вкладку)1002
ошибка протокола...
Другая сторона отвечает соответственно.
Клиент Сервер
| |
| |
| |
| Close frame |
+------------------------->|
| |
| Чистое закрытие |
| |
|<-------------------------+
Проверим нашу реализацию.
var ws = new WebSocket("ws://127.0.0.1:8000");
ws.onmessage = e => console.log(e.data);
ws.onclose = e => console.log(e.wasClean);
ws.send("Hello!"); // Hello!
ws.close(); // true
Заключение
В результате у нас получился простой WebSocket эхо-сервер.
В потенциальной следующей статье можно заняться тестированием с помощью внутренних средств и сторонних утилит (AutobahnTestsuite). Или заняться производительностью — избавиться от лишних горутин, используя epoll. Или, обвешав бенчмарками, сравнить с имплементацией на Rust.