Строго типизированное программирование в реальном времени с помощью TypeScript
Приложения реального времени (RTA) привлекают много внимания в последние несколько лет, и лежащие в их основе концепции могут быть использованы для упрощения разработки программного обеспечения для совместной работы. Простые чаты, игровые платформы и даже пакет документов Google используют связь в реальном времени для улучшения взаимодействия с пользователем и совместной работы. В Интернете есть множество документации о реализации сервера сокетов, рассылке сообщений от него и использовании обратных вызовов для прослушивания этих сообщений с помощью JavaScript. Однако в большинстве этих статей часто забывается, что вы можете отправить практически любой объект через сокет и что получатель может использовать это сообщение неправильно. В этой статье мы рассмотрим, как сделать связь через сокеты более безопасной и надежной. Но сначала давайте рассмотрим некоторые концепции.
Двухминутное введение в WebSockets
WebSockets являются частью собственного API JavaScript и обеспечивают двустороннюю связь между браузером пользователя и сервером. Однако интересно то, что с помощью этой технологии вы можете либо отправлять сообщения на сервер, либо получать их без ручного опроса сервера для получения ответов. Он основан на эмиттерах событий и слушателях событий.
Начнем с простого примера. Предположим, у нас есть приложение чата, в котором мы реализуем событие chat_message
, которое запускается, когда пользователь отправляет сообщение. Мы создадим наш небольшой пример с socket.io, но вы можете использовать любую из библиотек сокетов для создания своей собственной реализации. В любом случае, наш базовый сервер на простом JavaScript будет выглядеть примерно так:
const app = require('express')();
const http = require("http").createServer(app);
const io = require("socket.io")(http);
io.on("connection", (socket) => {
socket.on("chat_message", (message) => {
io.emit("chat_message", message);
})
});
Как видите, сервер делает простую вещь: прослушивает любые сообщения чата от клиента и повторно отправляет их всем клиентам, которые слушают событие. Это означает, что сервер может получать данные любой формы и пересылать их всем клиентам, слушающим это конкретное событие. Глядя на простую реализацию клиента, мы, вероятно, получим что-то вроде этого:
const io = require("socket.io-client")();
const messageList = document.querySelector("#chatbox");
const messageInput = document.querySelector("#my-message");
const sendButton = document.querySelector("#send");
// Listening to a message
io.on("chat_message", (message) => {
messageList.appendChild(`<p>${message}</p>`);
});
// Sending a message when clicking on the button
sendButton.addEventListener("click", () => {
io.emit("chat_message", messageInput.value);
});
Итак, в целом мы можем сказать, что приложение сокета основано на эмиттерах и слушателях событий:
- Эмиттер: функция, транслирующая определенное событие. В приложении чата этот эмиттер будет запускаться при отправке нового сообщения на сервер. Это сообщение будет иметь идентификатор события и содержание.
- Слушатель: функция, которая запускается при наступлении определенного события. Используя наше приложение для чата в качестве примера, слушатель подписывается на событие, которое запускается при получении нового сообщения от пользователя. Это сообщение затем будет обработано соответствующим образом или перенаправлено другим слушателям.
У вас могут быть другие варианты этого шаблона, в зависимости от потребностей вашего приложения. Например, вам может потребоваться отправлять сообщения только определенным слушателям, и в этом случае вам придется отслеживать уникальный идентификатор слушателя и отправлять сообщения только им. Для кода в этой статье мы будем использовать простой пример, где мы рассылаем сообщения всем клиентам.
Здорово! Но зачем нам безопасность типов?
Это типичный пример взаимодействия между сервером и клиентом. В этом случае вы не сможете отправить что-то отличное от string
в виде сообщения сокета, поскольку мы отправляем строковое значение messageInput
. Однако сразу же мы видим две проблемы с этой реализацией.
- Это не обслуживается: если я когда-нибудь захочу изменить имя события
chat_message
на что-то вродеsomeone_said_something
, мне придется изменить все места в моем коде, где это событие вызывается или прослушивается. В нашем текущем примере у нас есть только один файл и несколько вхождений, но вы можете увидеть, как он может выйти из-под контроля с несколькими файлами. - Это небезопасно: в этом примере мы отправляем только строки в сокет, однако ничто не мешает мне сделать что-то вроде:
io.emit("chat_message", {user: "Marty McFly", message: "Hey Doc!"})`
Проблема менее очевидна в реализации сервера, где мы только пересылаем это сообщение клиентам, но это действительно становится проблемой для клиентских слушателей. В нашем случае получение этого сообщения и его распечатка в контейнере чата выведет печально известную строку [object Object]
в наш DOM.
Итак, в сегодняшней статье мы исследуем, как сделать сокеты более удобными в обслуживании и, что самое важное: с безопасными типами
TypeScript спешит на помощь!
С момента своего создания TypeScript помог многим приложениям повысить безопасность типов с помощью простых изменений. Если вы еще не приняли его, попробуйте! Это избавит вас от многих головных болей.
Теперь, когда с пояснением покончено, давайте вытащим @types/socket.io
и посмотрим, безопасны ли эти типы. Сначала мы проверим типы для on
и emit
в коде сервера:
on(event: string, listener: Function): Namespace;
emit(event: string, ...args: any[]): Namespace;
С помощью on
мы можем отправить любую строку в качестве события и любую функцию в качестве слушателя. С emit
все очень похоже, с явным any
в списке аргументов. Это означает, что мы можем практически любое событие отправлять и слушать, как захотим. Это имеет смысл с точки зрения библиотеки, где мы хотим предоставить гибкость нашим пользователям, но как потребитель мы должны принять здесь некоторые меры предосторожности. Давайте пока добавим типизацию @types/socket.io-client
:
on( event: string, fn: Function ):Emitter;
emit( event: string, ...args: any[] ):Emitter;
Код практически такой же. Это означает, что нам нужно будет обрабатывать безопасность типов изнутри нашего приложения, но как мы можем сделать это кратким и поддерживаемым способом?
Как рождественский подарок: заверните сервер!
Рекомендуется оборачивать внешние библиотеки для повышения удобства обслуживания, поскольку в этом случае, если вы хотите переключиться на другую реализацию сокета, вам нужно будет изменить операции библиотеки только в одном месте. Давайте дадим серверу первый проход с типизацией и небольшой упаковкой:
import {Server} from "http";
import socketIO, {Socket} from "socket.io";
let io = null;
export function createSocketServer(server: Server) {
io = socketIO(server);
io.on("connection", (socket: Socket) => {
// Bind your listeners here
});
}
Не так много изменений, правда? Мы просто добавили несколько типов, чтобы все было в безопасности. Строка let io = null;
не является идеальной, так как в больших масштабах применения вы хотите обработать сокет - сервер как синглтон, но она будет работать для нашего примера.
А теперь давайте немного подумаем об этом. Мы хотим иметь центральное расположение для всех наших сокетов и иметь более безопасный способ их определения. Я придумал что-то вроде этого:
type SocketMessage = "chat_message";
type SocketActionFn<T> = (message: T) => void;
interface WrappedServerSocket<T> {
event: string;
callback: SocketActionFn<T>;
}
function broadcast<T>(event: SocketMessage) {
return (message: T) => io.emit(event, message);
}
function createSocket<T>(event: SocketMessage, action?: SocketActionFn<T>): WrappedServerSocket<T> {
const callback = action || broadcast(event);
return { event, callback };
}
Эй, давай сбавим обороты и проанализируем, что здесь происходит, снизу вверх:
Мы реализовали функцию createSocket
, которая завершит наши операции. Первый аргумент - это event
с типом SocketMessage
. Прямо сейчас может принимать только значение chat_message
, но поскольку оно будет расти в будущем, это не позволит нам использовать события, которые мы еще не реализовали.
Второй аргумент функции - это action
с типом SocketActionFn
. Это будет действие сервера, которое мы будем использовать для обработки сообщения. Это будет полезно, если мы хотим не только транслировать сообщение, но и сделать что-то дополнительное, например, сохранение данных или проверку условия. Если мы не отправляем действие, по умолчанию используется простая операция broadcast
.
Наконец, мы вернем объект с событием и обратным вызовом, который будем запускать. Мы будем использовать это для создания списка объектов WrappedServerSocket
, чтобы мы могли перебирать его и инициализировать всех слушателей за один раз.
Вы можете видеть, что мы используем здесь типы дженериков, и это то , что держит весь пример типобезопасным. Если мы используем createSocket
с типом string
, это означает, что параметром action
будет SocketActionFn<string>
.
Наконец, настройка нашего сервера может выглядеть так:
import {Server} from "http";
import socketIO, {Socket} from "socket.io";
let io = null;
export function createSocketServer(server: Server) {
io = socketIO(server);
io.on("connection", (socket: Socket) => {
registeredEvents.forEach(({event, callback}) => {
socket.on(event, callback);
});
});
}
const chatMessageEvent = createSocket<string>("chat_message");
const registeredEvents = [chatMessageEvent];
И вуаля! Наш сервер типобезопасен. Допустим, мы хотим зарегистрировать новое событие с именем user_connected
, которое получает объект User с таким интерфейсом:
interface User {
id: string;
name: string;
}
Чтобы позволить это, мы просто добавим user_connected
к типу SocketMessage
и будем использовать createSocket
для определения обратного вызова. Что-то вроде этого:
type SocketMessage = "chat_message" | "user_connected";
// To simply broadcast the message we omit the second parameter
const userConnectedEvent = createSocket<User>("user_connected");
// If we want to do a custom action we can pass a function as the second parameter
const userConnectedLogEvent = createSocket<User>("user_connected", (user) => {
console.log(user.id); // Compiles OK, user gets inferred as User
console.log(user.type); // TypeError! Type doesn't exist in User
})
Но это только одна сторона медали. Теперь посмотрим, как это сделать на стороне клиента:
Как и Дед Мороз: заверните клиента!
Реализация клиента очень похожа на серверную. Давайте взглянем:
import { SocketMessage, User } from "../contracts/events";
import socketIOClient from "socket.io-client";
const socketClient = socketIOClient();
interface EmitterCallback<T> {
(data: T): void;
}
interface WrappedClientSocket<T> {
emit: (data: T) => SocketIOClient.Socket;
on: (callback: EmitterCallback<T>) => SocketIOClient.Emitter;
off: (callback: EmitterCallback<T>) => SocketIOClient.Emitter;
}
function createSocket<T>(event: SocketMessage): WrappedClientSocket<T> {
return {
emit: (data) => socketClient.emit(event, data),
on: (callback) => socketClient.on(event, callback),
off: (callback) => socketClient.off(event, callback),
};
}
const chatMessageEvent: WrappedClientSocket<string> = createSocket("chat_message");
const userConnectedSocket: WrappedClientSocket<User> = createSocket("user_connected");
Подобно нашему серверу, мы заключаем операции клиентского сокета в тип WrappedClientSocket
. Функция createSocket
возвращает объект, где ключи операции и значения являются общими функциями. Мы можем добиться этого, сохранив EmitterCallback
общий тип T
и передав тип из функции createSocket
.
Мы бы использовали эти сокеты примерно так:
// None of the following will typecheck.
// 'on' and 'off' will not infer 'message' as a string
// We can still pass anything to the second argument of 'emit'
socketClient.on("chat_message", (message) => console.log(message));
socketClient.off("chat_message", (message) => console.log(message));
socketClient.emit("chat_message", "Hey Doc!");
// Instead, let's do:
// 'on' and 'off' will infer 'message' as a string
// We can only pass strings to 'emit'
chatMessageEvent.on((message) => console.log(message));
chatMessageEvent.off((message) => console.log(message));
chatMessageEvent.emit("Hey Doc!");
//This will fail: Argument of type number is not assignable to parameter of type string.
chatMessageEvent.emit(1);
Как видите, наша реализация сокета теперь безопасна! Больше нет Uncaught TypeError: Cannot read property 'user' of undefined
в вашем рабочем коде. Ура!
В заключение
Из этой статьи вы можете увидеть, насколько просто добавить типизацию к небезопасным операциям, реализовав статическую типизацию в своем коде. Внедрение TypeScript в существующую кодовую базу - простой процесс, который можно выполнять постепенно и без особых неявных затрат. Вы можете начать с малого, с простых параметров компилятора и использования всего нескольких типов для наиболее важных функций.
Я настоятельно рекомендую поддерживать согласованность ваших типов во внешнем и внутреннем интерфейсе с помощью контрактов. Это означает, что в нашем случае тип SocketMessage
и интерфейс User
должны быть общими для внешнего и внутреннего интерфейса через модуль TypeScript или ссылку на проект.