Поиск по сайту:

Как создать приложение реального времени с помощью Socket.IO, Angular и Node.js


Введение

WebSocket — это интернет-протокол, обеспечивающий полнодуплексную связь между сервером и клиентами. Этот протокол выходит за рамки типичной парадигмы запросов и ответов HTTP. С помощью WebSockets сервер может отправлять данные клиенту без того, чтобы клиент инициировал запрос, что позволяет использовать некоторые очень интересные приложения.

В этом руководстве вы создадите приложение для совместной работы с документами в реальном времени (аналогично Google Docs). Для этого мы будем использовать Angular 7.

Вы можете найти полный исходный код этого примера проекта на GitHub.

Предпосылки

Для выполнения этого урока вам понадобятся:

  • Node.js установлен локально, что можно сделать, следуя инструкциям по установке Node.js и созданию локальной среды разработки.
  • Современный веб-браузер с поддержкой WebSocket.

Это руководство изначально было написано в среде, состоящей из Node.js v8.11.4, npm v6.4.1 и Angular v7.0.4.

Это руководство было проверено с помощью Node v14.6.0, npm v6.14.7, Angular v10.0.5 и Socket.IO v2.3.0.

Шаг 1 — Настройка каталога проекта и создание сокет-сервера

Сначала откройте свой терминал и создайте новый каталог проекта, в котором будет храниться код нашего сервера и клиента:

  1. mkdir socket-example

Затем перейдите в каталог проекта:

  1. cd socket-example

Затем создайте новый каталог для кода сервера:

  1. mkdir socket-server

Затем перейдите в каталог сервера.

  1. cd socket-server

Затем инициализируйте новый проект npm:

  1. npm init -y

Теперь мы установим наши зависимости пакета:

  1. npm install express@4.17.1 socket.io@2.3.0 @types/socket.io@2.1.10 --save

Эти пакеты включают Express, Socket.IO и @types/socket.io.

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

Сначала создайте новый каталог src:

  1. mkdir src

Теперь создайте новый файл с именем app.js в каталоге src и откройте его в своем любимом текстовом редакторе:

  1. nano src/app.js

Начните с операторов require для Express и Socket.IO:

const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http);

Как вы можете заметить, мы используем Express, а Socket.IO обеспечивает уровень абстракции по сравнению с собственными WebSockets. Он поставляется с некоторыми приятными функциями, такими как резервный механизм для старых браузеров, не поддерживающих WebSockets, и возможность создавать комнаты. Мы увидим это в действии через минуту.

Для нашего приложения для совместной работы с документами в режиме реального времени нам понадобится способ хранения документов. В производственных условиях вы хотели бы использовать базу данных, но в рамках этого руководства мы будем использовать хранилище documents в памяти:

const documents = {};

Теперь давайте определим, что мы хотим, чтобы наш сервер сокетов на самом деле делал:

io.on("connection", socket => {
  // ...
});

Давайте разберем это. .on(...) — прослушиватель событий. Первый параметр — это имя события, а второй — обычно обратный вызов, выполняемый при возникновении события с полезной нагрузкой события.

Первый пример, который мы видим, — это когда клиент подключается к серверу сокетов (connection — это зарезервированный тип события в Socket.IO).

Мы получаем переменную socket для передачи нашему обратному вызову, чтобы инициировать связь либо с этим одним сокетом, либо с несколькими сокетами (т. е. широковещание).

безопасное соединение

Мы настроим локальную функцию (safeJoin), которая позаботится о присоединении и выходе из комнат:

io.on("connection", socket => {
  let previousId;

  const safeJoin = currentId => {
    socket.leave(previousId);
    socket.join(currentId, () => console.log(`Socket ${socket.id} joined room ${currentId}`));
    previousId = currentId;
  };

  // ...
});

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

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

Есть три типа событий, которые наш сокет ожидает от клиента:

  • получить документ
  • добавить документ
  • редактировать документ

