Привет, Хабр! В этой статье я хотел бы рассказать о методе работы с WebSocket, который я часто применяю в своих наработках. Если кратко, то WebSocket - это, пожалуй, самое частое, что я использую в своих проектах. Мне очень важно, чтобы сервер мог общаться с клиентом в двухстороннем режиме, но использование обычного WebSocket не всегда комфортно. Как правило, всё взаимодействие между клиентом и сервером сводится к "вопрос - ответ", и если с API всё понятно (где request - вопрос, а response - ответ), то в WebSocket всё немного сложнее. Request там - это ws.send, а ответ - один из ws.on('message', ()=>{}) (или его аналог из мира браузера). Для решения этой задачи я часто пишу какой-то обработчик, и, суммируя суть того, что делает этот обработчик, я решил написать небольшую и очень простую библиотеку.
AsyncSocket
Самое главное, что я хотел бы сделать, это асинхронную отправку ws.send. Выглядит дико, но удобно (по крайней мере, для меня). Обычный WebSocket умеет отправлять только текст и blob, что не очень удобно для хранения параметров, поэтому в AsyncSocket отправляются именно JSON-объекты. Я не хотел бы ограничивать изначальный функционал WebSocket, поэтому нужно написать клиент, способный принимать как объекты, так и текст и blob. Самым очевидным способом реализации отправки объекта по WebSocket каналу являются JSON.parse и JSON.stringify. JSON.parse выдает ошибку, когда ему подают что-то, что нельзя преобразовать в объект, поэтому мной была написана простая функция JSONParse:
function JSONParse(message) {
try {
return JSON.parse(message);
} catch (err) {
return null;
}
}
Ну и не менее незамысловатая функция отправки сообщения для клиента AsyncSocket:
class AsyncSocket extends EventEmitter {
constructor(ws) {
super();
this.ws = ws;
}
sendNoReply(data) {
this.ws.send(JSON.stringify(data));
}
}
Пока что оно даже не умеет прослушивать то, что пришло обратно. Хотелось бы иметь возможность, без лишней необходимости, не обращаться к клиенту ws напрямую. Поэтому все события, кроме 'message', хотелось бы передавать на AsyncSocket. Для этой задачи я написал следующий код:
this.nativeOn = this.on;
this.on = function(event, listener) {
if(event=="message") this.nativeOn(event, listener);
else ws.on(event, listener);
};
Скорее всего, это не лучшее решение, но это рабочее решение.
async WS.send()
Перейду к главному инструменту. По сути, все, что нужно, это определить, что данный ответ от сервера является именно ответом на ранее отправленный запрос. Для этого будет применяться идентификатор (в данном случае waitId). Вообще его лучше генерировать каким-либо хитрым способом, так чтобы он не мог повторяться в течение сессии, но для кустарной версии будет использоваться самый обычный Date.now(). Также нам нужно ожидать это сообщение и проверять каждое, не является ли оно тем самым сообщением, на которое нужно ответить. И как и в любом другом запросе, нужно сделать тайм-аут, по истечении которого мы делаем вывод, что сервер нам уже не ответит. В итоге это выглядит следующим образом:
class AsyncSocket extends EventEmitter {
constructor(ws) {
super();
this.ws = ws;
this._awaitMessages = {};
ws.on('message', (blobMessage) => {
const data = JSONParse(blobMessage);
if(data === null) return this.emit('message', blobMessage);
if(this._incoming(data)===2) return this.emit('message', {
...data,
asyncSocket: this,
reply: function(data) {
data.waitId ??= this.waitId;
return this.asyncSocket.send(data);
}
});
});
this.nativeOn = this.on;
this.on = function(event, listener) {
if(event=="message") this.nativeOn(event, listener);
else ws.on(event, listener);
};
this.emit('open');
}
_incoming(data) {
if(!this._awaitMessages[data.waitId]) return 2;
this._awaitMessages[data.waitId].resolve(data);
clearTimeout(this._awaitMessages[data.waitId].timeout);
delete this._awaitMessages[data.waitId];
}
sendNoReply(data) {
this.ws.send(JSON.stringify(data));
}
send(data={}) {
const {waitId = Date.now().toString(), timeout=10000} = data;
return new Promise((resolve, reject) => {
this._awaitMessages[waitId] = {
waitId, resolve, reject,
timeout: timeout?setTimeout(() => reject(new Error("The waiting time has been exceeded")), timeout):null
};
this.sendNoReply(data);
});
}
}
И уже на данном этапе два таких клиента способны общаться друг с другом. Событие 'message' в AsyncSocket будет вызвано только если в сообщении не содержится объект или если клиент не ожидает ответа с таким waitId. В таком случае есть возможность ответить (метод reply) на такой запрос или просто просмотреть содержимое (как было сказано ранее, я не хочу ограничивать функционал WebSocket и запрещать передавать по нему что-либо кроме объектов).
Event в WS
"Вопрос - ответ" в обе стороны это конечно хорошо, но было бы замечательно иметь возможность реагировать на ивенты посланные с сервера или клиента. Для этих целей будет добавлен параметр isEvent при существовании которого будет определено, что это ивент. Реализация тут ещё проще чем с waitId и представлена ниже:
class AsyncSocket extends EventEmitter {
_incoming(data) {
if(this._awaitMessages[data.waitId]) {
this._awaitMessages[data.waitId].resolve(data);
clearTimeout(this._awaitMessages[data.waitId].timeout);
delete this._awaitMessages[data.waitId];
return 0;
}
if(data.isEvent) {
this.emit(data.eventName, data.body);
return 1;
}
return 2;
}
sendEmit(eventName, body) {
return this.sendNoReply({
isEvent: true,
eventName, body
});
}
}
И теперь AsyncSocket содержит вообще всё, что мне хотелось бы иметь в WebSocket. Я так-же создал страницу на GitHub дабы удобно скачивать модуль через npm, а так-же дополнять код: https://github.com/strelok-js/AsyncSocket
Я надеюсь, что этот модуль станет для кого-то полезным, а кто-то даже предложит исправления моего кривого кода. На GitHub так-же были уже дописаны сервер и функция для лёгкого создания AsyncSocket, браузерная версия (min), а так-же примеры как это использовать.