Websocket API на nodejs по новому
О чем эта статья?
uWebsockets.js — высокопроизводительная реализация http/websocket сервера для nodejs
AsyncAPI — спецификация для асинхронного API, с помощью которой можно создать описание Websocket API
Простой пример websocket API с использованием библиотеки wsapix:
создадим websocket сервер, используя uWebsockets.js
настроим валидацию получаемых и отправляемых сообщений
добавим генерацию документации из кода
Краткое описание технологии
Websocket — это протокол связи поверх TCP-соединения, предназначенный для обмена сообщениями между браузером и веб-сервером через постоянное соединение. Данные передаются по нему в обоих направлениях в виде «пакетов», без разрыва соединения и дополнительных HTTP-запросов.
WebSocket особенно хорош для сервисов, которые нуждаются в постоянном обмене данными, например онлайн игры, торговые площадки, работающие в реальном времени, и т.д.
Как использовать технологию в Nodejs
В Nodejs есть ряд библиотек, которые позволяют построить клиент-серверное взаимодействие по протоколу Websocket поверх нативной имплементации HTTP в Nodejs. Например нативная библиотека ws или очень популярная библиотека socket.io. Статью с более подробной информации про эти библиотеки можно найти на хабре.
Помимо нативной имплементации http/ws на Nodejs есть библиотека uWebsockets.js, которая написана на C++ и позволяет создать http/websocket сервер для Nodejs, превосходящий по производительности в несколько раз нативную библиотеку и на порядок socket.io.
Сравнение производительности имплементаций Websocket для Nodejs 12.18
Эта библиотека стремительно набирает популярность, уже сейчас ее скачивают более 1 м раз в месяц. Однако в настоящий момент существующие фреймворки не поддерживают uWebsockets.js.
Спецификация для описания Websocket API
Известная всем OpenAPI-спецификация, используемая для стандартизированного описание REST API, к сожалению, не подходит для описания websocket API, однако AsyncApi подходит прекрасно. Также как и OpenAPI спецификация не завязана на какой-то язык программирования, она удобна для использования как человеком, так и компьютерной программой, может быть двух форматов: JSON и YAML:
Данная спецификация также отлично подходит для описания API «event-driven» архитектуры через брокеры, такие как RabbitMQ, Apache Kafka, Solace и т.д.
Библиотеки, позволяющие генерировать OpenAPI спецификации из кода, уже реализованы почти на всех языках программирования. Для Nodejs такой подход реализован из коробки в Fastify и Nest.js. Данный подход помогает решить сразу несколько задач:
Документация на основе спецификации, сгенерированной из кода, будет 100% соответствовать API
Спецификацию можно использовать для тестирования запросов/ответов через postman или другие подобные сервисы
На Nodejs возможность использовать аналогичный подход для генерации AsyncAPI спецификации из код websocket сервиса мне не удалось найти, поэтому пришлось начать проект wsapix.
Пример Websocket сервера с использованием wsapix
В качестве примера напишем простой чат, в котором создадим websocket сервер на основе uWebsockets.js и подключим Ajv для валидации входящих/исходящих сообщений. Сервер и клиент будут обмениваться следующими сообщениями:
От сервера клиенту:
пользовать вошел в чат
пользователь вышел из чата
пользователь отправил текстовое сообщение
От клиента серверу:
Для начала нам нужно установить библиотеки:
uWebsockets.js — http/ws сервер
wsapix — фреймворк для создания websocket API
ajv — библиотека для валидации сообщений
typebox — для описания Json схемы
npm i @sinclair/typebox wsapix ajv
npm i github:uNetworking/uWebSockets.js#v19.3.0
Подключим библиотеки, создадим uWebsocket сервер на порту 3000:
import { App } from "uWebSockets.js"
import { Wsapix } from "wsapix"
import { Type } from "@sinclair/typebox"
import Ajv from "ajv"
const port = Number(process.env.PORT || 3000)
const server = App()
server.listen(port, () => {
console.log(`Server listen port ${port}`)
})
Создадим wsapix сервер и подключим валидацию входящих/исходящих сообщений:
const ajv = new Ajv({ strict: false })
const validator = (schema, data, error) => {
const valid = ajv.validate(schema, data)
if (!valid) {
error(ajv.errors!.map(({ message }) => message).join(",\n"))
}
return valid
}
const wsx = Wsapix.uWS({ server }, { validator })
Добавим простейшую проверку подключений — на этом шаге может быть реализована полноценная аунтификация подключенного пользователя, но для простоты будем передавать имя пользователя через параметр запроса:
wsx.use((client) => {
if (!client.query) {
// если имя не указано, прерываем подключение
return client.terminate(4000)
}
// сохраняем имя и генерируем id
client.state = { id: Date.now().toString(36), name: client.query }
})
Опишем схему и добавим контроллер для сообщений от клиентов:
const userMessageSchema = {
$id: "user:message",
description: "New user message",
payload: Type.Strict(Type.Object({
type: Type.String({ const: "user:message", description: "Message type" }),
text: Type.String({ description: "Message text" })
}, { $id: "user:message" }))
}
wsx.clientMessage({ type: "user:message" }, userMessageSchema, (client, data) => {
wsx.clients.forEach((c) => {
if (c === client) { return }
c.send({
type: "chat:message",
userId: client.state.userId,
text: data.text
})
})
})
Опишем схему для сообщений от сервера, которая будет использоваться для валидации исходящих сообщений, а также для генерации документации. Добавим обработчики событий подключения и разъединения клиентов:
// New chat message schema
const chatMessageSchema = {
$id: "chat:message",
description: "New message in chat",
payload: Type.Strict(Type.Object({
type: Type.String({ const: "chat:message", description: "Message type" }),
userId: Type.String({ description: "User Id" }),
text: Type.String({ description: "Message text" })
}, { $id: "chat:message" }))
}
wsx.serverMessage({ type: "chat:message" }, chatMessageSchema)
// User connect message schema
const userConnectedSchema = {
$id: "user:connected",
description: "User online status update",
payload: Type.Strict(Type.Object({
type: Type.String({ const: "user:connected", description: "Message type" }),
userId: Type.String({ description: "User id" }),
name: Type.String({ description: "User name" }),
}, { $id: "user:connected" }))
}
wsx.serverMessage({ type: "user:connected" }, userConnectedSchema)
// Handle connect event
wsx.on("connect", (client) => {
wsx.clients.forEach((c) => {
if (c === client) { return }
c.send({ type: "user:connected", ...client.state })
client.send({ type: "user:connected", ...c.state })
})
})
// User disconnect message schema
const userDisconnectedSchema = {
$id: "user:disconnected",
description: "User online status update",
payload: Type.Strict(Type.Object({
type: Type.String({ const: "user:disconnected", description: "Message type" }),
userId: Type.String({ description: "User Id" }),
name: Type.String({ description: "User name" }),
}, { $id: "user:disconnected" }))
}
wsx.serverMessage({ type: "user:disconnected" }, userDisconnectedSchema)
// Handle disconnect event
wsx.on("disconnect", (client) => {
wsx.clients.forEach((c) => {
if (c === client) { return }
c.send({
type: "user:disconnected",
...client.state
})
})
})
Добавим генерацию документации для Webscoket API из кода на главную страницу :
server.get("/", (res) => {
res.writeHeader('Content-Type', 'text/html')
res.end(wsx.htmlDocTemplate("/wsapix"))
})
server.get("/wsapix", (res) => {
res.writeHeader('Content-Type', 'application/json')
res.end(wsx.asyncapi({
info: {
version: "1.0.0",
title: "Chat websocket API"
}
}))
})
Проверяем полученный результат (http://localhost:3000/):
Пробуем подключиться и отравить сообщение:
Исходник данного примера доступен по ссылке
P.S. Если для вас не критична производительность или вы не хотите переходить с нативной библиотеки на uWebsockets.js, то можно создать сервер на базе нативной библиотеки http/ws:
import * as http from "http"
import express from "express"
import { Wsapix } from "wsapix"
const port = Number(process.env.PORT || 3000)
const app = express()
const server = new http.Server(app)
const wsx = Wsapix.WS({ server })
server.listen(port, () => {
console.log(`Server listen port ${port}`)
})