Как создать многопользовательский чат с помощью WebSocket за 10 минут

Демонстрационная версия

Схематичное представление проекта

Введение

Чаты, которыми мы привычно пользуемся, кажутся чем-то загадочным. На самом деле они традиционно разрабатываются с использованием Websocket. В этой статье кратко изложена основная информация о Websocket и реализована простая демонстрационная версия чата на несколько человек. После прочтения вы сможете использовать Websocket для создания чата.

Общие сведения о Websocket

Бэкграунд Websocket

Во многих случаях пользователям требуются сообщения в реальном времени, например для обмена мнениями, доступа к показаниям медицинских приборов и т.д. Традиционное решение заключается в получении актуальных данных на основе опроса. Однако оно не позволяет достичь полной синхронизации онлайн-сообщений. В большинстве случаев запросы оказываются неоправданными, расходуют много трафика и ресурсов сервера. Эта проблема и привела к появлению Websocket.

Основная концепция Websocket

Websocket  —  это протокол полнодуплексной связи на одном TCP-соединении, предоставляемый HTML5. Коммуникационный протокол Websocket появился в 2008 году и стал международным стандартом в 2011 году.

Websocket упрощает обмен данными между клиентом и сервером, позволяя серверу активно передавать данные клиенту. В API Websocket браузеру и серверу необходимо только завершить ”рукопожатие” (подтверждение установления связи), после чего они могут напрямую создавать постоянное соединение и осуществлять двустороннюю передачу данных.

Проблемы совместимости (поддерживается основными браузерами):

Особенности Websocket

  • Незначительное потребление ресурсов

После создания соединения, когда происходит обмен данными между сервером и клиентом, заголовок пакета для управления протоколом относительно невелик. Без расширения размер заголовка составляет всего от 2 до 10 байт для передачи контента от сервера к клиенту (в зависимости от длины пакета). Для передачи контента от клиента к серверу этот заголовок необходимо маскировать дополнительными 4 байтами. По сравнению с HTTP-запросами, которые должны каждый раз нести полный заголовок, такие затраты являются значительно низкими.

  • Связь в реальном времени

Поскольку протокол является полнодуплексным, сервер может активно отправлять данные клиенту в любое время. По сравнению с HTTP-запросами, серверу нужно ждать, пока клиент инициирует запрос, прежде чем ответить, и задержка значительно меньше. Даже по сравнению с длинными опросами, такими как Comet, в данном случае данные могут доставляться большее количество раз за короткое время.

  • Постоянное соединение

В отличие от HTTP, веб-сокету необходимо сначала создать соединение, поэтому он является протоколом состояний. В дальнейшем при обмене данными некоторая информация о состоянии может быть опущена.

  • Поддержка двоичной передачи

Вы можете передавать текстовые и двоичные данные. Websocket определяет двоичные фреймы, которые могут обрабатывать двоичный контент легче, чем HTTP.

  • Идентификатором протокола является ws (или wss при поддержке шифрования), а URL сервера  —  это URL
  • Простота реализации

Реализация серверной стороны основана на TCP-протоколе и относительно проста, а также не имеет ограничений по источникам. Клиент может общаться с любым сервером.

  • Хорошая совместимость с HTTP-протоколом

Порты по умолчанию также 80 и 443, а во время фазы “рукопожатия” используется HTTP-протокол. Поэтому заблокировать “рукопожатие” нелегко, и оно может проходить через различные HTTP-прокси-серверы.

  • Поддержка расширений

Websocket определяет расширения. Тут можно расширять протоколы и реализовывать субпротоколы, определяемые пользователем. Например, некоторые браузеры поддерживают сжатие.

Начальное “рукопожатие” WebSocket

Каждое соединение веб-сокета начинается с HTTP-запроса, который похож на другие запросы, но содержит специальный заголовок  —  Upgrade. Upgrade указывает на то, что клиент переведет соединение на протокол Websocket (т.е. обновит соединение).

До “рукопожатия” Websocket следует протоколу HTTP/1.1.

Запрос, отправляемый клиентом для перехода на Websocket, также называется начальным рукопожатием. После того как клиент отправляет HTTP-запрос на переход, соединение не будет успешным до тех пор, пока сервер не ответит кодом состояния 101, заголовком Upgrade и Sec WebSocket Accept. В противном случае соединение не удастся.

