Имплементируем WebSocket протокол на Go

f089341fbdd3b608593c81a7688dcaa9

Начнем с написания простого веб-сервера.

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 байт.

Алгоритм расшифровки таков:

  1. Прочитать первые два байта. Узнать является ли фрейм фрагментированным, опкод, замаскированы ли данные, размер оставшегося заголовка.

  2. Прочитать оставшийся заголовок. Узнать размер данных и маскировочный ключ.

  3. Прочитать данные равные размеру и размаскировать.

Перепишем функцию-обработчик.

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.

© Habrahabr.ru