Как создать приложение реального времени с помощью 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 — Настройка каталога проекта и создание сокет-сервера
Сначала откройте свой терминал и создайте новый каталог проекта, в котором будет храниться код нашего сервера и клиента:
- mkdir socket-example
Затем перейдите в каталог проекта:
- cd socket-example
Затем создайте новый каталог для кода сервера:
- mkdir socket-server
Затем перейдите в каталог сервера.
- cd socket-server
Затем инициализируйте новый проект npm
:
- npm init -y
Теперь мы установим наши зависимости пакета:
- 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
:
- mkdir src
Теперь создайте новый файл с именем app.js
в каталоге src
и откройте его в своем любимом текстовом редакторе:
- 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');
});
Выполните следующую команду в своем терминале, чтобы запустить сервер:
- node src/app.js
Теперь у нас есть полноценный сокет-сервер для совместной работы с документами!
Шаг 2 — Установка @angular/cli и создание клиентского приложения
Откройте новое окно терминала и перейдите в каталог проекта.
Выполните следующие команды, чтобы установить Angular CLI как devDependency
:
- npm install @angular/cli@10.0.4 --save-dev
Теперь используйте команду @angular/cli
, чтобы создать новый проект Angular без Angular Routing и с использованием SCSS для стилей:
- ng new socket-app --routing=false --style=scss
Затем перейдите в каталог сервера:
- cd socket-app
Теперь мы установим наши зависимости пакета:
- npm install ngx-socket-io@3.2.0 --save
ngx-socket-io
— это оболочка Angular для клиентских библиотек Socket.IO.
Затем используйте команду @angular/cli
для создания модели document
, компонента document-list
, document
и сервис document
:
- ng generate class models/document --type=model
- ng generate component components/document-list
- ng generate component components/document
- ng generate service services/document
Теперь, когда вы завершили настройку проекта, вы можете перейти к написанию кода для клиента.
Модуль приложения
Откройте app.modules.ts
:
- 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
:
- nano src/app/models/document.model.ts
И определите id
и doc
:
export class Document {
id: string;
doc: string;
}
Откройте document.service.ts
:
- 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
:
- 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
:
- 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
:
- 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
:
- nano src/app/components/document/document.component.html
И замените содержимое следующим:
<textarea
[(ngModel)]='document.doc'
(keyup)='editDoc()'
placeholder='Start typing...'
></textarea>
Откройте document.component.scss
:
- 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
:
- 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
:
- nano src/app.component.html
И скомпонуйте два пользовательских компонента, заменив содержимое следующим:
<app-document-list></app-document-list>
<app-document></app-document>
Шаг 3 — Просмотр приложения в действии
Пока наш сервер сокетов все еще работает в окне терминала, давайте откроем новое окно терминала и запустим наше приложение Angular:
- ng serve
Откройте несколько экземпляров http://localhost:4200
в отдельных вкладках браузера и посмотрите, как они работают.
Теперь вы можете создавать новые документы и наблюдать за их обновлением в обоих окнах браузера. Вы можете внести изменения в одном окне браузера и увидеть изменение, отраженное в другом окне браузера.
Заключение
В этом руководстве вы завершили начальное изучение использования WebSocket. Вы использовали его для создания приложения для совместной работы с документами в реальном времени. Он поддерживает несколько сеансов браузера для подключения к серверу и обновления и изменения нескольких документов.
Если вы хотите узнать больше об Angular, ознакомьтесь с нашей темой по Angular, где вы найдете упражнения и проекты по программированию.
Если вы хотите узнать больше об интеграции Vue.js и Socket.IO.
Другие проекты WebSocket включают приложения для чата в реальном времени. См. Как создать приложение для чата в реальном времени с помощью React и GraphQL.