Socket.IO、Angular、Node.jsを使用してリアルタイムアプリを作成する方法
序章
WebSocketは、サーバーとクライアント間の全二重通信を可能にするインターネットプロトコルです。 このプロトコルは、一般的なHTTP要求および応答パラダイムを超えています。 WebSocketを使用すると、サーバーはクライアントが要求を開始せずにデータをクライアントに送信できるため、非常に興味深いアプリケーションが可能になります。
このチュートリアルでは、リアルタイムのドキュメントコラボレーションアプリケーション(Googleドキュメントと同様)を作成します。 これを実現するために、 Socket.IONode.jsサーバーフレームワークとAngular7を使用します。
このサンプルプロジェクトの完全なソースコードは、GitHubにあります。
前提条件
このチュートリアルを完了するには、次のものが必要です。
- Node.jsはローカルにインストールされます。これは、Node.jsのインストール方法とローカル開発環境の作成に従って実行できます。
- がWebSocketをサポートする最新のWebブラウザー。
このチュートリアルは元々、Node.js v8.11.4、npm v6.4.1、およびAngularv7.0.4で構成される環境で作成されました。
このチュートリアルは、Node v14.6.0、npm v6.14.7、Angular v10.0.5、および Socket.IOv2.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
次に、src
ディレクトリにapp.js
という名前の新しいファイルを作成し、お気に入りのテキストエディタを使用して開きます。
- nano src/app.js
ExpressおよびSocket.IOの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 = {};
それでは、ソケットサーバーに実際に実行させたいことを定義しましょう。
io.on("connection", socket => {
// ...
});
これを分解しましょう。 .on('...')
はイベントリスナーです。 最初のパラメーターはイベントの名前であり、2番目のパラメーターは通常、イベントの発生時にイベントペイロードを使用して実行されるコールバックです。
最初に表示される例は、クライアントがソケットサーバーに接続する場合です(connection
は、 Socket.IO の予約済みイベントタイプです)。
socket
変数を取得してコールバックに渡し、その1つのソケットまたは複数のソケット(つまり、ブロードキャスト)への通信を開始します。
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;
};
// ...
});
この場合、クライアントが部屋に参加すると、特定のドキュメントを編集します。 したがって、複数のクライアントが同じ部屋にいる場合、それらはすべて同じドキュメントを編集しています。
技術的には、ソケットは複数の部屋に配置できますが、1人のクライアントが同時に複数のドキュメントを編集できるようにしたくないため、ドキュメントを切り替える場合は、前の部屋を離れて新しい部屋に参加する必要があります。 この小さな関数がそれを処理します。
ソケットがクライアントからリッスンするイベントタイプは3つあります。
getDoc
addDoc
editDoc
そして、ソケットからクライアントに発行される2つのイベントタイプ:
document
documents
getDoc
最初のイベントタイプに取り組みましょう-getDoc
:
io.on("connection", socket => {
// ...
socket.on("getDoc", docId => {
safeJoin(docId);
socket.emit("document", documents[docId]);
});
// ...
});
クライアントがgetDoc
イベントを発行すると、ソケットはペイロード(この場合は単なるID)を受け取り、そのdocId
でルームに参加し、保存されたdocument
開始クライアントのみに戻ります。 そこでsocket.emit('document', ...)
が登場します。
addDoc
2番目のイベントタイプである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のみで構成されています。 そのIDの部屋に参加するようにソケットに指示し、将来の編集を同じ部屋の誰にでもブロードキャストできるようにします。
次に、サーバーに接続しているすべての人に、操作する新しいドキュメントがあることを知らせてもらいたいので、io.emit('documents', ...)
機能を使用してすべてのクライアントにブロードキャストします。
socket.emit()
とio.emit()
の違いに注意してください。socket
バージョンはクライアントの開始のみに放出するためのものであり、io
バージョンは接続されているすべての人に放出するためのものです。私たちのサーバーに。
editDoc
3番目のイベントタイプである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
のインストールとクライアントアプリの作成
新しいターミナルウィンドウを開き、プロジェクトディレクトリに移動します。
次のコマンドを実行して、AngularCLIをdevDependency
としてインストールします。
- npm install @angular/cli@10.0.4 --save-dev
次に、@angular/cli
コマンドを使用して、Angular Routingを使用せず、スタイル設定にSCSSを使用して新しいAngularプロジェクトを作成します。
- 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
をインポートします。
// ... 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;
}
}
ここでのメソッドは、ソケットサーバーがリッスンしている3つのイベントタイプをそれぞれ発行します。 プロパティcurrentDocument
およびdocuments
は、クライアントでObservable
として消費されるソケットサーバーによって発行されたイベントを表します。
this.docId()
への呼び出しに気付くかもしれません。 これは、ドキュメントIDとして割り当てるランダムな文字列を生成する小さなプライベートメソッドです。
ドキュメントリストコンポーネント
ドキュメントのリストをサイドナビに入れましょう。 現在、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()
は、何も返さないか、割り当てないことに気付くでしょう。 これらはソケットサーバーにイベントを発生させ、それが向きを変えて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
そして、デフォルトのHTMLtextarea
のいくつかのスタイルを変更します。
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
そして、コンテンツを次のように置き換えて、2つのカスタムコンポーネントを作成します。
<app-document-list></app-document-list>
<app-document></app-document>
ステップ3—動作中のアプリを表示する
ソケットサーバーがまだターミナルウィンドウで実行されている状態で、新しいターミナルウィンドウを開いて、Angularアプリを起動しましょう。
- ng serve
http://localhost:4200
の複数のインスタンスを別々のブラウザタブで開き、動作を確認します。
これで、新しいドキュメントを作成して、両方のブラウザウィンドウで更新されるのを確認できます。 一方のブラウザウィンドウで変更を加え、その変更がもう一方のブラウザウィンドウに反映されていることを確認できます。
結論
このチュートリアルでは、WebSocketの使用に関する最初の調査を完了しました。 これを使用して、リアルタイムのドキュメントコラボレーションアプリケーションを構築しました。 サーバーに接続し、複数のドキュメントを更新および変更するための複数のブラウザセッションをサポートします。
Angularについて詳しく知りたい場合は、Angularトピックページで演習とプログラミングプロジェクトを確認してください。
Socket.IO の詳細については、IntegratingVue.jsとSocket.IOをご覧ください。
その他のWebSocketプロジェクトには、リアルタイムチャットアプリケーションが含まれます。 ReactとGraphQLを使用してリアルタイムチャットアプリを構築する方法を参照してください。