DevGang
Авторизоваться

Строго типизированное программирование в реальном времени с помощью TypeScript

Приложения реального времени (RTA) привлекают много внимания в последние несколько лет, и лежащие в их основе концепции могут быть использованы для упрощения разработки программного обеспечения для совместной работы. Простые чаты, игровые платформы и даже пакет документов Google используют связь в реальном времени для улучшения взаимодействия с пользователем и совместной работы. В Интернете есть множество документации о реализации сервера сокетов, рассылке сообщений от него и использовании обратных вызовов для прослушивания этих сообщений с помощью JavaScript. Однако в большинстве этих статей часто забывается, что вы можете отправить практически любой объект через сокет и что получатель может использовать это сообщение неправильно. В этой статье мы рассмотрим, как сделать связь через сокеты более безопасной и надежной. Но сначала давайте рассмотрим некоторые концепции.

Двухминутное введение в WebSockets

WebSockets являются частью собственного API JavaScript и обеспечивают двустороннюю связь между браузером пользователя и сервером. Однако интересно то, что с помощью этой технологии вы можете либо отправлять сообщения на сервер, либо получать их без ручного опроса сервера для получения ответов. Он основан на эмиттерах событий и слушателях событий.

Начнем с простого примера. Предположим, у нас есть приложение чата, в котором мы реализуем событие chat_message, которое запускается, когда пользователь отправляет сообщение. Мы создадим наш небольшой пример с socket.io, но вы можете использовать любую из библиотек сокетов для создания своей собственной реализации. В любом случае, наш базовый сервер на простом JavaScript будет выглядеть примерно так:

server.js
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);
  })
});

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

client.js
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);
});

Итак, в целом мы можем сказать, что приложение сокета основано на эмиттерах и слушателях событий:

  1. Эмиттер: функция, транслирующая определенное событие. В приложении чата этот эмиттер будет запускаться при отправке нового сообщения на сервер. Это сообщение будет иметь идентификатор события и содержание.
  2. Слушатель: функция, которая запускается при наступлении определенного события. Используя наше приложение для чата в качестве примера, слушатель подписывается на событие, которое запускается при получении нового сообщения от пользователя. Это сообщение затем будет обработано соответствующим образом или перенаправлено другим слушателям.

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

Здорово! Но зачем нам безопасность типов?

Это типичный пример взаимодействия между сервером и клиентом. В этом случае вы не сможете отправить что-то отличное от string в виде сообщения сокета, поскольку мы отправляем строковое значение messageInput. Однако сразу же мы видим две проблемы с этой реализацией.

  1. Это не обслуживается: если я когда-нибудь захочу изменить имя события chat_message на что-то вроде someone_said_something, мне придется изменить все места в моем коде, где это событие вызывается или прослушивается. В нашем текущем примере у нас есть только один файл и несколько вхождений, но вы можете увидеть, как он может выйти из-под контроля с несколькими файлами.
  2. Это небезопасно: в этом примере мы отправляем только строки в сокет, однако ничто не мешает мне сделать что-то вроде:
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 или ссылку на проект.

Источник:

#JavaScript #TypeScript
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

Поделитесь своим опытом, расскажите о новом инструменте, библиотеке или фреймворке. Для этого не обязательно становится постоянным автором.

Попробовать

Оплатив хостинг 25$ в подарок вы получите 100$ на счет

Получить