序章

WebSocketは、サーバーとクライアント間の全二重通信を可能にするインターネットプロトコルです。 このプロトコルは、一般的なHTTP要求および応答パラダイムを超えています。 WebSocketを使用すると、サーバーはクライアントが要求を開始せずにデータをクライアントに送信できるため、非常に興味深いアプリケーションが可能になります。

このチュートリアルでは、リアルタイムのドキュメントコラボレーションアプリケーション(Googleドキュメントと同様)を作成します。 これを実現するために、 Socket.IONode.jsサーバーフレームワークとAngular7を使用します。

このサンプルプロジェクトの完全なソースコードは、GitHubにあります。

前提条件

このチュートリアルを完了するには、次のものが必要です。

このチュートリアルは元々、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—プロジェクトディレクトリの設定とソケットサーバーの作成

まず、ターミナルを開き、サーバーとクライアントの両方のコードを保持する新しいプロジェクトディレクトリを作成します。

  1. mkdir socket-example

次に、プロジェクトディレクトリに移動します。

  1. cd socket-example

次に、サーバーコード用の新しいディレクトリを作成します。

  1. mkdir socket-server

次に、サーバーディレクトリに移動します。

  1. cd socket-server

次に、新しいnpmプロジェクトを初期化します。

  1. npm init -y

次に、パッケージの依存関係をインストールします。

  1. 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ディレクトリを作成します。

  1. mkdir src

次に、srcディレクトリにapp.jsという名前の新しいファイルを作成し、お気に入りのテキストエディタを使用して開きます。

  1. nano src/app.js

ExpressおよびSocket.IOrequireステートメントから始めます。

socket-server / src / app.js
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のメモリ内ストアを使用します。

socket-server / src / app.js
const documents = {};

それでは、ソケットサーバーに実際に実行させたいことを定義しましょう。

socket-server / src / app.js
io.on("connection", socket => {
  // ...
});

これを分解しましょう。 .on('...')はイベントリスナーです。 最初のパラメーターはイベントの名前であり、2番目のパラメーターは通常、イベントの発生時にイベントペイロードを使用して実行されるコールバックです。

最初に表示される例は、クライアントがソケットサーバーに接続する場合です(connectionは、 Socket.IO の予約済みイベントタイプです)。

socket変数を取得してコールバックに渡し、その1つのソケットまたは複数のソケット(つまり、ブロードキャスト)への通信を開始します。

safeJoin

部屋への参加と退会を処理するローカル関数(safeJoin)を設定します。

socket-server / src / app.js
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

socket-server / src / app.js
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に取り組みましょう。

socket-server / src / app.js
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に取り組みましょう。

socket-server / src / app.js
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-server / src / app.js
io.on("connection", socket => {
  // ...

  io.emit("documents", Object.keys(documents));

  console.log(`Socket ${socket.id} has connected`);
});

ソケット機能がすべてセットアップされたら、ポートを選択してリッスンします。

socket-server / src / app.js
http.listen(4444, () => {
  console.log('Listening on port 4444');
});

ターミナルで次のコマンドを実行して、サーバーを起動します。

  1. node src/app.js

これで、ドキュメントコラボレーション用の完全に機能するソケットサーバーができました。

ステップ2—@angular/cliのインストールとクライアントアプリの作成

新しいターミナルウィンドウを開き、プロジェクトディレクトリに移動します。

次のコマンドを実行して、AngularCLIをdevDependencyとしてインストールします。

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

次に、@angular/cliコマンドを使用して、Angular Routingを使用せず、スタイル設定にSCSSを使用して新しいAngularプロジェクトを作成します。

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

次に、サーバーディレクトリに移動します。

  1. cd socket-app

次に、パッケージの依存関係をインストールします。

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

ngx-socket-ioは、Socket.IOクライアントライブラリのAngularラッパーです。