Ниже приведены заголовки запросов и соответствующие заголовки копируемого “рукопожатия” WebSocket:

// Заголовок запроса, отправляемый клиентом
GET wss://www.example.cn/webSocket HTTP/1.1 // Используемый протокол https и соответствующий запрос wss.
Host: www.example.cn
Connection: Upgrade // Сообщение http1.1 с заголовком обновления должно содержать заголовок соединения. Это означает, что любой, кто принимает это сообщение, удаляет домен, указанный в соединении, перед пересылкой сообщения (то есть не пересылает домен обновления).
Upgrade: websocket // Определите домен заголовка протокола преобразования. Если сервер поддерживает его, клиент будет использовать установленное соединение http (tcp).
Sec-WebSocket-Version: 13 // Список версий протокола WebSocket, поддерживаемых клиентом.
Origin: http://example.cn // Origin используется для предотвращения межсайтовых атак. Он помогает браузерам идентифицировать исходный домен.
Sec-WebSocket-Key: afmbhhBRQuwCLmnWDRWHxw== // Случайным образом генерируется клиентом. Сервер будет использовать это поле для сборки другого значения ключа и поместит его в возвращаемую информацию о "рукопожатии". Используется для начального "рукопожатия" между клиентским и серверным веб-сокетами, чтобы избежать межпротокольных атак.
Sec-WebSocket-Protocol: chat, superchat // Сообщает приложению клиента, какие протоколы доступны.
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits // permessage-deflate —— Сообщает приложению клиента, какие протоколы доступны. client_max_window_bits —— Договор об использовании сжатия данных при передаче


// Заголовки ответов, отправляемые сервером
HTTP/1.1 101
Server: nginx/1.12.2
Date: Sat, 11 Aug 2018 13:21:27 GMT
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Accept: sLMyWetYOwus23qJyUD/fa1hztc= // Confirm whether the server understands the websocket protocol
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Extensions: permessage-deflate;client_max_window_bits=15


/**
Этапы создания Sec-WebSocket-Accept的:
(1)Склейка Sec-WebSocket-Key с помощью GUID, определенного в соглашении.
(3)Base64 кодирует строку, сгенерированную в пункте 2
Sec-WebSocket-Accept используется для определения:
(1)Понимает ли сервер протокол Websocket? Если нет, то он не вернет корректный Sec-WebSocket-Accept.
(2)Возвращаемое значение - текущий запрос, а не предыдущий кэш.
*/

Сходства и различия между WebSocket и HTTP

  1. Идентичные моменты:
  • Оба являются протоколами прикладного уровня, основанными на TCP.
  • Оба используют модель Request/Response для установления соединений.
  • Метод обработки ошибок во время установления соединения у них одинаков. На этом этапе WebSocket может вернуть тот же код возврата, что и HTTP.

2. Различия:

  • HTTP-протокол основан на методе “запрос/ответ” (Request/Response). Он может осуществлять только одностороннюю передачу (полудуплексная связь), в то время как WebSocket является полнодуплексной связью.

Half duplex communication (полудуплексная связь): односторонний поток, сервер не активно передает данные клиенту.

Full duplex communication (полнодуплексная связь): сервер может активно передавать информацию клиенту, а клиент может также активно передавать информацию серверу. Это полноценный двусторонний равноправный диалог, работающий по технологии server push.

HTTP не фиксирует данные о поступающих запросах, поэтому запрос закрывается после получения ответа. Преимущество отсутствия фиксации данных о запросах в том, что серверу не нужно хранить соответствующую информацию о сессии. Недостатком является то, что каждый HTTP-запрос и ответ будут посылать избыточную информацию о запросе.

Между тем, WebSocket нужно только один раз установить пару сообщений Request/Response, за которой следует TCP-соединение, что позволяет избежать избыточной информации заголовков, генерируемой несколькими парами сообщений Request/Response. Это экономит существенное количество трафика и ресурсов сервера.

Когда WebSocket устанавливает соединение “рукопожатия”, данные передаются через HTTP-протокол , но после установления соединения фаза фактической передачи данных не требует участия HTTP-протокола. HTTP требуется три ”рукопожатия”.

