介紹
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>('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;}
}
這里的方法代表了 socket 服務器正在監聽的三種事件類型的每個發射。currentDocument
和 documents
屬性代表了 socket 服務器發射的事件,在客戶端作為 Observable
進行消費。您可能會注意到對 this.docId()
的調用。這是一個小的私有方法,用于生成一個隨機字符串,分配為文檔 id。
Document 列表組件
讓我們將文檔列表放在一個側邊欄中。目前,它只顯示 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
是當前選定文檔的 id。文檔列表需要知道我們在哪個文檔上,以便我們可以在側邊欄中突出顯示該文檔 id。_docSub
是給出當前或選定文檔的 Subscription
的引用。我們需要這個引用,這樣我們就可以在 ngOnDestroy
生命周期方法中取消訂閱。
您會注意到 loadDoc()
和 newDoc()
方法沒有返回或分配任何內容。請記住,這些方法觸發了 socket 服務器的事件,然后 socket 服務器會向我們的 Observables 發出事件。從上面的 Observable
模式中實現了獲取現有文檔或添加新文檔的返回值。
文檔組件
這將是文檔編輯界面。
打開 document.component.html
:
nano src/app/components/document/document.component.html
并用以下內容替換其中的內容:
<textarea[(ngModel)]='document.doc'(keyup)='editDoc()'placeholder='開始輸入...'
></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: '選擇一個現有文檔或創建一個新文檔以開始' })).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
實例并查看其運行情況。
!使用 Angular 和 Socket.IO 構建的實時文檔協作應用程序
現在,您可以創建新文檔并在兩個瀏覽器窗口中看到它們更新。您可以在一個瀏覽器窗口中進行更改,并在另一個瀏覽器窗口中看到更改的反映。
結論
在本教程中,您已經完成了對使用 WebSocket 的初步探索。您使用它構建了一個實時文檔協作應用程序。它支持多個瀏覽器會話連接到服務器,并更新和修改多個文檔。