И два типа событий, которые наш сокет отправляет клиенту:

  • документ
  • документы

получить документ

Давайте поработаем над первым типом события — getDoc:

io.on("connection", socket => {
  // ...

  socket.on("getDoc", docId => {
    safeJoin(docId);
    socket.emit("document", documents[docId]);
  });

  // ...
});

Когда клиент генерирует событие getDoc, сокет получает полезную нагрузку (в нашем случае это просто идентификатор), присоединяется к комнате с этим docId и генерирует сохраненный document возвращается только инициирующему клиенту. Вот где в игру вступает socket.emit(document, ...).

добавить документ

Давайте поработаем над вторым типом событий — addDoc:

io.on("connection", socket => {
  // ...

  socket.on("addDoc", doc => {
    documents[doc.id] = doc;
    safeJoin(doc.id);
    io.emit("documents", Object.keys(documents));
    socket.emit("document", doc);
  });

  // ...
});

С событием addDoc полезной нагрузкой является объект document, который на данный момент состоит только из идентификатора, сгенерированного клиентом. Мы сообщаем нашему сокету присоединиться к комнате с этим идентификатором, чтобы любые будущие изменения могли транслироваться всем в той же комнате.

Затем мы хотим, чтобы все, кто подключен к нашему серверу, знали, что есть новый документ для работы, поэтому мы транслируем его всем клиентам с помощью функции io.emit(documents, ...).

Обратите внимание на разницу между socket.emit() и io.emit() — версия socket предназначена для отправки обратно только для инициации клиента, версия io предназначена для отправки всем, кто подключен к нашему серверу.

редактировать документ

Давайте поработаем над третьим типом событий — editDoc:

io.on("connection", socket => {
  // ...

  socket.on("editDoc", doc => {
    documents[doc.id] = doc;
    socket.to(doc.id).emit("document", doc);
  });

  // ...
});

С событием editDoc полезной нагрузкой будет весь документ в его состоянии после любого нажатия клавиши. Мы заменим существующий документ в базе данных, а затем транслируем новый документ только тем клиентам, которые в данный момент просматривают этот документ. Мы делаем это, вызывая socket.to(doc.id).emit(document, doc), который излучает во все сокеты в этой конкретной комнате.

Наконец, всякий раз, когда устанавливается новое соединение, мы транслируем его всем клиентам, чтобы убедиться, что новое соединение получает последние изменения документа при подключении:

io.on("connection", socket => {
  // ...

  io.emit("documents", Object.keys(documents));

  console.log(`Socket ${socket.id} has connected`);
});

После того, как все функции сокета настроены, выберите порт и прослушайте его:

http.listen(4444, () => {
  console.log('Listening on port 4444');
});

Выполните следующую команду в своем терминале, чтобы запустить сервер:

  1. node src/app.js

Теперь у нас есть полноценный сокет-сервер для совместной работы с документами!

Шаг 2 — Установка @angular/cli и создание клиентского приложения

Откройте новое окно терминала и перейдите в каталог проекта.

Выполните следующие команды, чтобы установить Angular CLI как devDependency:

  1. npm install @angular/cli@10.0.4 --save-dev

Теперь используйте команду @angular/cli, чтобы создать новый проект Angular без Angular Routing и с использованием SCSS для стилей:

  1. ng new socket-app --routing=false --style=scss

Затем перейдите в каталог сервера:

  1. cd socket-app

Теперь мы установим наши зависимости пакета:

  1. npm install ngx-socket-io@3.2.0 --save

ngx-socket-io — это оболочка Angular для клиентских библиотек Socket.IO.

Затем используйте команду @angular/cli для создания модели document, компонента document-list, document и сервис document:

  1. ng generate class models/document --type=model
  2. ng generate component components/document-list
  3. ng generate component components/document
  4. ng generate service services/document

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

Модуль приложения

Откройте app.modules.ts:

  1. nano src/app/app.module.ts