Данные, передаваемые WebSocket, представляют собой двоичный поток, основанный на фреймах. Передача данных по HTTP-протоколу  —  это передача открытого текста, то есть передача строк.

API Websocket

WebSocket преобразует HTTP-протокол в WebSocket-протокол во время первого “рукопожатия” между клиентом и сервером. После установления соединения следующие сообщения напрямую передаются туда и обратно по методу, определенному интерфейсом WebSocket.

Экземпляр WebSocket

По сути WebSocket-протокол является протоколом на основе TCP. Вызовите конструктор WebSocket для создания WebSocket-соединения и возвращения экземпляра объекта WebSocket.

Протокол WebSocket определяет две URL-схемы.

  • ws  —  незашифрованный формат.
  • wss  —  шифрованный формат (используется механизм безопасности, принятый в HTTPS для обеспечения безопасности HTTP-соединений).
/**
* @param
* URL: connection target
* protocols(optional):string | string[]. имя протокола или набор имен протоколов
*/
const ws = new WebSocket(URL, protocols)

Чтобы установить соединение WebSocket, браузер клиента должен сначала отправить HTTP-запрос на сервер. Этот запрос отличается от обычного HTTP-запроса и содержит некоторую дополнительную информацию заголовка. Дополнительная информация заголовка “Upgrade: WebSocket” указывает на то, что это HTTP-запрос на обновление протокола.

Сервер анализирует дополнительную информацию заголовка и затем генерирует ответную информацию для возврата клиенту. Между клиентом и сервером устанавливается WebSocket-соединение. Обе стороны могут свободно передавать информацию по этому каналу связи. Соединение будет продолжаться до тех пор, пока один из клиентов или сервер активно не закроет соединение.

События Websocket

WebSocket ориентирован исключительно на события. Вы можете обрабатывать ввод данных и изменения состояния соединения, прослушивая события на объекте WebSocket. Ниже перечислены четыре события объекта WebSocket.

  • onopen: срабатывает после того, как клиент и сервер устанавливают соединение. Это называется начальным “рукопожатием” между клиентом и сервером. Если получен сигнал open, соединение успешно установлено и связь готова.
  • onmessage: срабатывает при получении сообщения. Сообщения, отправляемые сервером клиенту, могут включать открытые текстовые сообщения и двоичные данные (сообщения blob и ArrayBuffer).
  • onerror: срабатывает в ответ на неожиданный сбой. После ошибки соединение всегда прерывается.
  • onclose: срабатывает при закрытии соединения. После закрытия соединения клиент и сервер не смогут отправлять и получать сообщения. Для закрытия соединения можно также активно вызывать метод close().

Методы Websocket

  • send(): отправка сообщения после успешного соединения и перед закрытием (сообщения могут быть отправлены только после открытия и перед закрытием).
  • close(): закрытие соединения.

Свойства объектов Websocket

  • readyState: атрибут только для чтения, который указывает на состояние соединения веб-сокета. Значения следующие:
Constant property     | Value |   Status
--------------------------------------------------------------------------------------------------------
Websocket.CONNECTING | 0 | Соединение выполняется, но еще не было успешно установлено.

Websocket.OPEN | 1 | Соединение установлено, и сообщение может быть отправлено в обычном режиме.

Websocket.CLOSING | 2 | Соединение находится в процессе завершения "рукопожатия".

Websocket.CLOSED | 3 | Указывает на то, что соединение было закрыто или не может быть открыто.
  • bufferedAmount: свойство только для чтения. Количество текстовых байтов UTF-8, которые были поставлены в очередь посредством send() и ожидают передачи, но еще не были выданы.
  • Protocol: протокол, используемый во время открытого “рукопожатия”.

Создание простого чата с помощью Websocket

Базовое знакомство с WebSocket завершено. Теперь воспользуемся полученными знаниями, чтобы вручную реализовать демонстрационную версию чата.

Создание соединения

В нашем случае используем nodejs для реализации логики на стороне сервера и сторонний модуль связи WebSocket ws для создания простого WebSocket-соединения.

  1. Сначала необходимо установить ws.
npm i ws

2. Сервер. Создайте файл server.js, установите соединение WebSocket и запустите службу (здесь используется порт 3000). Используйте сторонний плагин ws для создания нового экземпляра WebSocket. После успешного подключения прослушайте событие onmessage для получения поступившего сообщения, прослушайте событие onclose для обработки логики соединения/разъединения и используйте метод send для отправки сообщений клиенту.