次に、@angular/cliコマンドを使用して、documentモデル、document-listコンポーネント、documentコンポーネント、およびdocumentサービスを生成します。

  1. ng generate class models/document --type=model
  2. ng generate component components/document-list
  3. ng generate component components/document
  4. ng generate service services/document

プロジェクトの設定が完了したので、クライアントのコードの記述に進むことができます。

アプリモジュール

app.modules.tsを開きます:

  1. nano src/app/app.module.ts

そして、FormsModuleSocketioModule、およびSocketioConfigをインポートします。

socket-app / src / app / app.module.ts
// ... other imports
import { FormsModule } from '@angular/forms';
import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';

そして、@NgModule宣言の前に、configを定義します。

socket-app / src / app / app.module.ts
const config: SocketIoConfig = { url: 'http://localhost:4444', options: {} };

これは、サーバーのapp.jsで以前に宣言したポート番号であることがわかります。

次に、imports配列に追加すると、次のようになります。

socket-app / src / app / app.module.ts
@NgModule({
  // ...
  imports: [
    // ...
    FormsModule,
    SocketIoModule.forRoot(config)
  ],
  // ...
})

これにより、AppModuleがロードされるとすぐに、ソケットサーバーへの接続が開始されます。

ドキュメントモデルとドキュメントサービス

document.model.tsを開きます:

  1. nano src/app/models/document.model.ts

そして、iddocを定義します。

socket-app / src / app / models / document.model.ts
export class Document {
  id: string;
  doc: string;
}

document.service.tsを開きます:

  1. nano src/app/services/document.service.ts

そして、クラス定義に以下を追加します。

socket-app / 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を開きます:

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

そして、内容を次のように置き換えます。

socket-app / 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を開きます:

  1. nano src/app/components/document-list/document-list.component.scss

そして、いくつかのスタイルを追加します。

socket-app / 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を開きます:

  1. nano src/app/components/document-list/document-list.component.ts

そして、クラス定義に以下を追加します。

socket-app / 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を開きます:

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

そして、内容を次のように置き換えます。

socket-app / src / app / components / document / document.component.html
<textarea
  [(ngModel)]='document.doc'
  (keyup)='editDoc()'
  placeholder='Start typing...'
></textarea>

document.component.scssを開きます:

  1. nano src/app/components/document/document.component.scss

そして、デフォルトのHTMLtextareaのいくつかのスタイルを変更します。

socket-app / src / app / components / document / document.component.scss
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を開きます:

  1. src/app/components/document/document.component.ts

そして、クラス定義に以下を追加します。

socket-app / 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を開きます:

  1. nano src/app.component.html

そして、コンテンツを次のように置き換えて、2つのカスタムコンポーネントを作成します。

socket-app / src / app.component.html
<app-document-list></app-document-list>
<app-document></app-document>

ステップ3—動作中のアプリを表示する

ソケットサーバーがまだターミナルウィンドウで実行されている状態で、新しいターミナルウィンドウを開いて、Angularアプリを起動しましょう。

  1. ng serve

http://localhost:4200の複数のインスタンスを別々のブラウザタブで開き、動作を確認します。

Real-time Document Collaboration app with Angular and Socket.IO

これで、新しいドキュメントを作成して、両方のブラウザウィンドウで更新されるのを確認できます。 一方のブラウザウィンドウで変更を加え、その変更がもう一方のブラウザウィンドウに反映されていることを確認できます。

結論

このチュートリアルでは、WebSocketの使用に関する最初の調査を完了しました。 これを使用して、リアルタイムのドキュメントコラボレーションアプリケーションを構築しました。 サーバーに接続し、複数のドキュメントを更新および変更するための複数のブラウザセッションをサポートします。

Angularについて詳しく知りたい場合は、Angularトピックページで演習とプログラミングプロジェクトを確認してください。

Socket.IO の詳細については、IntegratingVue.jsとSocket.IOをご覧ください。

その他のWebSocketプロジェクトには、リアルタイムチャットアプリケーションが含まれます。 ReactとGraphQLを使用してリアルタイムチャットアプリを構築する方法を参照してください。