И импортируйте FormsModule, SocketioModule и SocketioConfig:

// ... other imports
import { FormsModule } from '@angular/forms';
import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';

И перед объявлением @NgModule определите config:

const config: SocketIoConfig = { url: 'http://localhost:4444', options: {} };

Вы заметите, что это номер порта, который мы объявили ранее в app.js сервера.

Теперь добавьте в свой массив imports, чтобы он выглядел так:

@NgModule({
  // ...
  imports: [
    // ...
    FormsModule,
    SocketIoModule.forRoot(config)
  ],
  // ...
})

Это отключит соединение с нашим сервером сокетов, как только загрузится AppModule.

Модель документа и служба документов

Откройте document.model.ts:

  1. nano src/app/models/document.model.ts

И определите id и doc:

export class Document {
  id: string;
  doc: string;
}

Откройте document.service.ts:

  1. nano src/app/services/document.service.ts

И добавьте следующее в определение класса:

import { Injectable } from '@angular/core';
import { Socket } from 'ngx-socket-io';
import { Document } from 'src/app/models/document.model';

@Injectable({
  providedIn: 'root'
})
export class DocumentService {
  currentDocument = this.socket.fromEvent<Document>('document');
  documents = this.socket.fromEvent<string[]>('documents');

  constructor(private socket: Socket) { }

  getDocument(id: string) {
    this.socket.emit('getDoc', id);
  }

  newDocument() {
    this.socket.emit('addDoc', { id: this.docId(), doc: '' });
  }

  editDocument(document: Document) {
    this.socket.emit('editDoc', document);
  }

  private docId() {
    let text = '';
    const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

    for (let i = 0; i < 5; i++) {
      text += possible.charAt(Math.floor(Math.random() * possible.length));
    }

    return text;
  }
}

Методы здесь представляют каждый из трех типов событий, которые прослушивает сервер сокетов. Свойства currentDocument и documents представляют события, генерируемые сервером сокетов, которые используются клиентом как Observable.

Вы можете заметить вызов this.docId(). Это небольшой частный метод, который генерирует случайную строку для назначения в качестве идентификатора документа.

Компонент списка документов

Поместим список документов в сиденав. Прямо сейчас он показывает только docId — случайную строку символов.

Откройте document-list.component.html:

  1. nano src/app/components/document-list/document-list.component.html

И замените содержимое следующим:

<div class='sidenav'>
    <span
      (click)='newDoc()'
    >
      New Document
    </span>
    <span
      [class.selected]='docId === currentDoc'
      (click)='loadDoc(docId)'
      *ngFor='let docId of documents | async'
    >
      {{ docId }}
    </span>
</div>

Откройте document-list.component.scss:

  1. nano src/app/components/document-list/document-list.component.scss

И добавьте несколько стилей:

.sidenav {
  background-color: #111111;
  height: 100%;
  left: 0;
  overflow-x: hidden;
  padding-top: 20px;
  position: fixed;
  top: 0;
  width: 220px;

  span {
    color: #818181;
    display: block;
    font-family: 'Roboto', Tahoma, Geneva, Verdana, sans-serif;
    font-size: 25px;
    padding: 6px  8px  6px  16px;
    text-decoration: none;

    &.selected {
      color: #e1e1e1;
    }

    &:hover {
      color: #f1f1f1;
      cursor: pointer;
    }
  }
}

Откройте document-list.component.ts:

  1. nano src/app/components/document-list/document-list.component.ts

И добавьте следующее в определение класса:

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Observable, Subscription } from 'rxjs';

import { DocumentService } from 'src/app/services/document.service';

@Component({
  selector: 'app-document-list',
  templateUrl: './document-list.component.html',
  styleUrls: ['./document-list.component.scss']
})
export class DocumentListComponent implements OnInit, OnDestroy {
  documents: Observable<string[]>;
  currentDoc: string;
  private _docSub: Subscription;

  constructor(private documentService: DocumentService) { }