const Websocket = require('ws')
const wss = new Websocket.Server({ port: 3000 })

wss.on('connection', function (ws) { // Клиент подключен
ws.on('message', function (message) {
console.log('server receive message: ', message.toString())
})
ws.send('msg from server!')
ws.on('close', function (message) {
console.log('连接断开', message)
})
})

3. Клиент. Здесь Vue используется для создания базового проекта и соединения на странице home.vue. Сначала создайте экземпляр ws, используя нативный WebSocket, и отследите успешность соединения WebSocket посредством метода onopen (когда readyState равен “1”, соединение выполнено успешно). Затем обработайте логику входа пользователя в чат (open), используйте onmessage для получения сообщений сервера и onclose для отслеживания разъединения и выполните соответствующую обработку. Когда пользователь покидает чат, примените метод close вручную.

/* home.vue */

this.ws = new WebSocket('ws://localhost:3000')
console.log('before open', this.ws.readyState) // 0

// Прослушивание успешности создания соединения
this.ws.onopen = () => {
console.log('onopen', this.ws.readyState) // 1
this.roomOpen = true
this.ws.send(JSON.stringify({
userId: this.userName,
userName: this.nickname,
roomId: item.roomId,
roomName: item.name,
event: 'login', // Отправить на сервер сообщение о входе в систему с соответствующей информацией о чате и информацией о пользователе
}))
}

// Обратный вызов для полученного сообщения
this.ws.onmessage = (message) => {
console.log('The client receives the message', message)
}

// Получение уведомления о разъединении
this.ws.onclose = () => { // Callback to listen for websocket close
console.log('onclose', this.ws.readyState)
}

// Ручное отключение соединения websocket
close () {
this.ws && this.ws.close()
}

Статистика онлайн-статуса нескольких чатов и пользователей

Логика создания нескольких чатов осуществляется на стороне сервера. Здесь мы различаем несколько чатов в соответствии с roomId. Сначала создайте массив для хранения roomId. Каждый раз, когда создается новый чат, добавляйте roomId в массив. Если вы входите в уже созданный чат, добавьте “1” к количеству людей, находящихся онлайн в этом чате. Фронтенд отвечает за отправку на сервер уведомления о входе и выходе пользователя и соответствующей информации о пользователе, а также за рендеринг информации, необходимой странице, в соответствии с сообщением, отправленным сервером. Код выглядит следующим образом:

  • home.vue
// home.vue

this.ws.onopen = () => {
this.roomOpen = true
this.ws.send(JSON.stringify({
userId: this.userName,
userName: this.nickname,
roomId: item.roomId,
roomName: item.name,
event: 'login',
}))
}
this.ws.onmessage = (message) => {
const data = JSON.parse(message.data)

this.onlineNum = data.num
if (data.event === 'login') { // Сообщение о том, что другие пользователи заходят в чат
this.msgList.push({
content: `Welcome ${data.userName} to room ${data.roomName}~`,
})
} else if (data.event === 'logout') {
// Сообщение о том, что другие пользователи покидают чат
console.log('logout', data)
this.msgList.push({
content: `${data.userName} Leave the room`,
})
} else { // normal message
const self = this.userId === data.userId
if (self) return
this.msgList.push({
name: data.userName,
self: false,
content: data.content,
})
}
}
  • serve.js
// server.js

ws.on('message', function (message) {
console.log('server receive message: ', message.toString())
const data = JSON.parse(message.toString())

if (typeof ws.roomId === 'undefined' && data.roomId) {
ws.roomId = data.roomId
if (typeof group[ws.roomId] === 'undefined') {
group[ws.roomId] = 1
} else {
group[ws.roomId]++
}
}

data.num = group[ws.roomId]
wss.clients.forEach(client => {
if (client.readyState === Websocket.OPEN && client.roomId === ws.roomId) {
client.send(JSON.stringify(data))
}
})
})

