WebSockets в Angular: cоздаем Angular Service для работы с вебсокетами +12


image
В этой статье я постараюсь детально охватить узкую сферу применения технологии в рамках фреймворка Angular и его уже неотъемлемого помощника — RxJs, при этом мы намеренно не будем касаться серверных реализаций, т.к. это полноценная тема для отдельной статьи.

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

Для начала немного базовой информации.

Что такое WebSocket и почему он Вам нужен


Согласно Википедии, WebSocket это «протокол дуплексной связи (может передавать и принимать одновременно) поверх TCP-соединения, предназначенный для обмена сообщениями между браузером и веб-сервером в режиме реального времени.
WebSocket разработан для воплощения в web-браузерах и web-серверах, но он может быть использован для любого клиентского или серверного приложения. Протокол WebSocket?-?это независимый протокол, основанный на протоколе TCP. Он делает возможным более тесное взаимодействие между браузером и web-сайтом, способствуя распространению интерактивного содержимого и созданию приложений реального времени.»

Говоря иначе, WebSocket позволяет серверу принимать запросы от клиента и слать запросы на клиент в любое нужное время, таким образом, браузер (клиент) и сервер при соединении получают равные права и возможность обмениваться сообщениями. Обычный AJAX-запрос требует передачи полных HTTP-заголовков, что означает увеличение трафика в обе стороны, тогда как накладные расходы вебсокетов после установления соединения составляют лишь два байта. Вебсокет уменьшает в сотни и тысячи раз количество передаваемой информации в HTTP-заголовках и в разы сокращает время ожидания. Соединения вебсокетов поддерживают кроссдоменность, подобно CORS.

На стороне сервера имеются свои пакеты для поддержки вебсокета, на клиенте же это HTML5 WebSocket API, которое имеет интерфейс из трех методов:

WebSocket — основной интерфейс для подключения к серверу WebSocket, а затем отправки и получения данных по соединению;
CloseEvent — событие, отправленное объектом WebSocket при закрытии соединения;
MessageEvent — событие, отправленное объектом WebSocket, когда сообщение получено с сервера.

Вот так это выглядит на уровне реализации JavaSript:

const ws = new WebSocket("ws://www.example.com/socketserver", "protocolOne");

ws.onopen = () => {
    ws.onmessage = (event) => {
        console.log(event);
    }


    ws.send("Here's some text that the server is urgently awaiting!");
};

onmessage — слушаем сообщения от сервера
send — шлём свои сообщения на сервер

То есть в базовом виде всё предельно просто, но если у Вас есть желание углубиться в тему, можете обратиться к MDN Web Docs и заодно изучить библиотеки, которые реализуют свои собственные слои поверх данного API.

Почему не нужно бояться использовать WebSocket


Первый момент, который может отпугивать — это поддержка браузерами. На сегодняшний день такой проблемы нет — WebSocket поддерживается практически полностью как в вебе, так и в мобильном сегменте.

image

image

https://caniuse.com/#feat=websockets

Второй момент — простота реализации. Да, поначалу это обескураживает.

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

При этом загвоздка в том, что данные сервер обычно присылает разные, следовательно, нужны несколько разных onmessage? Или на каждую модель данных нужно создавать свое собственное соединение?

Итак, задача: нужно принять от сервера модель юзера и модель последних новостей, а может даже и еще чего другого.

Мне доводилось сталкиваться с такой «изящной» реализацией:

