简介
WebSocket是允许服务器和客户端之间进行全双工通信的互联网协议。该协议超越了典型的HTTP请求和响应范例。使用WebSockets,服务器可以在客户端不发起请求的情况下向客户端发送数据,从而允许一些非常有趣的应用程序。
在本教程中,您将构建一个实时文档协作应用程序(类似于Google Docs)。我们将使用Socket.IONode.js服务器框架和ANGLE 7]来实现这一点。
您可以在GitHub上找到完整的示例项目的源代码。
前提条件
要完成本教程,您需要:
- 本地安装node.js,可按照如何安装node.js并创建本地开发Environment.
- 支持WebSocket.]的现代网络浏览器
本教程最初是在Node.js v8.11.4、npm v6.4.1和Angular v7.0.4组成的环境中编写的。
本教程使用NodeV14.6.0、NPMv6.14.7、ANGLING v10.0.5和Socket.IO v2.3.0进行了验证。
第一步-设置项目目录并创建Socket服务器
首先,打开您的终端并创建一个新的项目目录,该目录将包含我们的服务器和客户端代码:
1mkdir socket-example
接下来,切换到项目目录:
1cd socket-example
然后,为服务器代码创建一个新目录:
1mkdir socket-server
接下来,切换到服务器目录。
1cd socket-server
然后,初始化一个新的npm
项目:
1npm init -y
现在,我们将安装我们的包依赖项:
1npm install [email protected] [email protected] @types/[email protected] --save
这些包包括Express、Socket.IO和@type/socket.io
。
现在您已经完成了项目的设置,您可以继续为服务器编写代码了。
首先,新建一个src
目录:
1mkdir src
现在,在src
目录中创建一个名为app.js
的新文件,并使用您喜欢的文本编辑器打开它:
1nano src/app.js
从Express和Socket.IO的require
语句开始:
1[label socket-server/src/app.js]
2const app = require('express')();
3const http = require('http').Server(app);
4const io = require('socket.io')(http);
如您所知,我们正在使用Express和Socket.IO来设置我们的服务器。IO在本地WebSocket之上提供了一层抽象层。它附带了一些很好的功能,比如针对不支持WebSocket的旧浏览器的后备机制,以及创建_Room_的能力。我们将立即看到这一点的实际效果。
为了实现我们的实时文档协作应用,我们需要一种存储文档‘的方法。在生产环境中,您可能希望使用数据库,但在本教程的范围内,我们将使用
Documents`的内存存储:
1[label socket-server/src/app.js]
2const documents = {};
现在,让我们定义我们希望socket服务器实际做的事情:
1[label socket-server/src/app.js]
2io.on("connection", socket => {
3 // ...
4});
我们来分析一下。)`是一个事件监听器。第一个参数是事件的名称,第二个参数通常是在事件触发时执行的回调,带有事件负载。
我们看到的第一个示例是当客户端连接到套接字服务器时(Connection
是Socket.IO中的保留事件类型)。
我们得到一个socket
变量来传递给我们的回调,以启动与该一个套接字或多个套接字的通信(即,广播)。
SafeJoin
我们将设置一个本地函数(safeJoin
)来处理加入和离开_rooms_:
1[label socket-server/src/app.js]
2io.on("connection", socket => {
3 let previousId;
4
5 const safeJoin = currentId => {
6 socket.leave(previousId);
7 socket.join(currentId, () => console.log(`Socket ${socket.id} joined room ${currentId}`));
8 previousId = currentId;
9 };
10
11 // ...
12});
在这种情况下,当客户加入聊天室时,他们正在编辑特定文档。因此,如果多个客户端在同一房间中,则它们都在编辑同一文档。
从技术上讲,一个套接字可以在多个房间里,但我们不想让一个客户同时编辑多个文档,所以如果他们交换文档,我们需要离开以前的房间,加入新的房间。这个小函数可以解决这个问题。
套接字从客户端监听的事件类型有三种:
==同步,由Elderman更正==@elder_man ==同步,由Elderman更正==@elder_man ==同步,由Elderman更正==@elder_man
以及由套接字向客户端发出的两种事件类型:
- `文档‘
- `单据‘
Колибрипрограмма
让我们来看看第一个事件类型--getDoc
:
1[label socket-server/src/app.js]
2io.on("connection", socket => {
3 // ...
4
5 socket.on("getDoc", docId => {
6 safeJoin(docId);
7 socket.emit("document", documents[docId]);
8 });
9
10 // ...
11});
当客户端发出getDoc
事件时,套接字将接受负载(在我们的例子中,它只是一个id),使用该docId
进入房间,并将存储的Docent
发回给发起客户端。这就是socket.emit(‘文档’,...)
发挥作用的地方。
addDoc
让我们来看看第二个事件类型--addDoc
:
1[label socket-server/src/app.js]
2io.on("connection", socket => {
3 // ...
4
5 socket.on("addDoc", doc => {
6 documents[doc.id] = doc;
7 safeJoin(doc.id);
8 io.emit("documents", Object.keys(documents));
9 socket.emit("document", doc);
10 });
11
12 // ...
13});
在addDoc
事件中,负载是一个Docent
对象,目前只由客户端生成的id组成。我们告诉我们的套接字加入该ID的房间,以便将来的任何编辑都可以广播给同一房间中的任何人。
接下来,我们希望连接到服务器的每个人都知道有一个新文档要处理,所以我们用io.emit('documents',...)
广播给所有客户端。功能
请注意socket.emit()
和io.emit()
之间的区别-socket
版本用于只发送回初始化客户端,io
版本用于发送到连接到我们服务器的每个人。
帖子主题:Re:Колибри
让我们来看看第三个事件类型--editDoc
:
1[label socket-server/src/app.js]
2io.on("connection", socket => {
3 // ...
4
5 socket.on("editDoc", doc => {
6 documents[doc.id] = doc;
7 socket.to(doc.id).emit("document", doc);
8 });
9
10 // ...
11});
使用editDoc
事件,有效负载将是在任何重新加载之后处于其状态的整个文档。我们将替换数据库中的现有文档,然后将新文档仅广播给当前正在查看该文档的客户端。我们通过调用socket.to(doc.id).emit(document,doc)
来实现这一点,它向该特定房间中的所有套接字发出。
最后,每当建立新连接时,我们都会向所有客户端广播,以确保新连接在连接时收到最新的文档更改:
1[label socket-server/src/app.js]
2io.on("connection", socket => {
3 // ...
4
5 io.emit("documents", Object.keys(documents));
6
7 console.log(`Socket ${socket.id} has connected`);
8});
在所有套接字函数都设置好之后,选择一个端口并监听它:
1[label socket-server/src/app.js]
2http.listen(4444, () => {
3 console.log('Listening on port 4444');
4});
在您的终端中运行以下命令以启动服务器:
1node src/app.js
我们现在有了一个用于文档协作的全功能套接字服务器!
第二步-安装@Angel/cli
并创建客户端App
打开一个新的终端窗口并导航到项目目录。
运行以下命令将Angular CLI安装为devDeputy
:
1npm install @angular/[email protected] --save-dev
现在,使用@angular/sort
命令创建一个新的Angular项目,没有Angular Routing,使用SCSS进行样式化:
1ng new socket-app --routing=false --style=scss
然后,切换到服务器目录:
1cd socket-app
现在,我们将安装我们的包依赖项:
1npm install [email protected] --save
ngx-套接字-io
是Socket.IO客户端库上的角度包装。
然后,使用@angular/cli
命令生成一个Document
模型、一个Document-list
组件、一个Document
组件和一个Document
服务:
1ng generate class models/document --type=model
2ng generate component components/document-list
3ng generate component components/document
4ng generate service services/document
现在您已经完成了项目的设置,您可以继续为客户端编写代码了。
APP模块
打开app.modes.ts
:
1nano src/app/app.module.ts
并导入FormsModule
、SocketioModule
、SocketioConfig
:
1[label socket-app/src/app/app.module.ts]
2// ... other imports
3import { FormsModule } from '@angular/forms';
4import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';
在您的@NgModule
声明之前,定义config
:
1[label socket-app/src/app/app.module.ts]
2const config: SocketIoConfig = { url: 'http://localhost:4444', options: {} };
您会注意到这是我们之前在服务器的app.js
中声明的端口号。
现在,添加到你的imports
数组中,所以它看起来像:
1[label socket-app/src/app/app.module.ts]
2@NgModule({
3 // ...
4 imports: [
5 // ...
6 FormsModule,
7 SocketIoModule.forRoot(config)
8 ],
9 // ...
10})
这将在AppModule
加载后立即触发到我们的套接字服务器的连接。
文档模型和文档服务
打开Document.Model.ts
:
1nano src/app/models/document.model.ts
并定义id
和doc
:
1[label socket-app/src/app/models/document.model.ts]
2export class Document {
3 id: string;
4 doc: string;
5}
打开Docent.service.ts
:
1nano src/app/services/document.service.ts
并在类定义中添加以下内容:
1[label socket-app/src/app/services/document.service.ts]
2import { Injectable } from '@angular/core';
3import { Socket } from 'ngx-socket-io';
4import { Document } from 'src/app/models/document.model';
5
6@Injectable({
7 providedIn: 'root'
8})
9export class DocumentService {
10 currentDocument = this.socket.fromEvent<Document>('document');
11 documents = this.socket.fromEvent<string[]>('documents');
12
13 constructor(private socket: Socket) { }
14
15 getDocument(id: string) {
16 this.socket.emit('getDoc', id);
17 }
18
19 newDocument() {
20 this.socket.emit('addDoc', { id: this.docId(), doc: '' });
21 }
22
23 editDocument(document: Document) {
24 this.socket.emit('editDoc', document);
25 }
26
27 private docId() {
28 let text = '';
29 const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
30
31 for (let i = 0; i < 5; i++) {
32 text += possible.charAt(Math.floor(Math.random() * possible.length));
33 }
34
35 return text;
36 }
37}
这里的方法分别表示套接字服务器正在侦听的三种事件类型。属性CurrentDocument
和Documents
表示套接字服务器发出的事件,在客户端作为Observable
消费。
您可能会注意到对this.docID()
的调用。这是一个小的私有方法,它生成一个随机字符串以指定为文档ID。
文档列表组件
让我们将文档列表放在一个副标题中。目前,它只显示docId
--一个随机字符串。
打开DOCUMENT-LIS.COMPOMENT.html
:
1nano src/app/components/document-list/document-list.component.html
并将内容替换为以下内容:
1[label socket-app/src/app/components/document-list/document-list.component.html]
2<div class='sidenav'>
3 <span
4 (click)='newDoc()'
5 >
6 New Document
7 </span>
8 <span
9 [class.selected]='docId === currentDoc'
10 (click)='loadDoc(docId)'
11 *ngFor='let docId of documents | async'
12 >
13 {{ docId }}
14 </span>
15</div>
打开DOCUMENT-LIS.COMPOMENT.SCSS
:
1nano src/app/components/document-list/document-list.component.scss
并添加一些样式:
1[label socket-app/src/app/components/document-list/document-list.component.scss]
2.sidenav {
3 background-color: #111111;
4 height: 100%;
5 left: 0;
6 overflow-x: hidden;
7 padding-top: 20px;
8 position: fixed;
9 top: 0;
10 width: 220px;
11
12 span {
13 color: #818181;
14 display: block;
15 font-family: 'Roboto', Tahoma, Geneva, Verdana, sans-serif;
16 font-size: 25px;
17 padding: 6px 8px 6px 16px;
18 text-decoration: none;
19
20 &.selected {
21 color: #e1e1e1;
22 }
23
24 &:hover {
25 color: #f1f1f1;
26 cursor: pointer;
27 }
28 }
29}
打开DOCUMENT-LIS.COMPOMENT.ts
:
1nano src/app/components/document-list/document-list.component.ts
并在类定义中添加以下内容:
1[label socket-app/src/app/components/document-list/document-list.component.ts]
2import { Component, OnInit, OnDestroy } from '@angular/core';
3import { Observable, Subscription } from 'rxjs';
4
5import { DocumentService } from 'src/app/services/document.service';
6
7@Component({
8 selector: 'app-document-list',
9 templateUrl: './document-list.component.html',
10 styleUrls: ['./document-list.component.scss']
11})
12export class DocumentListComponent implements OnInit, OnDestroy {
13 documents: Observable<string[]>;
14 currentDoc: string;
15 private _docSub: Subscription;
16
17 constructor(private documentService: DocumentService) { }
18
19 ngOnInit() {
20 this.documents = this.documentService.documents;
21 this._docSub = this.documentService.currentDocument.subscribe(doc => this.currentDoc = doc.id);
22 }
23
24 ngOnDestroy() {
25 this._docSub.unsubscribe();
26 }
27
28 loadDoc(id: string) {
29 this.documentService.getDocument(id);
30 }
31
32 newDoc() {
33 this.documentService.newDocument();
34 }
35}
让我们从属性开始。Documents
将是所有可用文档的流。CurrentDocId
为当前选中单据的id。文档列表需要知道我们所在的文档,这样我们就可以在Sideav中突出显示该文档ID。_docSub
是对Subscription
的引用,该Subscription
为我们提供当前或选定的文档。我们需要这个,这样我们才能在`ngOnDestroy‘生命周期方法中取消订阅。
您会注意到loadDoc()
和newDoc()
方法不返回或赋值任何内容。请记住,这些函数向套接字服务器发送事件,套接字服务器转过身来向我们的观察点发送回一个事件。获取已有文档或添加新文档的返回值是通过上面的`Observable‘模式实现的。
文档组件
这将是文档编辑图面。
打开document.component.html
:
1nano src/app/components/document/document.component.html
并将内容替换为以下内容:
1[label socket-app/src/app/components/document/document.component.html]
2<textarea
3 [(ngModel)]='document.doc'
4 (keyup)='editDoc()'
5 placeholder='Start typing...'
6></textarea>
打开Docent.Component.scss
:
1nano src/app/components/document/document.component.scss
并在默认的HTMLtextarea
上更改一些样式:
1[label socket-app/src/app/components/document/document.component.scss]
2textarea {
3 border: none;
4 font-size: 18pt;
5 height: 100%;
6 padding: 20px 0 20px 15px;
7 position: fixed;
8 resize: none;
9 right: 0;
10 top: 0;
11 width: calc(100% - 235px);
12}
打开Document.Component.ts
:
1src/app/components/document/document.component.ts
并在类定义中添加以下内容:
1[label socket-app/src/app/components/document/document.component.ts]
2import { Component, OnInit, OnDestroy } from '@angular/core';
3import { Subscription } from 'rxjs';
4import { startWith } from 'rxjs/operators';
5
6import { Document } from 'src/app/models/document.model';
7import { DocumentService } from 'src/app/services/document.service';
8
9@Component({
10 selector: 'app-document',
11 templateUrl: './document.component.html',
12 styleUrls: ['./document.component.scss']
13})
14export class DocumentComponent implements OnInit, OnDestroy {
15 document: Document;
16 private _docSub: Subscription;
17
18 constructor(private documentService: DocumentService) { }
19
20 ngOnInit() {
21 this._docSub = this.documentService.currentDocument.pipe(
22 startWith({ id: '', doc: 'Select an existing document or create a new one to get started' })
23 ).subscribe(document => this.document = document);
24 }
25
26 ngOnDestroy() {
27 this._docSub.unsubscribe();
28 }
29
30 editDoc() {
31 this.documentService.editDocument(this.document);
32 }
33}
与我们在上面的DocumentListComponent
中使用的模式类似,我们将订阅当前文档的更改,并在更改当前文档时向套接字服务器触发事件。这意味着,如果其他客户端正在编辑与我们相同的文档,我们将看到所有更改,反之亦然。我们使用RxJS 'startWith'操作符在用户第一次打开应用程序时给他们一个小消息。
AppComponent
打开app.Component.html
:
1nano src/app.component.html
并通过将内容替换为以下内容来组合这两个自定义组件:
1[label socket-app/src/app.component.html]
2<app-document-list></app-document-list>
3<app-document></app-document>
第三步-查看正在运行的App
在我们的Socket服务器仍在终端窗口中运行的情况下,让我们打开一个新的终端窗口并启动我们的角度应用程序:
1ng serve
在不同的浏览器选项卡中打开多个http://localhost:4200
实例,并查看其运行情况。
的实时文档协作应用
现在,您可以在两个浏览器窗口中创建新文档并查看它们的更新。您可以在一个浏览器窗口中进行更改,并在另一个浏览器窗口中看到所做的更改。
结论
在本教程中,您已经完成了使用WebSocket的初步探索。您使用它构建了一个实时文档协作应用程序。它支持多个浏览器会话以连接到服务器并更新和修改多个文档。
如果您想了解更多有关ANGLE的信息,请查看我们的ANGLE主题页面以获取练习和编程项目。
如果您想了解有关Socket.IO的更多信息,请查看集成Vue.js和Socket.IO.
其他WebSocket项目包括实时聊天应用程序。请参阅如何使用Reaction和GraphQL.构建实时聊天应用