ws.on('close', function (message) {
// После мониторинга закрытия чата уменьшите количество онлайн-пользователей на 1 и передайте сообщение о выходе из чата другим клиентам, чтобы обновить количество пользователей онлайн на странице
group[ws.roomId]--

wss.clients.forEach(function each (client) {
if (client !== ws && client.readyState === Websocket.OPEN && client.roomId === ws.roomId) {
client.send(JSON.stringify({
...ws.enterInfo,
event: 'logout',
num: group[ws.roomId],
}))
}
})
})

Поддержка heartbeat (“пульсации”)

В сценарии длительного соединения клиент и сервер не всегда находятся на связи. Если две стороны долгое время не общались, неясно, находится ли другая сторона на связи. Поэтому для обеспечения нормального соединения необходимо отправить небольшое контрольное сообщение, чтобы передать другой стороне: “я все еще жив”.

Heartbeat-поддержка

Как показано на изображении выше, на прикладном уровне клиент обычно посылает серверу heartbeat-пакет ping, а сервер после его получения отвечает pong. Это указывает на то, что обе стороны “живы”.

Помимо обеспечения нормального соединения, heartbeat-поддержка выполняет следующие функции.

  • Если сервер обнаружит, что клиент не отвечает на heartbeat-сигнал, он может активно закрыть канал и перевести его в оффлайн-режим.
  • Клиент может переподключиться для получения нового соединения, если обнаружит, что сервер не ответил на heartbeat-сигнал.

Допустим, пользователь ПК регистрируется на хосте с помощью Telnet и TCP/IP. Если в конце дня он просто выключит питание, не выходя из системы, то останется полуоткрытое соединение. Если клиент исчез, оставив на сервере полуоткрытое соединение, а сервер ждет данных от клиента, то сервер будет ждать вечно. Функция live-keeping будет пытаться обнаружить это полуоткрытое соединение на стороне сервера.

Для сервера также очень важно своевременно узнавать о наличии соединения. С одной стороны, ему необходимо своевременно очищать некорректные соединения, чтобы снизить нагрузку. С другой, это является одним из бизнес-требований. Например, если речь идет о копии игры, серверу необходимо своевременно решать проблемы, вызванные отключением игроков.

Ниже приведен код для heartbeat-поддержки:

this.ws.onopen = () => {
if (this.heartbeatTimer !== -1) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = -1
}
this.heartbeatTimer = setInterval(() => {
if (this.heartBeatTimeoutJob !== -1) {
clearTimeout(this.heartBeatTimeoutJob)
this.heartBeatTimeoutJob = -1
}
this.heartBeatTimeoutJob = setTimeout(() => {
console.log('heartbeat timeout')
}, 10000)

this.ws.send(JSON.stringify({
event: 'heartBeat',
content: 'ping',
}))
console.log('send ping')
}, 25000)
}

this.ws.onmessage = (message) => {
console.log('onmessage', message)
const data = JSON.parse(message.data)
console.log('message.data: ', data)

if (data.event === 'heartBeat' && data.content === 'pong') {
console.log('receive server pong')
if (this.heartBeatTimeoutJob !== -1) {
clearTimeout(this.heartBeatTimeoutJob)
this.heartBeatTimeoutJob = -1
}
return
}
}
this.ws.onclose = () => {
console.log('onclose', this.ws.readyState)
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = -1
clearTimeout(this.heartBeatTimeoutJob)
this.heartBeatTimeoutJob = -1
}

В home.vue мы создали таймер heartbeatTimer для отправки в реальном времени ping-сообщений на сервер каждый раз после прослушивания onopen. В то же время внутри heartBeatTimer мы создали таймер heartBeatTimeoutJob для проверки того, ответил ли сервер pong-сообщением. Если pong-сообщение от сервера не будет получено в течение времени, установленного heartBeatTimeoutJob, это будет считаться heartbeat-таймаутом. После обнаружения heartbeat-таймаута можно отключиться и подключиться снова.

Обратите внимание, что длительность heartbeatTimer должна быть больше, чем длительность heartBeatTimeoutJob. В противном случае таймаут heartBeatTimeoutJob не наступит. Конкретная продолжительность может быть определена в соответствии с вашими производственными потребностями.

Когда вы получите pong-сообщение от сервера в обратном вызове onmessage, вам необходимо очистить и переустановить таймер heartbeat-таймаута heartBeatTimeoutJob. Следует также отметить, что перед созданием каждого нового таймера необходимо определить, существует ли в текущей среде таймер с таким же именем, и очистить его, чтобы избежать возникновения ошибок при многократном запуске таймера.

