如何使用 Socket.IO、Angular 和 Node.js 创建实时应用程序
作者:mmseoamin日期:2024-03-04

介绍

WebSocket 是一种允许服务器客户端之间进行全双工通信的互联网协议。该协议超越了典型的 HTTP 请求和响应范式。通过 WebSocket,服务器可以向客户端发送数据,而无需客户端发起请求,因此可以实现一些非常有趣的应用程序。

在本教程中,您将构建一个实时文档协作应用程序(类似于 Google Docs)。我们将使用 Socket.IO Node.js 服务器框架和 Angular 7 来实现这一目标。

您可以在 GitHub 上找到此示例项目的完整源代码。

先决条件

要完成本教程,您需要:

  • 在本地安装 Node.js,您可以按照《如何安装 Node.js 并创建本地开发环境》中的步骤进行操作。
  • 一个支持 WebSocket 的现代 Web 浏览器。

    本教程最初是在 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 — 设置项目目录并创建 Socket 服务器

    首先,打开您的终端并创建一个新的项目目录,该目录将包含我们的服务器和客户端代码:

    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
    

    现在,在 src 目录中创建一个名为 app.js 的新文件,并使用您喜欢的文本编辑器打开它:

    nano src/app.js
    

    从 Express 和 Socket.IO 开始编写 app.js 文件的 require 语句:

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

    正如您所看到的,我们使用 Express 和 Socket.IO 来设置我们的服务器。Socket.IO 提供了对原生 WebSocket 的抽象层。它带有一些很好的功能,例如对不支持 WebSocket 的旧版浏览器的回退机制,以及创建“房间”的能力。我们将在下一步中看到这一点。

    对于我们的实时文档协作应用程序,我们将需要一种存储 documents 的方式。在生产环境中,您可能希望使用数据库,但在本教程的范围内,我们将使用一个存储 documents 的内存存储:

    const documents = {};
    

    现在,让我们定义我们希望我们的 socket 服务器实际执行的操作:

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

    让我们来分解一下。.on('...') 是一个事件监听器。第一个参数是事件的名称,第二个参数通常是在事件触发时执行的回调函数,带有事件负载。

    我们首先看到的示例是当客户端连接到 socket 服务器时(connection 是 Socket.IO 中的保留事件类型)。

    我们获得一个 socket 变量,以便将其传递给我们的回调函数,以便与该 socket 或多个 socket(即广播)进行通信。

    safeJoin

    我们将设置一个本地函数(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;
      };
      // ...
    });
    

    在这种情况下,当客户端加入一个房间时,它们正在编辑特定的文档。因此,如果多个客户端在同一个房间中,它们都在编辑同一个文档。

    从技术上讲,一个 socket 可以在多个房间中,但我们不希望让一个客户端同时编辑多个文档,因此如果他们切换文档,我们需要离开先前的房间并加入新的房间。这个小函数负责处理这个问题。

    我们的 socket 正在监听来自客户端的三种事件类型:

    • getDoc
    • addDoc
    • editDoc

      以及从我们的 socket 发出的两种事件类型:

      • document
      • documents

        getDoc

        让我们来处理第一种事件类型 - getDoc:

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

        当客户端发出 getDoc 事件时,socket 将获取负载(在我们的情况下,它只是一个 id),加入具有该 docId 的房间,并将存储的 document 发送回发起请求的客户端。这就是 socket.emit('document', ...) 起作用的地方。

        addDoc

        让我们来处理第二种事件类型 - 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 对象,目前只包含客户端生成的 id。我们告诉我们的 socket 加入该 ID 的房间,以便将来的编辑可以广播给同一房间中的任何人。

        接下来,我们希望连接到我们的服务器的所有人都知道有一个新的文档可供使用,因此我们使用 io.emit('documents', ...) 函数向所有客户端广播。

        请注意 socket.emit() 和 io.emit() 之间的区别 - socket 版本用于仅向发起请求的客户端发出,io 版本用于向连接到我们的服务器的所有人发出。

        editDoc

        让我们来处理第三种事件类型 - 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) 来实现这一点,该方法会向该特定房间中的所有 socket 发出。

        最后,每当建立新连接时,我们向所有客户端广播,以确保新连接在连接时接收到最新的文档更改:

        io.on("connection", socket => {
          // ...
          io.emit("documents", Object.keys(documents));
          console.log(`Socket ${socket.id} has connected`);
        });
        

        在设置好 socket 函数之后,选择一个端口并在其上进行监听:

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

        在您的终端中运行以下命令以启动服务器:

        node src/app.js
        

        现在,我们已经拥有了一个完全功能的用于文档协作的 socket 服务器!

        步骤 2 — 安装 @angular/cli 并创建客户端应用

        打开一个新的终端窗口并导航到项目目录。

        运行以下命令将 Angular CLI 安装为 devDependency:

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

        现在,使用 @angular/cli 命令创建一个新的 Angular 项目,不使用 Angular 路由,并使用 SCSS 进行样式设置:

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

        然后,切换到服务器目录:

        cd socket-app
        

        现在,我们将安装我们的包依赖项:

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

        ngx-socket-io 是 Socket.IO 客户端库的 Angular 封装。

        然后,使用 @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:

        // ... 其他导入
        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 加载时触发与我们的 socket 服务器的连接。

        Document 模型和 Document 服务

        打开 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');
          documents = this.socket.fromEvent('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;
          }
        }
        

        这里的方法代表了 socket 服务器正在监听的三种事件类型的每个发射。currentDocument 和 documents 属性代表了 socket 服务器发射的事件,在客户端作为 Observable 进行消费。您可能会注意到对 this.docId() 的调用。这是一个小的私有方法,用于生成一个随机字符串,分配为文档 id。

        Document 列表组件

        让我们将文档列表放在一个侧边栏中。目前,它只显示 docId - 一串随机字符。

        打开 document-list.component.html:

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

        并用以下内容替换其中的内容:

        New Document {{ docId }}

        打开 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;
          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 是当前选定文档的 id。文档列表需要知道我们在哪个文档上,以便我们可以在侧边栏中突出显示该文档 id。_docSub 是给出当前或选定文档的 Subscription 的引用。我们需要这个引用,这样我们就可以在 ngOnDestroy 生命周期方法中取消订阅。

        您会注意到 loadDoc() 和 newDoc() 方法没有返回或分配任何内容。请记住,这些方法触发了 socket 服务器的事件,然后 socket 服务器会向我们的 Observables 发出事件。从上面的 Observable 模式中实现了获取现有文档或添加新文档的返回值。

        文档组件

        这将是文档编辑界面。

        打开 document.component.html:

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

        并用以下内容替换其中的内容:

        
        

        打开 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: '选择一个现有文档或创建一个新文档以开始' })
            ).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
        

        并通过以下内容替换其中的内容来组合两个自定义组件:

        
        
        

        步骤 3 —— 查看应用程序的运行情况

        在我们的套接字服务器仍在一个终端窗口中运行的情况下,让我们打开一个新的终端窗口并启动我们的 Angular 应用程序:

        ng serve
        

        在单独的浏览器标签中打开多个 http://localhost:4200 实例并查看其运行情况。

        !使用 Angular 和 Socket.IO 构建的实时文档协作应用程序

        现在,您可以创建新文档并在两个浏览器窗口中看到它们更新。您可以在一个浏览器窗口中进行更改,并在另一个浏览器窗口中看到更改的反映。

        结论

        在本教程中,您已经完成了对使用 WebSocket 的初步探索。您使用它构建了一个实时文档协作应用程序。它支持多个浏览器会话连接到服务器,并更新和修改多个文档。