  ngOnInit() {
    this.documents = this.documentService.documents;
    this._docSub = this.documentService.currentDocument.subscribe(doc => this.currentDoc = doc.id);
  }

  ngOnDestroy() {
    this._docSub.unsubscribe();
  }

  loadDoc(id: string) {
    this.documentService.getDocument(id);
  }

  newDoc() {
    this.documentService.newDocument();
  }
}

Начнем со свойств. documents будет потоком всех доступных документов. currentDocId — идентификатор текущего выбранного документа. Список документов должен знать, в каком документе мы находимся, поэтому мы можем выделить этот идентификатор документа в боковой панели. _docSub — это ссылка на Subscription, которая дает нам текущий или выбранный документ. Нам это нужно, чтобы мы могли отказаться от подписки в методе жизненного цикла ngOnDestroy.

Вы заметите, что методы loadDoc() и newDoc() ничего не возвращают и не присваивают. Помните, что эти события отправляются на сервер сокетов, который разворачивается и запускает событие обратно в наши Observables. Возвращаемые значения для получения существующего документа или добавления нового документа реализуются из приведенных выше шаблонов Observable.

Компонент документа

Это будет поверхность редактирования документа.

Откройте document.component.html:

  1. nano src/app/components/document/document.component.html

И замените содержимое следующим:

<textarea
  [(ngModel)]='document.doc'
  (keyup)='editDoc()'
  placeholder='Start typing...'
></textarea>

Откройте document.component.scss:

  1. nano src/app/components/document/document.component.scss

И измените некоторые стили HTML textarea по умолчанию:

textarea {
  border: none;
  font-size: 18pt;
  height: 100%;
  padding: 20px  0  20px  15px;
  position: fixed;
  resize: none;
  right: 0;
  top: 0;
  width: calc(100% - 235px);
}

Откройте document.component.ts:

  1. src/app/components/document/document.component.ts

И добавьте следующее в определение класса:

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { startWith } from 'rxjs/operators';

import { Document } from 'src/app/models/document.model';
import { DocumentService } from 'src/app/services/document.service';

@Component({
  selector: 'app-document',
  templateUrl: './document.component.html',
  styleUrls: ['./document.component.scss']
})
export class DocumentComponent implements OnInit, OnDestroy {
  document: Document;
  private _docSub: Subscription;

  constructor(private documentService: DocumentService) { }

  ngOnInit() {
    this._docSub = this.documentService.currentDocument.pipe(
      startWith({ id: '', doc: 'Select an existing document or create a new one to get started' })
    ).subscribe(document => this.document = document);
  }

  ngOnDestroy() {
    this._docSub.unsubscribe();
  }

  editDoc() {
    this.documentService.editDocument(this.document);
  }
}

Подобно шаблону, который мы использовали в DocumentListComponent выше, мы собираемся подписаться на изменения для нашего текущего документа и запускать событие на сервер сокетов всякий раз, когда мы изменяем текущий документ. Это означает, что мы увидим все изменения, если какой-либо другой клиент редактирует тот же документ, что и мы, и наоборот. Мы используем оператор RxJS startWith, чтобы дать нашим пользователям небольшое сообщение, когда они впервые открывают приложение.

AppComponent

Откройте app.component.html:

  1. nano src/app.component.html

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

<app-document-list></app-document-list>
<app-document></app-document>

Шаг 3 — Просмотр приложения в действии

Пока наш сервер сокетов все еще работает в окне терминала, давайте откроем новое окно терминала и запустим наше приложение Angular:

  1. ng serve

Откройте несколько экземпляров http://localhost:4200 в отдельных вкладках браузера и посмотрите, как они работают.

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

Заключение

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

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

Если вы хотите узнать больше об интеграции Vue.js и Socket.IO.

Другие проекты WebSocket включают приложения для чата в реальном времени. См. Как создать приложение для чата в реальном времени с помощью React и GraphQL.