Кроме того, таймер должен своевременно очищаться при каждом закрытии WebSocket-соединения. В противном случае, даже если пользователь вышел из чата, фоновый таймер не прекратит работу, что может привести к утечке памяти и другим непредвиденным проблемам.

  • server.js
ws.on('message', function (message) {
console.log('server receive message: ', message.toString())
const data = JSON.parse(message.toString())

if (data.event === 'login') {
ws.enterInfo = data
}

if (data.event === 'heartBeat' && data.content === 'ping') {
console.log('receive ping message')
ws.isAlive = true
ws.send(JSON.stringify({
event: 'heartBeat',
content: 'pong',
}))
return
}

if (typeof ws.roomId === 'undefined' && data.roomId) {
ws.roomId = data.roomId
if (typeof group[ws.roomId] === 'undefined') {
group[ws.roomId] = 1
} else {
group[ws.roomId]++
}
}
console.log('groun', group)

data.num = group[ws.roomId]
wss.clients.forEach(client => {
if (client.readyState === Websocket.OPEN && client.roomId === ws.roomId) {
client.send(JSON.stringify(data))
}
})
})

В server.js сервер также должен иметь таймер, чтобы проверить, истек ли heartbeat-таймаут. Если это подтвердится, он отключится и пересчитает количество людей онлайн.

Сообщение должно быть доставлено

Зачем нужно получать оповещения?

Сообщение Bida используется для обработки некоторых важных сообщений в процессе длительного соединения. Из-за неполадок в сети, на сервере и по другим причинам пользователи могут не получить сообщений.

Несколько ситуаций потери сообщений:

(1) (2) (3)

Ситуация 1 на приведенном выше изображении: сообщение теряется, когда клиент отправляет сообщение на сервер. Такая ситуация не может быть исправлена.

Ситуации 2 и 3 на том же изображении: если сообщение успешно отправлено на сервер, но доставить его не удается из-за разрыва сети и переключения, о чем сервер уведомляет пользователя или других пользователей, пользователь-адресат не может получить сообщение. В этом случае пользователи могут получать важные сообщения с помощью подтверждения ACK, чтобы улучшить показатель количества доставленных сообщений.

Использование ACK-механизма для обработки сообщения Bida

ACK-механизм используется для обработки сообщения, которое должно дойти до адресата. Когда клиент получает сообщение, он должен отправить ACK-подтверждение, тем самым уведомляя сервер о получении сообщения. Если сервер не получает от клиента ACK-подтверждение, считается, что клиент сообщение не получил. В таком случае сервер повторно отправляет сообщение, чтобы пользователь гарантированно его получил.

Когда клиенту необходимо получить сообщение, могут возникнуть следующие ситуации при использовании ACK для обработки сообщения.

1. Нормальные условия

  • После получения сообщения пользователь отправляет ACK-подтверждение на сервер. Получив уведомление о том, что клиент получил сообщение, сервер не будет больше отправлять это сообщение (как показано на рисунке ниже слева).
  • Пользователь не получил сообщение, поэтому не отправил ACK-подтверждение на сервер. Сервер, не получив ACK-подтверждение, повторно отправил сообщение. Когда пользователь получает сообщение, отправка этого сообщения завершается (см. рисунок ниже справа).

2. Сбой

После получения сообщения пользователь отправляет ACK-подтверждение на сервер. Во время процесса отправки связь прерывается. В результате сервер ошибочно считает, что клиент не получил сообщение, повторно передает сообщение, и на стороне клиента отображается несколько повторяющихся сообщений. (Это проблема, вызванная обработкой ACK трафика, связанного с сообщениями. Клиент должен принимать участие в дедупликации сообщений).

Заключение

Websocket нуждается в определенных доработках  —  аутентификации чатов, синхронизации сообщений в оффлайн-режиме, роуминге сообщений и т.п. Иными словами, Websocket нужно развивать, чтобы совершенствовать чат-функции и мгновенные сообщения.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи fatfish: How to Build a Multiplayer Chatroom With WebSocket in 10 Minutes

Предыдущая статьяТоп-7 онлайн-редакторов кода и IDE
Следующая статьяСхватка “рекурсия против циклов” на арене JavaScript