const wsUser = new WebSocket("ws://www.example.com/user");
wsUser.onmessage = (event) => { // ... };
const wsNews = new WebSocket("ws://www.example.com/news");
wsNews.onmessage = (event) => { // ... };
const wsTime = new WebSocket("ws://www.example.com/time");
wsTime.onmessage = (event) => { // ... };
const wsDinner = new WebSocket("ws://www.example.com/dinner");
wsDinner.onmessage = (event) => { // ... };
const wsCurrency = new WebSocket("ws://www.example.com/currency");
wsCurrency.onmessage = (event) => { // ... };
const wsOnline = new WebSocket("ws://www.example.com/online");
wsOnline.onmessage = (event) => { // ... };
const wsLogin = new WebSocket("ws://www.example.com/login");
wsLogin.onmessage = (event) => { // ... };
const wsLogout = new WebSocket("ws://www.example.com/logout");
wsLogout.onmessage = (event) => { // ... };

На первый взгляд, всё логично. Но теперь представьте как это будет выглядеть, если их десятки или сотни. На одном из проектов, над которым мне довелось работать, событий было около трёх сотен.

image

Решаем проблему.

Все сторонние библиотеки для работы с вебсокетами позволяют подписываться на сообщения по типу addEventListener. Выглядит это примерно следующим образом:

ws.on("user", (userData) => { / .. })

Как мы знаем, оперировать мы можем одним единственном методом — onmessage, который и получает все данные в рамках своего соединения, поэтому такой код выглядит несколько необычно. Реализуется это следующим образом: onmessage возвращает MessageEvent, которое содержит поле data. Именно в data содержится информация, которую нам присылает сервер. Выглядит этот объект примерно следующим образом:

{
    "event": "user",
    "data": {
        "name": "John Doe",
        ...
    }
}

где event? — это ключ, по которому можно определить, какую именно информацию прислал сервер. Далее на стороне фронтенда создается шина, которая фильтрует информацию по event и отсылает её по нужному адресу:

const ws = new WebSocket("ws://www.example.com");

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);

    if (data.event === 'user') {
        // ...
    }

    if (data.event === 'news') {
        // ...
    }
};


Это обеспечивает возможность в рамках одного соединения получать разные данные и подписываться на них через синтаксис, подобный обычным для JS событий.

WebSockets в Angular


Наконец мы подобрались к самому главному — использованию WebSockets непосредственно в Angular.

Несмотря на простоту работы с нативным WebSocket API, в этой статье мы будем использовать RxJs, что разумеется, ведь речь идет об Angular.

Нативный WebSocket API вполне можно использовать в приложениях на Angular, создать на его основе удобный в работе интерфейс, RxJs Observable, подписываться на нужные сообщения и т.д., но RxJs уже сделал за Вас основную работу: WebSocketSubject — это реактивная обертка над стандартным WebSocket API. Она не создает шину событий или обработку реконнекта. Это обычный Subject, при помощи которого можно работать с вебсокетами в реактивном стиле.

RxJs WebSocketSubject


Итак, WebSocketSubject ожидает WebSocketSubjectConfig и необязательный destination, в котором можно передать ссылку на свой наблюдаемый Subject, создает Observable, через который можно слушать и отправлять сообщения для вебсокетов.

Проще говоря, передаете в качестве аргумента WebSocketSubject url соединения и подписываетесь обычным для RxJs способом на всю активность вебсокета. А если нужно отослать сообщение серверу, то используете такой же обычный метод webSocketSubject.next(data).

Делаем сервис для работы с WebSocket Angular


Кратко опишем что мы ожидаем от сервиса:

  • Единый и лаконичный интерфейс;
  • Возможность конфигурации на уровне подключения зависимостей DI;
  • Возможность переиспользования;
  • Типизация;
  • Возможность подписываться на получение информации по ключу;
  • Возможность прервать подписку;
  • Отправку сообщений на сервер;
  • Реконнект.

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

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

Приступим.

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

Я буду по возможности сокращать код, полную версию вы можете посмотреть в русскоязычном сообществе Angular на GitHub.

export interface WebSocketConfig {
    url: string;
    reconnectInterval?: number;
    reconnectAttempts?: number;
}

export class WebsocketModule {
    public static config(wsConfig: WebSocketConfig): ModuleWithProviders {
        return {
            ngModule: WebsocketModule,
            providers: [{ provide: config, useValue: wsConfig }]
        };
    }
}

Далее нам потребуется описать интерфейс сообщения вебсокета:

export interface IWsMessage<T> {
    event: string;
    data: T;
}

где event?—?ключ, а data, получаемая по ключу — типизированная модель.

Публичный интерфейс сервиса выглядит так:

export interface IWebsocketService {
    on<T>(event: string): Observable<T>;
    send(event: string, data: any): void;
    status: Observable<boolean>;
}

Сервис имеет поля:

// объект конфигурации WebSocketSubject
private config: WebSocketSubjectConfig<IWsMessage<any>>;

private websocketSub: SubscriptionLike;
private statusSub: SubscriptionLike;

// Observable для реконнекта по interval
private reconnection$: Observable<number>;
private websocket$: WebSocketSubject<IWsMessage<any>>;

// сообщает, когда происходит коннект и реконнект
private connection$: Observer<boolean>;

// вспомогательный Observable для работы с подписками на сообщения
private wsMessages$: Subject<IWsMessage<any>>; 

// пауза между попытками реконнекта в милисекундах
private reconnectInterval: number;

// количество попыток реконнекта
private reconnectAttempts: number;

// синхронный вспомогатель для статуса соединения
private isConnected: boolean;

// статус соединения
public status: Observable<boolean>;

В конструкторе класса сервиса мы получаем объект WebSocketConfig, заданный при подключении модуля:

constructor(@Inject(config) private wsConfig: WebSocketConfig) {
    this.wsMessages$ = new Subject<IWsMessage<any>>();

    // смотрим конфиг, если пусто, задаем умолчания для реконнекта
    this.reconnectInterval = wsConfig.reconnectInterval || 5000;
    this.reconnectAttempts = wsConfig.reconnectAttempts || 10;

    // при сворачивании коннекта меняем статус connection$ и глушим websocket$
    this.config = {
        url: wsConfig.url,
        closeObserver: {
            next: (event: CloseEvent) => {
                this.websocket$ = null;
                this.connection$.next(false);
            }
        },
        // при коннекте меняем статус connection$
        openObserver: {
            next: (event: Event) => {
                console.log('WebSocket connected!');
                this.connection$.next(true);
            }
        }
    };

    // connection status
    this.status = new Observable<boolean>((observer) => {
        this.connection$ = observer;
    }).pipe(share(), distinctUntilChanged());

    // запускаем реконнект при отсутствии соединения
    this.statusSub = this.status
        .subscribe((isConnected) => {
            this.isConnected = isConnected;

            if (!this.reconnection$ && typeof(isConnected) === 'boolean' && !isConnected) {
                this.reconnect();
            }
        });

    // говорим, что что-то пошло не так
    this.websocketSub = this.wsMessages$.subscribe(
        null, (error: ErrorEvent) => console.error('WebSocket error!', error)
    );

    // коннектимся
    this.connect();
}

Сам метод коннекта прост:

private connect(): void {
    this.websocket$ = new WebSocketSubject(this.config); // создаем
// если есть сообщения, шлем их в дальше,
// если нет, ожидаем
// реконнектимся, если получили ошибку
this.websocket$.subscribe(
    (message) => this.wsMessages$.next(message),
    (error: Event) => {
        if (!this.websocket$) {
            // run reconnect if errors
            this.reconnect();
        }
    });
}

Реконнект немного сложнее:

private reconnect(): void {
    // Создаем interval со значением из reconnectInterval
    this.reconnection$ = interval(this.reconnectInterval)
        .pipe(takeWhile((v, index) => index < this.reconnectAttempts && !this.websocket$));

// Пытаемся подключиться пока не подключимся, либо не упремся в ограничение попыток подключения
this.reconnection$.subscribe(
    () => this.connect(),
    null,
    () => {
        // Subject complete if reconnect attemts ending
        this.reconnection$ = null;

        if (!this.websocket$) {
            this.wsMessages$.complete();
            this.connection$.complete();
        }
    });
}

Метод on, он тоже предельно прост, тут даже комментировать нечего.

public on<T>(event: string): Observable<T> {
    if (event) {
        return this.wsMessages$.pipe(
            filter((message: IWsMessage<T>) => message.event === event),
            map((message: IWsMessage<T>) => message.data)
        );
    }
}

Метод send еще проще:

public send(event: string, data: any = {}): void {
    if (event && this.isConnected) {
        // костыль с any потому, что на "том" конце ожидается string
        // более изящный костыль не придумался :)
        this.websocket$.next(<any>JSON.stringify({ event, data }));
    } else {
        console.error('Send error!');
    }
}

Вот и весь сервис. Как видно, основной объем кода пришелся на организацию реконнекта.

Давайте теперь посмотрим, как этим пользоваться. Подключим модуль WebsocketModule:

imports: [
    WebsocketModule.config({
        url: environment.ws // или просто ссылка в виде 'ws://www.example.com'
    })
]

В конструктор компонента заинжектим сервис и подпишемся на сообщения от ‘messages’, отправим обратно на сервер текст:

constructor(private wsService: WebsocketService) {
    this.wsService.on<IMessage[]>('messages')
        .subscribe((messages: IMessage[]) => {
            console.log(messages);

            this.wsService.send('text', 'Test Text!');
        });
}

Название событий удобнее вынести в константы или перечисление. Создаем где-нибудь файл websocket.events.ts и запишем в него:

export const WS = {
    ON: {
        MESSAGES: 'messages'
    },
    SEND: {
        TEXT: 'text'
    }
};

Перепишем подписки при помощи созданного объекта WS:

this.wsService.on<IMessage[]>(WS.ON.MESSAGES)
    .subscribe((messages: IMessage[]) => {
        console.log(messages);

        this.wsService.send(WS.SEND.TEXT, 'Test Text!');
    });

image

В завершение


Вот, собственно, и всё. Это необходимый минимум, который нужно знать разработчику на Angular про WebSockets. Я надеюсь, что достаточно внятно осветил эту тему. Полную версию сервиса можно найти на GitHub.

По всем вопросам можете обращаться в комментарии, ко мне в Телеграм или на канал Angular там же.




К сожалению, не доступен сервер mySQL